diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..ca32449 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,105 @@ +# AGENTS.md + +Read order: +1. `/AGENTS.md` +2. nearest nested `AGENTS.md` +3. `/docs/standards/infra/STYLE.md` (normative labels/naming/ports/images/resources — always read before any manifest work) +4. relevant `/docs/standards/infra/**` +5. relevant `/docs/examples/infra/**` +6. current request + +> **WARNING TO AI AGENTS**: You MUST READ the files in `/docs/standards/infra/` and `/docs/examples/infra/` using your file reading tools BEFORE proposing or writing any K8s manifests. DO NOT rely on generic Kubernetes knowledge. The standard documents contain mandatory strict rules (e.g., probe settings, security contexts, non-root constraints, resource limits, namespace strategies) that MUST be hardcoded into your output. Generating plain boilerplate YAML without strict standards compliance is a critical failure. + +Repo role: +- this repository owns Kubernetes/K3s infrastructure source +- source of truth is Git + Kustomize directories under `k8s/` +- scripts are helper tools, not the source of truth +- runtime cluster state or server-local manifest files are not authoritative + +Primary directories: +- `k8s/base/`: environment-neutral Kustomize bases +- `k8s/base/app/`: application-facing workload units +- `k8s/base/managing/`: management/operations units such as bootstrap, migration, backup, restore, and admin jobs +- `k8s/base/plugins/`: platform/plugin-style base resources +- `k8s/overlays//`: environment overlays such as `dev`, `staging`, and `prod` +- `k8s/scripts/`: render, diff, apply, validate, backup, and restore helpers +- `docs/standards/infra/`: infra standards +- `docs/examples/infra/`: approved examples + +Structure principle: +- `k8s/base` is organized by operational ownership and workload role, not by environment. +- `k8s/overlays` is organized by environment first, so large fleets can be rendered, diffed, applied, and audited environment-by-environment. +- This repo intentionally does not use the small-project “each service owns its own base and overlays” pattern as the primary layout. +- Service ownership still exists under `k8s/base/app///**`; environment rollout ownership lives under `k8s/overlays/`. + +Hard bans: +- do not treat `/var/lib/rancher/k3s/server/manifests` as source of truth +- do not edit K3s packaged component manifests directly +- do not put production secrets in Git/plain manifests +- do not bypass Kustomize with ad-hoc generated YAML as the primary path +- do not mix app rollout, DB migration, and control-plane upgrade in one opaque step +- do not expose health, metrics, admin, or management endpoints publicly by default +- do not use `hostPath` as an operating default +- do not use `start-dev`/dev-mode style configs for production components +- do not rely on default namespace for production workloads + +Global routing: +- environment / namespace / source-of-truth / K3s packaged components + -> `/docs/standards/infra/architecture-environments.md` +- config / secret / Vault delivery strategy + -> `/docs/standards/infra/config-and-secrets.md` +- workload kind selection + -> `/docs/standards/infra/workload-selection.md` +- storage / PVC / storage class / retention + -> `/docs/standards/infra/storage-pvc.md` +- network / service / ingress / TLS + -> `/docs/standards/infra/network-ingress-tls.md` +- resources / probes / quota / PDB / HPA + -> `/docs/standards/infra/resources-probes-availability.md` +- backup / restore + -> `/docs/standards/infra/backup-restore.md` +- security hardening / RBAC / network policy / pod security + -> `/docs/standards/infra/security-hardening.md` +- operational procedure / upgrade / rollback + -> `/docs/standards/infra/operations-runbook-upgrade-rollback.md` +- observability / health / metrics / logs + -> `/docs/standards/infra/observability-health.md` +- database / PostgreSQL / migration ownership / Flyway flow + -> `/docs/standards/infra/db-and-migration.md` +- K3s-only rules + -> `/docs/standards/infra/k3s-specific.md` +- scripts structure and shell rules + -> `/docs/standards/infra/scripts.md` +- Kustomize structure and overlay rules + -> `/docs/standards/infra/kustomize.md` + +Component routing: +- Keycloak + -> `/docs/standards/infra/keycloak.md` +- Vault + -> `/docs/standards/infra/vault.md` +- MinIO + -> `/docs/standards/infra/minio.md` +- Flyway + -> `/docs/standards/infra/flyway.md` + +Before editing: +- identify target environment: dev / staging / prod +- identify target unit: app / managing / plugin / script / docs +- identify whether the change belongs in `k8s/base`, `k8s/overlays/`, `k8s/scripts`, or docs +- identify workload type: Deployment / StatefulSet / Job / CronJob +- identify whether storage, secret delivery, ingress, migration, or rollback path changes +- identify whether the change touches a K3s-specific rule + +Default execution flow: +- read the owning standard first +- prefer changing Kustomize source under `k8s/` over live cluster state +- prefer render -> validate -> diff -> apply thinking +- prefer explicit rollback/restore path before risky changes + +If the request touches multiple areas, use this priority: +1. `k3s-specific.md` +2. `architecture-environments.md` +3. `db-and-migration.md` +4. the directly relevant component standard +5. supporting standards such as storage / security / operations / observability diff --git a/README.md b/README.md index ec10dbb..f241a2a 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,166 @@ -# project-infra +# Project-Infra +K3s 기반 백엔드 실행 환경을 Git으로 관리하는 개인 프로젝트입니다. +[Project-Auth-Server](https://github.com/donghyeon-ka/project-auth-server)가 실제로 실행될 때 필요한 인증 게이트, secret 전달, DB 마이그레이션, 네트워크 정책을 Kubernetes 리소스로 구성했습니다. + +이 프로젝트에서 확인하고 싶었던 질문은 네 가지입니다. + +- 로그인 / 세션 처리를 애플리케이션 밖으로 빼면 ingress와 backend의 책임은 어떻게 나뉘는가? +- secret을 Vault에 두면서도 Pod는 Kubernetes-native하게 실행할 수 있는가? +- default-deny NetworkPolicy 환경에서 백엔드 워크로드가 어떤 통신을 명시해야 하는가? +- dev에 집중하되, staging / prod 승격 시 달라질 정책 지점을 overlay 구조로 남길 수 있는가? + +| | | +|---|---| +| **Runtime** | K3s 1.30, Kustomize, Helm, Bash | +| **Ingress / Auth** | Traefik, oauth2-proxy, Keycloak Operator | +| **Secrets** | HashiCorp Vault, Vault Secrets Operator | +| **Data** | PostgreSQL, Flyway, MinIO, Docker Registry | +| **Policy** | NetworkPolicy default-deny, PSS Restricted | +| **Validation** | `kustomize build`, `kubeconform -strict`, `kube-linter` | +| **Scope** | dev overlay 중심. staging / prod는 의도적으로 미구현 | + +--- + +## Architecture + +![System Overview](docs/diagrams/architecture/01-overall.png) + +큰 흐름은 두 가지입니다. + +- 요청 흐름: `Traefik → oauth2-proxy → auth-server → PostgreSQL / MinIO` +- secret 흐름: `Vault → VSO → Kubernetes Secret → Pod envFrom` + +상세 다이어그램: + +- [ForwardAuth cold path](docs/diagrams/sequence/forward-auth-cold.md) / [warm path](docs/diagrams/sequence/forward-auth-warm.md) +- [Secret pipeline bootstrap](docs/diagrams/sequence/secret-pipeline-bootstrap.md) / [runtime reconcile](docs/diagrams/sequence/secret-pipeline-runtime.md) +- [전체 아키텍처 설명](docs/architecture.md) + +--- + +## Engineering Decisions + +### 1. 인증은 Ingress에서 먼저 막고, 백엔드는 JWT를 다시 검증 + +Traefik `ForwardAuth → oauth2-proxy → Keycloak` 조합으로 미인증 요청을 ingress 계층에서 먼저 차단합니다. +그 뒤 auth-server는 Keycloak JWT를 Spring Security Resource Server로 다시 검증합니다. + +이 구조는 로그인 / 세션 처리와 API 권한 검증을 분리하기 위한 선택입니다. 인증 정책 변경은 애플리케이션 코드보다 Kubernetes manifest 변경으로 다룰 수 있습니다. + +![인증·인가 흐름](docs/diagrams/architecture/02-auth-flow.png) + +### 2. Vault는 source of truth, Pod는 Kubernetes Secret만 소비 + +Pod마다 Vault Agent sidecar를 붙이지 않고, Vault Secrets Operator가 Vault KV 값을 Kubernetes Secret으로 동기화합니다. + +```text +Vault KV-v2 → VSO reconcile → Kubernetes Secret → Pod envFrom +``` + +Pod는 Vault endpoint, token, template rendering을 직접 알지 않습니다. 대신 secret이 Kubernetes Secret으로 존재하므로, etcd encryption-at-rest가 다음 검증 항목으로 남습니다. + +### 3. Vault role / policy는 도메인별로 분리 + +VSO Operator의 ServiceAccount는 하나지만, Vault 쪽 role과 policy는 `vso-auth-platform` / `vso-storage`로 나눴습니다. +각 `VaultStaticSecret`은 자기 도메인의 `VaultAuth`만 참조합니다. + +auth-platform 권한으로 storage secret 경로에 접근하지 못하게 하려는 결정입니다. + +상세 매핑은 [docs/vault-vso.md](docs/vault-vso.md#vaultauth--vaultstaticsecret-매핑)를 참고합니다. + +### 4. NetworkPolicy는 default-deny에서 시작 + +`mnt` namespace 안에서도 모든 Pod 간 ingress / egress를 기본 차단합니다. +새 워크로드를 추가하려면 필요한 통신을 NetworkPolicy로 명시해야 합니다. + +이 마찰은 의도한 것입니다. wide-open으로 시작하는 실수를 줄이고, 워크로드 간 통신 관계를 코드로 남기기 위함입니다. + +상세 매트릭스는 [docs/networking.md](docs/networking.md)를 참고합니다. + +### 5. K3s packaged Traefik manifest는 직접 수정하지 않음 + +K3s가 관리하는 Traefik manifest를 직접 수정하면 재부팅 / 재적용 시 덮어써질 수 있습니다. +그래서 `HelmChartConfig` overlay와 Middleware / TLSOption 리소스로 운영 정책을 관리합니다. + +![Traefik 파이프라인](docs/diagrams/architecture/07-traefik-pipeline.png) + +--- + +## Repository Layout + +Kustomize `base / components / overlays` 구조입니다. + +| Path | Role | +|---|---| +| `k8s/base/` | 환경 중립 매니페스트 | +| `k8s/components/` | 재사용 component. 현재 ForwardAuth component | +| `k8s/overlays/dev/` | 기본 dev 환경. 현재 ForwardAuth component 포함 | +| `k8s/overlays/{staging,prod}/` | 의도적으로 비워둔 승격 지점 | +| `k8s/scripts/` | bootstrap / validation / reusable tasks | +| `terraform/` | contracts만 존재. 추후 구현 | +| `docs/` | 상세 설계와 운영 문서 | + +--- + +## Validation + +```bash +bash k8s/scripts/ci/validate.sh +``` + +`validate.sh`는 주요 overlay에 대해 세 단계를 수행합니다. + +1. `kustomize build` — overlay 조립과 patch 유효성 확인 +2. `kubeconform -strict` — Kubernetes / CRD schema 확인 +3. `kube-linter` — securityContext, resources, image tag 등 정적 점검 + +목표 상태: + +```text +k8s/overlays/dev build=ok schema=ok lint=ok +k8s/overlays/dev/vso build=ok schema=ok lint=ok +``` + +운영 절차는 [docs/operations.md](docs/operations.md), 실행 매뉴얼은 [guide.md](guide.md)를 참고합니다. + +--- + +## Current Status + +| Area | Status | +|---|---| +| dev overlay 매니페스트 | 구성됨 (kustomize / kubeconform / kube-linter 검증 가능) | +| Vault + VSO 부트스트랩 | idempotent script 로 구성 | +| Traefik HelmChartConfig + Middleware | 구성됨 | +| NetworkPolicy default-deny | 구성됨 | +| ForwardAuth | dev overlay에 포함됨 | +| KeycloakRealmImport | overlay 준비됨. 실제 적용 결과 확인 필요 | +| cert-manager + ClusterIssuer | manifest 준비됨. 외부 DNS 필요 | +| staging / prod overlay | 의도적으로 비워둠 | +| Terraform | 디렉토리 contracts만 존재 | + +--- + +## Limitations + +운영 완료 상태가 아니라 dev 환경에서 실행 구조를 검증한 프로젝트입니다. + +- Vault는 file backend 단일 노드입니다. dev에서는 secret 전달 경로와 VSO reconcile을 검증하는 데 충분하다고 보고 선택했습니다. prod에서는 `raft` storage와 KMS auto-unseal로 전환해야 합니다. +- cert-manager / ClusterIssuer manifest는 있지만, 실제 ACME 인증서 발급은 외부 DNS가 Traefik 진입점을 가리켜야 완료됩니다. +- ForwardAuth 로그인 / 로그아웃 / deny-allow E2E 검증은 인증서와 DNS 정리 후 진행할 항목입니다. +- Postgres 백업, Terraform 실제 모듈, staging/prod overlay는 아직 구현하지 않았습니다. + +--- + +## Documentation + +- [docs/architecture.md](docs/architecture.md) — 전체 구조, 워크로드 표, 이미지 정책 +- [docs/networking.md](docs/networking.md) — NetworkPolicy 매트릭스 / 작성 규칙 +- [docs/ingress-traefik.md](docs/ingress-traefik.md) — Traefik, ForwardAuth, cert-manager, KeycloakRealmImport +- [docs/vault-vso.md](docs/vault-vso.md) — Vault auth, policy/role, VSO Secret catalog +- [docs/operations.md](docs/operations.md) — bootstrap 단계, validate.sh, 환경별 차등 +- [docs/troubleshooting.md](docs/troubleshooting.md) — 운영 중 만난 함정 7건 (사건 카탈로그) +- [docs/diagrams/architecture/](docs/diagrams/architecture/) — draw.io 아키텍처 그림 +- [docs/diagrams/sequence/](docs/diagrams/sequence/) — Mermaid sequence diagrams +- [guide.md](guide.md) — 실제 실행 매뉴얼 diff --git a/docs/adr/0001-keycloak-admin-host-not-public.md b/docs/adr/0001-keycloak-admin-host-not-public.md new file mode 100644 index 0000000..12f5dc1 --- /dev/null +++ b/docs/adr/0001-keycloak-admin-host-not-public.md @@ -0,0 +1,41 @@ +# ADR-0001: Keycloak admin host 는 공개 Ingress 로 노출하지 않는다 + +- Status: Accepted +- Date: 2026-04-24 +- Scope: dev / staging / prod 전 환경 + +## 컨텍스트 + +Keycloak CR 의 `spec.hostname.admin` 는 `https://keycloak-admin.dev.example.com` 로 선언되어 있다. Keycloak 26 Hostname v2 는 이 값을 admin console 의 베이스 URL 및 redirect 기준으로 사용한다 (공식 문서: https://www.keycloak.org/server/hostname). 그러나 해당 FQDN 에 대응하는 Kubernetes `Ingress` 리소스는 **의도적으로 생성하지 않는다**. + +## 결정 + +1. `keycloak-admin..example.com` 에 대한 공개 Ingress 는 repo 내에 두지 않는다. +2. admin console 접근은 다음 중 하나를 요구한다: + - 사내 VPN + `kubectl port-forward -n mnt svc/keycloak 9000:9000` (관리 포트 직접 접근) + - bastion 에서 `kubectl exec` 로 `kcadm.sh` 호출 + - 향후 별도로 도입할 zero-trust proxy (Pomerium / cloudflared tunnel / Teleport) 경로 +3. `spec.hostname.admin` 선언 자체는 유지한다 — Keycloak 이 admin UI 링크를 올바른 FQDN 으로 발행해야 외부 OIDC/SAML 메타데이터와 충돌이 없기 때문이다. "FQDN 은 있으나 외부 공개 라우터는 없다" 가 정식 상태다. + +## 근거 + +- `docs/standards/infra/keycloak.md` §11 "Admin Console 은 별도 host 로 분리" + "사내 IP 화이트리스트 / VPN / OIDC forward-auth" 요구와 정합. +- `docs/standards/infra/network-ingress-tls.md` §7 "내부 도구(Argo CD, Grafana, Kibana)는 VPN/zero-trust proxy 로만 노출" 및 §16 "`/admin/*` 는 Ingress path 에 포함하지 않는다" 기준. +- Keycloak 공식 권장: admin console 의 인터넷 공개는 공격면 확대. `sslRequired=external` 만으로는 brute-force / credential stuffing / SSRF 경로를 차단하지 못한다. +- CWE-284 (Improper Access Control) 예방을 위해 관리 평면을 데이터 평면과 물리적으로 분리한다. + +## 결과 + +- admin console 접근은 SRE / platform 팀에만 부여되며, 접근 경로는 Runbook (`docs/standards/infra/operations-runbook-upgrade-rollback.md`) 의 "Keycloak admin 접근" 섹션(TODO)을 따른다. +- 인증 실패에 대한 alerting 은 `/admin/*` 공개 환경보다 훨씬 낮은 임계치로 설정 가능 (외부 스캐너 노이즈가 없기 때문). +- 향후 공개가 필요해지면 이 ADR 의 status 를 `Superseded by ADR-XXXX` 로 바꾸고 신규 ADR 에서: + 1. `keycloak-admin..example.com` 용 Certificate (ECDSA, dev 는 letsencrypt-staging) + 2. 전용 Ingress + Traefik `CIDRAllowList` middleware (사내 CIDR 만 허용) + 3. oauth2-proxy forward-auth 또는 상호 TLS 인증 중 하나 + 4. admin-specific NetworkPolicy (egress-from-keycloak 에 대한 회귀 방지) + 를 함께 도입한다. + +## 대안과 기각 사유 + +- **대안 A — public Ingress + IP allowlist**: Traefik `CIDRAllowList` middleware 로 사내 CIDR 만 허용. 기각 사유: 사내 CIDR 이 변하거나 원격 근무자 VPN 미사용 시 실수로 통과시키는 위험. 관리 평면은 데이터 평면과 동일 ingress controller 를 공유하지 않는 것이 수비적으로 낫다. +- **대안 B — Keycloak 내장 IP allowlist**: Keycloak 자체 인증 흐름에는 path-level IP 필터 기능이 없다. 기각. diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..d19487c --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,147 @@ +# Architecture 상세 + +README 의 Architecture / Key Components 를 보충하는 문서. 폴더 구조 전체와 워크로드, 이미지 정책을 모은다. + +## 폴더 구조 + +``` +Project-Infra/ +├── .gitignore # vault-init-keys.json 제외 +├── .kube-linter.yaml # kube-linter 규칙 (컨텍스트 오탐 4종 제외) +├── README.md # 본 프로젝트 진입 문서 +├── guide.md # 운영 절차서 +├── docs/ # README 보조 분할 문서 +│ +├── k8s/ +│ ├── base/ +│ │ ├── managing/ +│ │ │ ├── namespace/ # mnt namespace + PSS restricted 라벨 +│ │ │ └── migration-flyway/ # Flyway Job (공식 이미지) +│ │ │ +│ │ ├── app/ # 애플리케이션 워크로드 (소유권 = 개발팀) +│ │ │ ├── identity/auth/ +│ │ │ │ ├── stateful/identity-postgres/ +│ │ │ │ └── stateless/auth-server/ +│ │ │ ├── storage/minio/stateful/minio/ +│ │ │ └── test/stateless/test-server-{1,2,3}/ +│ │ │ +│ │ └── plugins/ # 플랫폼 플러그인 (다른 워크로드가 의존) +│ │ ├── vault/ # Vault StatefulSet (공식 이미지) +│ │ ├── docker-registry/ # Registry Deployment (공식 이미지) +│ │ ├── oauth2-proxy/ # ForwardAuth 용 auth proxy +│ │ └── vso/ # VSO CRD 리소스 (VaultConnection / VaultAuth / VaultStaticSecret) +│ │ +│ ├── components/ +│ │ └── forward-auth/ # oauth2-proxy + Traefik ForwardAuth 재사용 component +│ │ +│ ├── overlays/ +│ │ ├── dev/ +│ │ │ ├── kustomization.yaml # dev 전체 집계 (namespace: mnt) +│ │ │ ├── networkpolicy-baseline.yaml # default-deny + DNS egress +│ │ │ ├── platform/ +│ │ │ │ ├── traefik/ # HelmChartConfig + Middleware + TLSOption +│ │ │ │ ├── cert-manager/ # cert-manager v1.20.2 +│ │ │ │ ├── cert-manager-issuers/ # letsencrypt-staging/prod ClusterIssuer +│ │ │ │ └── keycloak-operator/ # Keycloak Operator 26.6.1 +│ │ │ ├── tls/ # cert-manager 적용 후 Certificate +│ │ │ ├── vault/ # Vault overlay + NetworkPolicy + storage patch +│ │ │ ├── registry/ # Registry overlay + NetworkPolicy + storage patch +│ │ │ ├── vso/ # VSO CRDs (Helm 설치 후 별도 apply) +│ │ │ ├── database/ # identity-postgres + VaultStaticSecret +│ │ │ ├── auth/ # auth-server + Ingress(project.com) + flyway +│ │ │ ├── keycloak/ # Keycloak CR + public Ingress +│ │ │ ├── keycloak-realm/ # KeycloakRealmImport (Git-managed realm/client) +│ │ │ ├── storage/ # minio + VaultStaticSecret + certConfig FQDN patch +│ │ │ └── test/ # test-server 1/2/3 +│ │ ├── components/forward-auth/ # dev overlay 에 포함되는 ForwardAuth component +│ │ ├── staging/ # 의도적으로 비어둠 +│ │ └── prod/ # 의도적으로 비어둠 +│ │ +│ └── scripts/ +│ ├── bin/ # 사용자 진입점 (bootstrap.sh / teardown.sh) +│ ├── ci/validate.sh # kustomize + kubeconform + kube-linter +│ ├── lib/ # 공통 라이브러리 (common.sh / vault.sh) +│ └── tasks/ # 재사용 작업 (vault-init / vault-seed-apps / vso-install) +│ +└── terraform/ # contracts 만 존재, 추후 구현 +``` + +## 네임스페이스 전략 + +`mnt` 단일 namespace. 학습 단계의 단순성 우선. 실무에서는 역할별 namespace(`auth`, `storage`, `security`, `registry`) 분리가 원칙이며, base 는 환경 중립이라 overlay 재구성으로 분리 가능하다. + +- Pod Security Standards: `pod-security.kubernetes.io/enforce=restricted` (audit + warn 동시). +- 모든 리소스는 overlay 의 `namespace: mnt` 로 일괄 주입. + +## 워크로드 목록 + +| 워크로드 | 종류 | 위치 | 참조 Secret | +|---|---|---|---| +| `identity-postgres` | StatefulSet | `base/app/identity/auth/stateful/` | `identity-postgres-superuser`, `keycloak-db`, `auth-server-db` | +| [`auth-server`](https://github.com/donghyeon-ka/project-auth-server/tree/develop) | Deployment | `base/app/identity/auth/stateless/` | `auth-server-db` | +| `keycloak` | Keycloak CR (Operator 생성 StatefulSet) | `overlays/dev/keycloak/` | `keycloak-db-operator`, `keycloak-bootstrap-admin-operator` | +| `minio` | Tenant CRD | `base/app/storage/minio/stateful/` | `minio-tenant-env` | +| `test-server-1/2/3` | Deployment | `base/app/test/stateless/` | — | +| `migration-flyway` | Job (PreSync / sync-wave=-1) | `base/managing/migration-flyway/` | `auth-server-db` | +| `vault` | StatefulSet | `base/plugins/vault/` | — | +| `docker-registry` | Deployment | `base/plugins/docker-registry/` | `docker-registry-basic-auth`, `docker-registry-pull-credentials` | + +auth-server / keycloak 모두 `jdbc:postgresql://identity-postgres:5432/` 로 short name 접속 (같은 namespace). + +### Flyway 실행 순서 + +dev overlay 가 migration-flyway Job 에 ArgoCD annotation 을 patch: + +``` +argocd.argoproj.io/sync-wave: "-1" +argocd.argoproj.io/hook: PreSync +``` + +ArgoCD 배포 시 Job 이 앱보다 먼저 돌고 스키마 마이그레이션을 마친 뒤 `auth-server` 가 뜬다. + +### Keycloak hostname patch + +dev overlay JSON patch 가 Keycloak ConfigMap 에 다음을 주입: + +- `KC_HOSTNAME=https://keycloak.dev.example.com` +- `KC_HOSTNAME_ADMIN=https://keycloak-admin.dev.example.com` + +staging / prod 는 자체 hostname 을 overlay 에서 주입. + +### MinIO certConfig.dnsNames + +base 는 short name(`minio`, `minio-hl`) 만 둔다. dev overlay 에서 `minio.mnt.svc.cluster.local`, `*.minio-hl.mnt.svc.cluster.local` 을 patch — base 환경 중립성 원칙. + +## 이미지 정책 + +| 구분 | 이미지 | 근거 | +|---|---|---| +| 공식 upstream | `hashicorp/vault:1.17.2` | HashiCorp 공식 | +| | `registry:2.8.3` | Docker library 공식 | +| | `postgres:16.4` | PostgreSQL 공식 | +| | `quay.io/keycloak/keycloak:26.6.1` | Keycloak Operator 26.6.1 관리 | +| | `minio/minio:RELEASE.2025-01-20T14-49-07Z` | MinIO 공식 | +| | `flyway/flyway:10.20.1` | Flyway 공식 | +| 사용자 개발 | `registry.example.com/auth-platform/auth-server:0.1.0` | 조직 개발 서비스 | +| | `registry.example.com/test-platform/test-server-{1,2,3}:0.1.0` | 조직 개발 서비스 | + +prod 승격 시 공식 이미지도 digest pin(`@sha256:…`)으로 전환. + +## Docker Registry + +| 항목 | 값 | +|---|---| +| 이미지 | `registry:2.8.3` | +| 내부 서비스 | `docker-registry.mnt.svc.cluster.local:5000` | +| 외부 Ingress | `registry.project.com` (`/v2` only) | +| 인증 | 내부 Service 무인증, 외부 Ingress + kubelet pull 만 credential 사용 | +| 저장 | MinIO S3 bucket `docker-registry` | + +### 인증 경계 + +Registry 자체 auth 는 켜지 않는다. 인증 경계는 두 곳: + +- 외부 Ingress: Traefik `Middleware/docker-registry-basic-auth` 가 VSO 로 생성된 `docker-registry-basic-auth` Secret 의 htpasswd 를 검증 +- 내부 pull: 앱 ServiceAccount 에 `docker-registry-pull-credentials` imagePullSecret + +따라서 `docker-registry-ingress-traefik` NetworkPolicy + BasicAuth Secret + imagePullSecret 이 함께 있어야 push/pull 양쪽이 안전하다. diff --git a/docs/diagrams/architecture/01-overall.png b/docs/diagrams/architecture/01-overall.png new file mode 100644 index 0000000..9c45278 Binary files /dev/null and b/docs/diagrams/architecture/01-overall.png differ diff --git a/docs/diagrams/architecture/02-auth-flow.png b/docs/diagrams/architecture/02-auth-flow.png new file mode 100644 index 0000000..0214808 Binary files /dev/null and b/docs/diagrams/architecture/02-auth-flow.png differ diff --git a/docs/diagrams/architecture/07-traefik-pipeline.png b/docs/diagrams/architecture/07-traefik-pipeline.png new file mode 100644 index 0000000..f97f12d Binary files /dev/null and b/docs/diagrams/architecture/07-traefik-pipeline.png differ diff --git a/docs/diagrams/sequence/forward-auth-cold.md b/docs/diagrams/sequence/forward-auth-cold.md new file mode 100644 index 0000000..70fc5ea --- /dev/null +++ b/docs/diagrams/sequence/forward-auth-cold.md @@ -0,0 +1,53 @@ +# ForwardAuth · Cold Path (최초 로그인) + +세션 쿠키가 없는 첫 요청. OIDC Authorization Code Flow + PKCE 전 구간이 1 회 일어난다. **사용자당 세션 만료 주기마다 한 번** 만 발생 — 일상 운영 트래픽의 99% 는 [warm path](forward-auth-warm.md) 다. + +> Traefik 의 path-based Ingress 라우팅이 전제: `project.com/oauth2/*` 는 oauth2-proxy 로, 그 외 path 는 ForwardAuth Middleware 를 거쳐 백엔드로 간다. 이 라우팅 결정이 그림의 모든 분기의 기반이다. + +```mermaid +sequenceDiagram + autonumber + participant User + participant Traefik + participant OAuth as oauth2-proxy + participant KC as Keycloak + + User->>Traefik: GET project.com/api/me + Traefik->>OAuth: ForwardAuth GET /oauth2/auth (no cookie) + OAuth-->>Traefik: 401 Unauthorized + Traefik-->>User: 302 to /oauth2/start + + User->>Traefik: GET /oauth2/start + Note over Traefik: Ingress 가 /oauth2/* 를 oauth2-proxy 로 라우팅 + Traefik->>OAuth: forward + Note over OAuth: PKCE code_verifier 생성, code_challenge 산출 + OAuth-->>User: 302 to Keycloak authorize with code_challenge + + User->>KC: GET /realms/platform/protocol/openid-connect/auth + KC-->>User: 로그인 폼 + User->>KC: POST 자격증명 + KC-->>User: 302 to /oauth2/callback with auth code + + User->>Traefik: GET /oauth2/callback with code + Traefik->>OAuth: forward + OAuth->>KC: POST /token (code, code_verifier) + KC-->>OAuth: id_token, access_token, refresh_token + + OAuth->>KC: GET /realms/platform/protocol/openid-connect/certs + KC-->>OAuth: JWKS 공개키 + Note over OAuth: id_token 서명 검증 with JWKS, nonce 일치 확인 + + OAuth-->>User: Set-Cookie _oauth2_proxy + 302 to /api/me + Note over User: 이후 요청은 warm path +``` + +## 핵심 인사이트 + +- **Ingress 라우팅이 분기의 뿌리**: 그림의 Note 가 가리키듯 `/oauth2/*` 와 그 외 path 가 *Ingress 단에서* 갈린다. 이 라우팅이 없으면 cold path 가 시작 자체를 못 한다. +- **PKCE 가 핵심 보안 장치**: `code_verifier` 는 메시지 7~8 에서 oauth2-proxy 가 생성해 자기 세션에 저장하고, 메시지 16 에서 token exchange 시 함께 보낸다. Keycloak 은 `code_challenge` 와 매칭 검증. **authorization code 가 중간에 가로채지더라도 verifier 없이는 token 으로 교환 불가**. oauth2-proxy v7.5+ 는 PKCE 가 기본 활성. +- **JWKS 검증의 위치**: 메시지 18~19 (`GET .../certs`) 가 별개의 호출이다. oauth2-proxy 는 JWKS 를 *처음 1 회 fetch 후 캐시* 하고, Keycloak 의 JWKS endpoint 가 회전 가능 (`kid` 헤더로 식별). **id_token 서명 검증 (메시지 20 의 Note) 이 끝나야 쿠키가 발급되므로**, 이후 warm path 에서 백엔드가 받는 `X-Forwarded-User` 는 *이미 검증된 사용자* 다. +- **TLS 검증 전제**: 현재 oauth2-proxy 설정은 `ssl_insecure_skip_verify=false` 이다. 따라서 Keycloak 공개 호스트(`keycloak.dev.example.com`) 인증서 체인이 정상이어야 token 교환과 JWKS 검증 흐름이 끝까지 진행된다. + +## 평소 요청 흐름은? + +→ [forward-auth-warm.md](forward-auth-warm.md) diff --git a/docs/diagrams/sequence/forward-auth-warm.md b/docs/diagrams/sequence/forward-auth-warm.md new file mode 100644 index 0000000..85680b5 --- /dev/null +++ b/docs/diagrams/sequence/forward-auth-warm.md @@ -0,0 +1,47 @@ +# ForwardAuth · Warm Path (세션 쿠키 보유) + +쿠키 검증으로 끝나는 평소 요청 경로. **운영 트래픽의 99% 가 이 흐름** 이다. cold path 의 OIDC handshake 는 세션 만료 시에만 다시 일어난다. + +3 가지 결과가 있다: (1) 쿠키 정상 → 즉시 통과, (2) access_token 만료 → silent refresh 후 통과, (3) 쿠키 위조 또는 refresh 실패 → cold path 재진입. + +```mermaid +sequenceDiagram + autonumber + participant User + participant Traefik + participant OAuth as oauth2-proxy + participant KC as Keycloak + participant App as auth-server + + User->>Traefik: GET project.com/api/me with cookie + Traefik->>OAuth: ForwardAuth GET /oauth2/auth + + alt 쿠키 HMAC 유효 + access_token 미만료 + OAuth-->>Traefik: 202 Accepted with X-Auth-Request-User + else access_token 만료, refresh_token 유효 + Note over OAuth: silent refresh + OAuth->>KC: POST /token with refresh_token + KC-->>OAuth: 새 access_token + OAuth-->>Traefik: 202 Accepted with X-Auth-Request-User + else 쿠키 위조 또는 refresh 실패 + OAuth-->>Traefik: 401 Unauthorized + Traefik-->>User: 302 to /oauth2/start + Note over User: cold path 재진입 + end + + Note over Traefik: 클라이언트 X-Forwarded 헤더 strip 후 oauth2-proxy 응답 헤더만 주입 + + Traefik->>App: GET /api/me with X-Forwarded-User alice + App-->>User: 200 OK +``` + +## 핵심 인사이트 + +- **백엔드가 헤더만 신뢰해도 안전한 이유**: Traefik 의 ForwardAuth Middleware 가 *클라이언트로부터 들어온* `X-Forwarded-*` 헤더를 strip 하고, *oauth2-proxy 응답에 담긴* 헤더만 백엔드로 전달한다. 클라이언트가 위조한 `X-Forwarded-User: admin` 은 도달하지 못한다. **이 strip 동작이 무너지면 권한 우회 취약점**이 되므로 Traefik Middleware 의 `authResponseHeaders` 와 (Traefik global) `forwardedHeaders` 설정이 핵심. +- **백엔드 코드 단순화의 실체**: `auth-server` 의 컨트롤러는 `request.getHeader("X-Forwarded-User")` 한 줄만 본다. JWT 라이브러리, JWKS 캐시, 쿠키 파서, 세션 스토어가 모두 사라진다. 단위 테스트도 헤더 1 개 주입으로 인증된 사용자 시나리오가 만들어진다. +- **silent refresh 는 사용자에게 보이지 않음**: alt 의 두 번째 분기가 그 경우. 사용자 브라우저는 redirect 를 안 본다 — Traefik ForwardAuth 호출 안에서 refresh 가 끝나고 같은 응답이 202 로 돌아온다. +- **위조 시 회귀 경로**: 세 번째 분기. 쿠키 HMAC 가 안 맞거나 refresh 가 실패하면 oauth2-proxy 가 401 을 반환하고, Traefik 이 cold path 의 시작점인 `/oauth2/start` 로 돌려보낸다. 즉 **공격자가 쿠키를 위조해 봤자 결과는 로그인 페이지로의 redirect 일 뿐**이다. + +## 처음 로그인 시 흐름은? + +→ [forward-auth-cold.md](forward-auth-cold.md) diff --git a/docs/diagrams/sequence/secret-pipeline-bootstrap.md b/docs/diagrams/sequence/secret-pipeline-bootstrap.md new file mode 100644 index 0000000..3f81aed --- /dev/null +++ b/docs/diagrams/sequence/secret-pipeline-bootstrap.md @@ -0,0 +1,44 @@ +# Secret Pipeline · Bootstrap (1 회) + +`tasks/vault-init.sh` 가 클러스터 최초 셋업 시 한 번만 수행하는 흐름. Vault 의 Kubernetes auth method 와 두 개의 role/policy, 그리고 VSO 가 사용할 VaultAuth CR 까지 준비한다. 모든 단계는 멱등 체크 후 차이만 적용된다 — Note 에 명시된 read 호출이 그 체크 지점. + +```mermaid +sequenceDiagram + autonumber + participant Op as Operator + participant Vault + participant K8s as K8s API + + Op->>Vault: operator init (5 unseal keys) + Op->>Vault: operator unseal (3 keys) + Vault-->>Op: Unsealed + + Op->>K8s: apply ClusterRoleBinding (system auth-delegator) + Note over K8s: Vault Pod SA 에 TokenReview 권한 위임 + + Op->>Vault: auth enable kubernetes + write config + Note over Vault: vault auth list 후 미존재 시에만 enable + + Op->>Vault: secrets enable kv-v2 at path secret + Note over Vault: vault secrets list 후 미존재 시에만 enable + + Op->>Vault: policy write x2 + role write x2 (auth-platform, storage) + Note over Vault: 각 policy/role read 후 차이만 적용 + + Op->>Vault: kv put auth-server-db, keycloak-db, minio-tenant-env + + Op->>K8s: apply VaultAuth x2 + Op->>K8s: apply VaultStaticSecret x7 + Note over K8s: VaultAuth 가 먼저, VaultStaticSecret 나중 - 그래야 reconcile 성공 +``` + +## 핵심 인사이트 + +- **두 role 의 의도**: VSO 의 ServiceAccount 는 `vault-secrets-operator/mnt` 한 개뿐이다. 그러나 Vault 에 role 두 개를 두고 각각 다른 policy 를 묶었다. **VaultStaticSecret 마다 자기 도메인의 VaultAuth CR 을 참조**하므로, auth-platform role 의 토큰이 유출돼도 minio secret 은 못 읽는다. +- **`system:auth-delegator` 의 위치**: 이 ClusterRoleBinding 은 *Vault Pod 의 SA* 에 부여된다. Vault 가 VSO 의 SA JWT 를 검증하기 위해 K8s 의 `TokenReview` API 를 호출할 권한이 필요하기 때문. VSO 측이 아니라 Vault 측에 붙는다는 점이 자주 헷갈리는 지점. +- **멱등성의 위치**: 각 enable / write 호출 직전에 `vault auth list`, `vault secrets list`, `vault policy read`, `vault read auth/kubernetes/role/` 으로 현재 상태를 체크하고 차이만 적용한다. 따라서 이 다이어그램의 모든 단계는 *재실행 안전*. +- **마지막 두 단계의 순서**: VaultAuth 가 먼저, VaultStaticSecret 이 나중. 그래야 VSO 가 첫 reconcile 에서 `vaultAuthRef` 를 정상 해석한다. + +## 정상 운영 시 reconcile 흐름은? + +→ [secret-pipeline-runtime.md](secret-pipeline-runtime.md) diff --git a/docs/diagrams/sequence/secret-pipeline-runtime.md b/docs/diagrams/sequence/secret-pipeline-runtime.md new file mode 100644 index 0000000..d3fd320 --- /dev/null +++ b/docs/diagrams/sequence/secret-pipeline-runtime.md @@ -0,0 +1,54 @@ +# Secret Pipeline · Steady-State Reconcile + +VSO 가 VaultStaticSecret CR 을 reconcile 할 때마다 일어나는 흐름. **이 다이어그램은 한 reconcile 사이클** 만 다룬다 — 부트스트랩(정책/role/CR 적용) 은 [secret-pipeline-bootstrap.md](secret-pipeline-bootstrap.md) 에서 이미 끝난 상태를 전제한다. + +VSO 는 controller-runtime 기반이라 informer 가 *시작 시 1 회 watch 등록* 하고, 이후 K8s API 가 push 하는 이벤트로 reconcile 이 트리거된다. 즉 매 cycle 마다 watch 호출이 새로 일어나는 게 아니다. + +```mermaid +sequenceDiagram + autonumber + participant VSO as VSO Operator + participant K8s as K8s API + participant Vault + participant Sec as K8s Secret + + Note over VSO,K8s: informer 가 시작 시 1 회 watch 등록 후 이벤트 수신 대기 + + K8s-->>VSO: event for VaultStaticSecret auth-server-db-creds + VSO->>K8s: read VaultStaticSecret spec + K8s-->>VSO: vaultAuthRef, path + + VSO->>K8s: read VaultAuth vault-auth-auth-platform + K8s-->>VSO: role vso-auth-platform, mount kubernetes + + VSO->>Vault: POST auth/kubernetes/login (role, jwt) + Vault->>K8s: TokenReview (VSO SA JWT) + Note over Vault,K8s: system auth-delegator 권한 사용 + K8s-->>Vault: ok, sa vault-secrets-operator + Vault-->>VSO: Vault token (policy vso-auth-platform, ttl 1h) + + VSO->>Vault: GET secret/data/auth-server/db + Vault-->>VSO: username, password, jdbc-url + + Note over VSO: destination overwrite false 면 기존 Secret 유지 + VSO->>K8s: create or update Secret auth-server-db + K8s-->>Sec: stored + + Note over Sec: kubelet 이 Pod 시작 시 envFrom 으로 마운트 (시퀀스 외) + + loop every refreshAfter (1h) + VSO->>Vault: GET secret/data/auth-server/db + Vault-->>VSO: 최신 값 + opt 값이 변경된 경우 + VSO->>K8s: update Secret auth-server-db + end + end +``` + +## 핵심 인사이트 + +- **이 다이어그램의 시작점은 K8s 가 던지는 event**: VSO 가 매 cycle 마다 watch API 를 새로 호출하는 게 아니다. controller-runtime 의 informer 가 startup 에 watch 를 establish 하고, K8s API 가 변경 사항을 push 하면 reconcile loop 이 깨어난다. 그래서 메시지 1 의 화살표 방향이 K8s → VSO. +- **TokenReview 는 VSO 가 부르는 게 아니라 Vault 가 부른다**: 메시지 7 (`Vault to K8s API: TokenReview`) 가 그 호출. Vault 가 *받은* SA JWT 가 진짜 VSO 의 것인지 확인하기 위해 K8s 에 위임 검증한다. +- **role 결정은 VaultAuth CR 이 한다**: 메시지 4~5 에서 VSO 는 *VaultStaticSecret 이 가리키는 VaultAuth* 를 읽고, 거기에 박힌 `role: vso-auth-platform` 으로 Vault login 한다. 같은 SA 라도 어느 VaultAuth 를 거쳤느냐에 따라 받는 policy 가 달라진다. +- **`overwrite=false` 의 책임 위치**: 이건 K8s API 의 동작이 아니라 *VSO reconciler 가 update 호출 전에 자기 로직으로 결정* 한다. 그래서 Note 가 VSO 위에 붙는다. +- **즉시 반영**: Vault 값 변경 직후 반영하려면 `kubectl -n mnt delete secret auth-server-db`. 다음 reconcile 에서 VSO 가 위 흐름을 다시 돌아 새 값으로 재생성한다 — Pod 는 envFrom 으로 받은 값이 바뀌었음을 자동으로 알 수 없으므로 rollout 도 함께. diff --git a/docs/examples/infra/architecture-environments.md b/docs/examples/infra/architecture-environments.md new file mode 100644 index 0000000..b1194a9 --- /dev/null +++ b/docs/examples/infra/architecture-environments.md @@ -0,0 +1,417 @@ +# architecture / environments 예시 + +이 파일의 모든 YAML은 `kubectl apply --server-side --dry-run=server` 에 통과해야 한다. +모든 예시는 1000+ 서비스 운영 기준으로 작성되었고, 단독으로 복붙해서 바로 apply 할 수 있도록 self-contained 하다. + +--- + +## 좋은 예시 1: namespace에 환경 · 도메인 · PodSecurity · 운영 label 전부 박기 + +```yaml +apiVersion: v1 +kind: Namespace +metadata: + name: prod-identity-auth + labels: + app.kubernetes.io/name: auth + app.kubernetes.io/instance: auth-prod + app.kubernetes.io/version: "1.24.3" + app.kubernetes.io/component: api + app.kubernetes.io/part-of: identity-platform + app.kubernetes.io/managed-by: argocd + example.com/environment: prod + example.com/team: identity-sre + example.com/tier: backend + example.com/slo-tier: tier-1 + example.com/data-classification: confidential + example.com/cost-center: cc-1042 + pod-security.kubernetes.io/enforce: restricted + pod-security.kubernetes.io/enforce-version: latest + pod-security.kubernetes.io/audit: restricted + pod-security.kubernetes.io/warn: restricted + annotations: + example.com/owner-email: identity-sre@example.com + example.com/runbook: https://runbooks.example.com/identity/auth + example.com/slo-doc: https://slo.example.com/identity/auth +--- +apiVersion: v1 +kind: ResourceQuota +metadata: + name: default-quota + namespace: prod-identity-auth + labels: + app.kubernetes.io/name: auth + app.kubernetes.io/instance: auth-prod + app.kubernetes.io/component: quota + app.kubernetes.io/part-of: identity-platform + app.kubernetes.io/managed-by: argocd + example.com/environment: prod +spec: + hard: + requests.cpu: "20" + requests.memory: 40Gi + limits.cpu: "40" + limits.memory: 80Gi + pods: "200" + persistentvolumeclaims: "20" +--- +apiVersion: v1 +kind: LimitRange +metadata: + name: default-limits + namespace: prod-identity-auth + labels: + app.kubernetes.io/name: auth + app.kubernetes.io/instance: auth-prod + app.kubernetes.io/component: limits + app.kubernetes.io/part-of: identity-platform + app.kubernetes.io/managed-by: argocd + example.com/environment: prod +spec: + limits: + - type: Container + default: + cpu: "500m" + memory: 512Mi + defaultRequest: + cpu: "100m" + memory: 128Mi + max: + cpu: "4" + memory: 8Gi + min: + cpu: "10m" + memory: 32Mi +``` + +**왜 좋은가:** + +- `app.kubernetes.io/*` well-known 6종이 모두 있고, 운영 축은 `example.com/*`로 분리되어 selector immutability를 깨지 않는다 +- PodSecurity admission이 namespace 레벨에서 `restricted`로 강제 → 이후 Pod spec이 noncompliant면 창조 시점에 거부 +- ResourceQuota + LimitRange가 namespace 단위로 고정되어 하나의 서비스가 클러스터를 삼킬 수 없다 +- 환경(prod)·도메인(identity)·서비스(auth)가 namespace 이름과 label 양쪽에 드러남 + +--- + +## 좋은 예시 2: multi-region prod overlay 디렉터리 (kr-main + kr-dr) + +```text +k8s/ + base/ + app/ + units/ + identity/ + auth/ + kustomization.yaml + deployment.yaml + service.yaml + servicemonitor.yaml + pdb.yaml + hpa.yaml + plugins/ + ingress-nginx/ + cert-manager/ + external-secrets/ + managing/ + flyway-migrate-identity/ + overlays/ + dev/ + kustomization.yaml + staging/ + kustomization.yaml + prod/ + kr-main/ + kustomization.yaml + patches/ + auth-replicas.yaml + auth-resources.yaml + auth-topology-spread.yaml + kr-dr/ + kustomization.yaml + patches/ + auth-replicas.yaml + auth-image-pull-mirror.yaml +``` + +**왜 좋은가:** + +- 1000+ 서비스 스케일에서 단일 overlay/prod로는 region 차이를 표현할 수 없다. region이 overlay 하위 계층이 되어야 한다 +- base는 region·환경을 모른다 (원칙 충족) +- DR region은 base의 image pull spec만 mirror로 패치하고 나머지는 공유 + +--- + +## 좋은 예시 3: SLO tier 별 기본 default per namespace + +```yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: slo-defaults + namespace: prod-identity-auth + labels: + app.kubernetes.io/name: auth + app.kubernetes.io/instance: auth-prod + app.kubernetes.io/component: slo-config + app.kubernetes.io/part-of: identity-platform + app.kubernetes.io/managed-by: argocd + example.com/environment: prod + example.com/slo-tier: tier-1 +data: + availability-slo: "99.95" + rpo-minutes: "5" + rto-minutes: "15" + backup-interval-minutes: "15" + multi-az-required: "true" + pdb-min-available-percent: "50" +``` + +**왜 좋은가:** + +- SLO/RPO/RTO 숫자가 YAML로 문서화되어 audit 가능 +- 같은 tier 정의가 팀마다 제각각 drift 되는 일을 막는다 +- `example.com/slo-tier` label이 cluster-wide 쿼리 축 제공 (`kubectl get ns -l example.com/slo-tier=tier-1`) + +--- + +## 좋은 예시 4: K3s packaged component disable을 bootstrap 레벨에서 선언 + +```yaml +# /etc/rancher/k3s/config.yaml (Git-managed, applied identically to every server node) +write-kubeconfig-mode: "0640" +cluster-cidr: "10.42.0.0/16" +service-cidr: "10.43.0.0/16" +cluster-dns: "10.43.0.10" +cluster-domain: "cluster.local" +disable: + - traefik + - servicelb + - local-storage +disable-network-policy: false +tls-san: + - "k3s.prod.example.internal" + - "10.0.0.10" +kube-apiserver-arg: + - "audit-log-path=/var/log/k3s/audit.log" + - "audit-log-maxage=30" + - "audit-log-maxbackup=10" + - "audit-log-maxsize=100" + - "audit-policy-file=/etc/rancher/k3s/audit-policy.yaml" +kubelet-arg: + - "config=/etc/rancher/k3s/kubelet.yaml" +``` + +**왜 좋은가:** + +- prod 스케일에서 traefik / servicelb / local-storage는 전부 외부 컴포넌트로 대체되므로 disable이 기본 +- critical config (`cluster-cidr`, `service-cidr`, `cluster-dns`, `cluster-domain`)가 Git 하나의 파일에 고정 → 서버 간 mismatch 불가능 +- audit log와 kubelet config가 선언형으로 박힘 → 신규 서버 조인 시 drift 없음 + +--- + +## 좋은 예시 5: 도메인 분리 + public/internal/operator ingress host 패턴 + +```yaml +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: auth-public + namespace: prod-identity-auth + labels: + app.kubernetes.io/name: auth + app.kubernetes.io/instance: auth-prod + app.kubernetes.io/version: "1.24.3" + app.kubernetes.io/component: api + app.kubernetes.io/part-of: identity-platform + app.kubernetes.io/managed-by: argocd + example.com/environment: prod + example.com/exposure: public + annotations: + cert-manager.io/cluster-issuer: letsencrypt-prod + nginx.ingress.kubernetes.io/ssl-redirect: "true" + nginx.ingress.kubernetes.io/proxy-body-size: "8m" +spec: + ingressClassName: nginx-public + tls: + - hosts: + - auth.example.com + secretName: auth-public-tls + rules: + - host: auth.example.com + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: auth + port: + number: 8080 +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: auth-admin + namespace: prod-identity-auth + labels: + app.kubernetes.io/name: auth + app.kubernetes.io/instance: auth-prod + app.kubernetes.io/component: admin + app.kubernetes.io/part-of: identity-platform + app.kubernetes.io/managed-by: argocd + example.com/environment: prod + example.com/exposure: operator-only + annotations: + cert-manager.io/cluster-issuer: internal-ca + nginx.ingress.kubernetes.io/auth-url: "https://sso.ops.example.com/oauth2/auth" + nginx.ingress.kubernetes.io/auth-signin: "https://sso.ops.example.com/oauth2/sign_in?rd=$escaped_request_uri" + nginx.ingress.kubernetes.io/whitelist-source-range: "10.0.0.0/8" +spec: + ingressClassName: nginx-internal + tls: + - hosts: + - auth.ops.example.com + secretName: auth-admin-tls + rules: + - host: auth.ops.example.com + http: + paths: + - path: /actuator + pathType: Prefix + backend: + service: + name: auth + port: + number: 8081 +``` + +**왜 좋은가:** + +- 한 서비스(auth)가 public API와 operator-only admin 포트를 별도 ingress + 별도 ingressClass + 별도 TLS issuer로 분리 +- CIDR whitelist + OAuth2 sso forward-auth가 admin endpoint에 강제 +- `example.com/exposure` label로 cluster-wide audit 쿼리 가능 + +--- + +## 나쁜 예시 1: `default` namespace에 prod workload + +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: auth-server + namespace: default +spec: + replicas: 3 + selector: + matchLabels: + app: auth-server + template: + metadata: + labels: + app: auth-server + spec: + containers: + - name: auth + image: registry.example.com/auth:1.24.3 +``` + +**문제:** `default` namespace는 PodSecurity / Quota / NetworkPolicy를 걸기 위한 격리 단위가 될 수 없고, 다른 팀 리소스와 섞인다. 1000-서비스 환경에서 `default`는 영구적으로 비워두는 것이 운영 원칙. + +--- + +## 나쁜 예시 2: `app.kubernetes.io/environment` 사용 (well-known label에 없음) + +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: auth + namespace: prod-identity-auth + labels: + app.kubernetes.io/name: auth + app.kubernetes.io/environment: prod # invalid well-known key +``` + +**문제:** Kubernetes 공식 well-known label set은 `{name,instance,version,component,part-of,managed-by}` 6종뿐. `environment`는 여기 없으므로 **자체 도메인**(`example.com/environment`)을 써야 한다. 다른 팀이 `app.kubernetes.io/env` 같은 변종을 만들어 drift가 퍼진다. + +--- + +## 나쁜 예시 3: `manifests/` 디렉터리에 운영 리소스 직접 배치 + +```text +/var/lib/rancher/k3s/server/manifests/auth-prod.yaml +/var/lib/rancher/k3s/server/manifests/keycloak-prod.yaml +/var/lib/rancher/k3s/server/manifests/ingress-nginx.yaml +``` + +**문제:** 멀티 서버 K3s는 이 디렉터리를 서버 간 동기화하지 **않는다**. 서버 A에만 있는 파일은 서버 B 리더가 되면 사라진 것처럼 보인다. source of truth는 Git + Kustomize여야 한다. + +--- + +## 나쁜 예시 4: selector에 버전 / 환경 label 포함 + +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: auth + namespace: prod-identity-auth +spec: + selector: + matchLabels: + app.kubernetes.io/name: auth + app.kubernetes.io/version: "1.24.3" # changes on every release + example.com/environment: prod # injected by overlay + template: + metadata: + labels: + app.kubernetes.io/name: auth + app.kubernetes.io/version: "1.24.3" + example.com/environment: prod + spec: + containers: + - name: auth + image: registry.example.com/auth:1.24.3 +``` + +**문제:** `selector.matchLabels`는 Deployment/StatefulSet에서 **immutable**이다. `version`은 배포마다 바뀌고 `environment`는 overlay가 주입한다 → 첫 배포 이후 재apply 시 `field is immutable` 에러로 영구 차단. selector에는 불변 3종(`name`/`instance`/`component`)만. + +--- + +## 나쁜 예시 5: 같은 hostname을 dev와 prod가 공유 + +```yaml +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: auth + namespace: dev-identity-auth +spec: + ingressClassName: nginx-public + rules: + - host: auth.example.com # same as prod + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: auth + port: + number: 8080 +``` + +**문제:** 환경 간 host 공유는 TLS cert race, 동일 hostname의 두 ingress 간 routing 불확실성, 외부 모니터링이 어느 환경을 보는지 혼동을 유발한다. dev는 반드시 `auth.dev.example.com` 같이 별도 hostname을 쓴다. + +--- + +## 나쁜 예시 6: K3s traefik manifest 직접 수정으로 prod ingress 커스터마이즈 + +```bash +vim /var/lib/rancher/k3s/server/manifests/traefik.yaml +# added custom middleware config inline +systemctl restart k3s +``` + +**문제:** K3s는 재시작 시 이 파일을 packaged 원본으로 overwrite한다. 운영 커스터마이징이 조용히 사라진다. prod 1000-서비스 스케일에서는 `--disable=traefik` 후 ingress-nginx를 별도 컴포넌트로 관리하는 것이 유일한 정답. 유지한다면 **반드시** `HelmChartConfig` 사용. diff --git a/docs/examples/infra/backup-restore.md b/docs/examples/infra/backup-restore.md new file mode 100644 index 0000000..1c749c6 --- /dev/null +++ b/docs/examples/infra/backup-restore.md @@ -0,0 +1,564 @@ +# backup / restore 예시 + +모든 예시는 실제 매니페스트로 `kubectl apply -f` 가능하다. + +--- + +## 좋은 예시 1: Velero 설치 후 BackupStorageLocation / VolumeSnapshotLocation + +```yaml +--- +apiVersion: velero.io/v1 +kind: BackupStorageLocation +metadata: + name: default + namespace: velero + labels: + app.kubernetes.io/part-of: platform-backup +spec: + provider: aws + objectStorage: + bucket: acme-prod-velero-backups + prefix: k3s-prod + config: + region: us-east-1 + s3ForcePathStyle: "false" + s3Url: https://s3.us-east-1.amazonaws.com + default: true + accessMode: ReadWrite + credential: + name: velero-s3-credentials + key: cloud +--- +apiVersion: velero.io/v1 +kind: VolumeSnapshotLocation +metadata: + name: csi-default + namespace: velero +spec: + provider: csi +``` + +왜 좋은가: +- 백업 저장소가 **클러스터 외부** S3 (같은 cluster MinIO에 넣지 않음) +- credential은 별도 Secret +- CSI snapshot location이 명시됨 + +❌ 나쁜 예시 1: 같은 cluster 안 MinIO를 백업 저장소로 사용 + +```yaml +spec: + provider: aws + objectStorage: + bucket: backups + config: + s3Url: http://minio.object-prod.svc.cluster.local:9000 # 같은 cluster! +``` + +문제: +- cluster 장애 = 백업 동시 소실 +- MinIO 자체를 복구하려면 외부 백업이 또 필요 — 순환 의존 + +--- + +## 좋은 예시 2: Velero Schedule (tier별 분리, 30일 retention) + +```yaml +--- +apiVersion: velero.io/v1 +kind: Schedule +metadata: + name: gold-daily + namespace: velero + labels: + backup.platform.io/tier: gold +spec: + schedule: "0 2 * * *" # 매일 02:00 UTC + useOwnerReferencesInBackup: true + template: + ttl: 720h0m0s # 30일 retention + includedNamespaces: + - auth-prod + - data-prod + - object-prod + includedResources: + - persistentvolumeclaims + - persistentvolumes + - secrets + - configmaps + - deployments + - statefulsets + - services + - ingresses + - networkpolicies + labelSelector: + matchLabels: + backup.platform.io/tier: gold + snapshotVolumes: true + defaultVolumesToFsBackup: false + csiSnapshotTimeout: 30m + storageLocation: default + volumeSnapshotLocations: + - csi-default + hooks: + resources: + - name: postgres-consistent + includedNamespaces: [data-prod] + labelSelector: + matchLabels: + app.kubernetes.io/name: postgres + pre: + - exec: + container: postgres + command: ["/bin/sh", "-c", "psql -U postgres -c CHECKPOINT"] + onError: Fail + timeout: 2m +--- +apiVersion: velero.io/v1 +kind: Schedule +metadata: + name: bronze-weekly-fsb + namespace: velero + labels: + backup.platform.io/tier: bronze +spec: + schedule: "0 3 * * 0" # 매주 일요일 03:00 UTC + template: + ttl: 2160h0m0s # 90일 + includedNamespaces: ["archive-prod"] + labelSelector: + matchLabels: + backup.platform.io/tier: bronze + snapshotVolumes: false + defaultVolumesToFsBackup: true # kopia/restic FSB + storageLocation: default +``` + +왜 좋은가: +- Schedule이 tier별로 분리되어 RPO/retention/도구를 구분 +- CSI snapshot(gold)과 FSB(bronze)를 목적에 맞게 선택 +- Postgres는 pre-hook으로 `CHECKPOINT`를 수행해 crash-consistent에 가까운 스냅샷 확보 +- `labelSelector`가 PVC의 `backup.platform.io/tier`와 매칭 + +--- + +## 좋은 예시 3: Velero Restore + +```yaml +--- +apiVersion: velero.io/v1 +kind: Restore +metadata: + name: auth-prod-restore-2026-04-16 + namespace: velero +spec: + backupName: gold-daily-20260415020000 + includedNamespaces: ["auth-prod"] + restorePVs: true + existingResourcePolicy: none # 기존 리소스 보존, 누락된 것만 복원 + namespaceMapping: + auth-prod: auth-prod-restore # 검증용 별도 네임스페이스로 복원 + labelSelector: + matchLabels: + backup.platform.io/tier: gold +``` + +왜 좋은가: +- 복원 대상이 `auth-prod-restore`로 분리되어 운영 영향 없이 검증 가능 +- `existingResourcePolicy: none`으로 실수 덮어쓰기 방지 +- `restorePVs: true`로 PVC/PV까지 함께 복원 + +--- + +## 좋은 예시 4: CloudNativePG Cluster + ScheduledBackup + Backup + +```yaml +--- +apiVersion: v1 +kind: Namespace +metadata: + name: data-prod + labels: + pod-security.kubernetes.io/enforce: restricted +--- +apiVersion: v1 +kind: Secret +metadata: + name: cnpg-s3-credentials + namespace: data-prod +type: Opaque +stringData: + ACCESS_KEY_ID: REPLACE_VIA_VSO + ACCESS_SECRET_KEY: REPLACE_VIA_VSO +--- +apiVersion: postgresql.cnpg.io/v1 +kind: Cluster +metadata: + name: auth-pg + namespace: data-prod + labels: + app.kubernetes.io/name: auth-pg + app.kubernetes.io/component: database + app.kubernetes.io/part-of: auth-platform + backup.platform.io/tier: gold +spec: + instances: 3 + imageName: ghcr.io/cloudnative-pg/postgresql:16.4-8 + primaryUpdateStrategy: unsupervised + postgresql: + parameters: + shared_buffers: "512MB" + max_connections: "200" + wal_compression: "on" + archive_timeout: "60s" + bootstrap: + initdb: + database: auth + owner: auth_app + secret: + name: auth-pg-app + storage: + size: 50Gi + storageClass: fast-ssd-retain + walStorage: + size: 20Gi + storageClass: fast-ssd-retain + monitoring: + enablePodMonitor: true + resources: + requests: {cpu: "500m", memory: "2Gi"} + limits: {cpu: "2", memory: "4Gi"} + backup: + retentionPolicy: "30d" + barmanObjectStore: + destinationPath: s3://acme-prod-pg-backups/auth-pg + endpointURL: https://s3.us-east-1.amazonaws.com + s3Credentials: + accessKeyId: + name: cnpg-s3-credentials + key: ACCESS_KEY_ID + secretAccessKey: + name: cnpg-s3-credentials + key: ACCESS_SECRET_KEY + wal: + compression: gzip + maxParallel: 8 + data: + compression: gzip + immediateCheckpoint: true + jobs: 4 + affinity: + podAntiAffinityType: required + topologyKey: kubernetes.io/hostname +--- +apiVersion: postgresql.cnpg.io/v1 +kind: ScheduledBackup +metadata: + name: auth-pg-daily + namespace: data-prod +spec: + schedule: "0 0 2 * * *" # 매일 02:00 (CNPG는 6-field cron) + backupOwnerReference: self + cluster: + name: auth-pg + method: barmanObjectStore +--- +apiVersion: postgresql.cnpg.io/v1 +kind: Backup +metadata: + name: auth-pg-premigration-2026-04-16 + namespace: data-prod +spec: + cluster: + name: auth-pg + method: barmanObjectStore +``` + +왜 좋은가: +- Postgres 16, 3 instances, 자동 failover +- WAL continuous archiving + daily base backup으로 RPO 5분 / PITR 가능 +- `ScheduledBackup`이 cron 기반 정기 백업, `Backup`이 on-demand (마이그레이션 직전 등) +- `enablePodMonitor`로 Prometheus 연동 +- `podAntiAffinity`로 노드 분산 +- `backup.retentionPolicy: 30d` + +❌ 나쁜 예시 2: StatefulSet + cron으로 `pg_dump` 하나만 + +```yaml +apiVersion: batch/v1 +kind: CronJob +metadata: + name: pg-dump-nightly +spec: + schedule: "0 3 * * *" + jobTemplate: + spec: + template: + spec: + restartPolicy: OnFailure + securityContext: + runAsNonRoot: true + runAsUser: 999 + runAsGroup: 999 + fsGroup: 999 + seccompProfile: + type: RuntimeDefault + containers: + - name: dump + image: postgres:16 + command: ["sh", "-c", "pg_dumpall -U postgres > /backup/dump.sql"] + resources: + requests: { cpu: 100m, memory: 128Mi } + limits: { memory: 512Mi } + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: ["ALL"] + volumeMounts: + - { name: tmp, mountPath: /tmp } + - { name: backup, mountPath: /backup } + volumes: + - name: tmp + emptyDir: {} + - name: backup + emptyDir: {} +``` + +문제: +- PITR 불가 (base backup + WAL 아님) +- single file → 대규모에서 restore 시간 폭증 +- logical dump는 replication slot / extension / large object 처리에 구멍 +- 같은 cluster의 PVC에 저장 시 장애 시 동시 소실 + +--- + +## 좋은 예시 5: CNPG PITR restore (bootstrap.recovery) + +```yaml +--- +apiVersion: postgresql.cnpg.io/v1 +kind: Cluster +metadata: + name: auth-pg-restore + namespace: data-prod +spec: + instances: 3 + imageName: ghcr.io/cloudnative-pg/postgresql:16.4-8 + storage: + size: 50Gi + storageClass: fast-ssd-retain + walStorage: + size: 20Gi + storageClass: fast-ssd-retain + bootstrap: + recovery: + source: auth-pg-source + recoveryTarget: + targetTime: "2026-04-16 09:45:00.00+00" + externalClusters: + - name: auth-pg-source + barmanObjectStore: + destinationPath: s3://acme-prod-pg-backups/auth-pg + endpointURL: https://s3.us-east-1.amazonaws.com + s3Credentials: + accessKeyId: {name: cnpg-s3-credentials, key: ACCESS_KEY_ID} + secretAccessKey: {name: cnpg-s3-credentials, key: ACCESS_SECRET_KEY} + wal: + maxParallel: 8 +``` + +왜 좋은가: +- PITR을 declarative CRD로 표현 +- 원본 cluster를 건드리지 않고 별도 `auth-pg-restore` 클러스터로 복원 +- 특정 시점(`targetTime`)까지 WAL replay + +--- + +## 좋은 예시 6: K3s etcd snapshot + S3 업로드 + +```ini +# /etc/rancher/k3s/config.yaml (control-plane nodes) +etcd-snapshot-schedule-cron: "0 */6 * * *" +etcd-snapshot-retention: 28 +etcd-s3: true +etcd-s3-endpoint: "s3.us-east-1.amazonaws.com" +etcd-s3-bucket: "acme-prod-k3s-etcd" +etcd-s3-folder: "prod-cluster-1" +etcd-s3-region: "us-east-1" +etcd-s3-access-key-file: /var/lib/rancher/k3s/server/etcd-s3-access +etcd-s3-secret-key-file: /var/lib/rancher/k3s/server/etcd-s3-secret +secrets-encryption: true +``` + +token 별도 보관 (예: 운영자 금고 / 외부 Vault): +``` +/var/lib/rancher/k3s/server/token → offline backup, 접근 로그 남김 +``` + +왜 좋은가: +- 6시간마다 etcd snapshot + S3 자동 업로드 + 28개 보관 +- secrets encryption 활성화로 snapshot 유출 시 노출 감소 +- server token을 snapshot과 같은 위치에 두지 않음 + +**주의: 이 snapshot은 PVC 데이터를 포함하지 않는다. 반드시 Velero + CNPG backup과 병행.** + +--- + +## 좋은 예시 7: Vault raft snapshot (CronJob) + +```yaml +--- +apiVersion: batch/v1 +kind: CronJob +metadata: + name: vault-raft-snapshot + namespace: vault +spec: + schedule: "0 */6 * * *" + concurrencyPolicy: Forbid + successfulJobsHistoryLimit: 3 + failedJobsHistoryLimit: 3 + jobTemplate: + spec: + backoffLimit: 0 + template: + spec: + restartPolicy: Never + serviceAccountName: vault-snapshot + securityContext: + runAsNonRoot: true + runAsUser: 100 + runAsGroup: 1000 + fsGroup: 1000 + seccompProfile: + type: RuntimeDefault + containers: + - name: snapshot + image: hashicorp/vault@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef + env: + - name: VAULT_ADDR + value: https://vault.vault.svc:8200 + - name: VAULT_TOKEN + valueFrom: + secretKeyRef: + name: vault-snapshot-token + key: token + command: + - sh + - -c + - | + set -eu + TS=$(date -u +%Y%m%dT%H%M%SZ) + vault operator raft snapshot save /snap/vault-${TS}.snap + aws s3 cp /snap/vault-${TS}.snap s3://acme-prod-vault-snap/ --sse aws:kms + resources: + requests: { cpu: 100m, memory: 128Mi } + limits: { memory: 512Mi } + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: {drop: ["ALL"]} + volumeMounts: + - {name: snap, mountPath: /snap} + volumes: + - name: snap + emptyDir: {} +``` + +왜 좋은가: +- 6시간마다 raft snapshot + S3 (SSE-KMS) 업로드 +- snapshot용 scoped token 사용 (최소권한) +- `concurrencyPolicy: Forbid`로 snapshot 중복 실행 방지 + +--- + +## 좋은 예시 8: MinIO bucket replication (DR) + +```bash +# 소스 클러스터 MinIO에서 +mc alias set src https://minio.prod-a.acme.io $SRC_KEY $SRC_SECRET +mc alias set dst https://minio.prod-b.acme.io $DST_KEY $DST_SECRET + +mc admin replicate add src dst +mc version enable src/assets +mc version enable dst/assets +mc replicate add src/assets --remote-bucket dst/assets --replicate "delete,delete-marker,existing-objects,metadata-sync" + +# DR 발생 시 (소스 완전 장애 후 복구) +mc replicate resync start src/assets --remote-bucket dst/assets +``` + +왜 좋은가: +- bucket versioning이 replication 전제 +- `resync`로 DR 복구 경로 확보 +- `mc mirror`를 단독 DR 수단으로 사용하지 않음 + +❌ 나쁜 예시 3: `mc mirror`만 단독 사용 + +```bash +mc mirror --overwrite src/assets dst/assets # 현재 객체만 동기화, 버전 이력 없음 +``` + +문제: +- 버전 이력 / 삭제 marker / metadata 누락 +- 랜섬웨어 / 실수 삭제 시 복구 불가 + +--- + +## 좋은 예시 9: Restore drill 기록 양식 + +```yaml +# /runbooks/restore-drills/2026-Q1-auth-pg.yaml +drill: + id: drill-2026-q1-auth-pg + component: cloudnativepg:auth-pg + tier: gold + target_rpo: 5m + target_rto: 30m + executed_at: 2026-03-18T14:00:00Z + executor: sre@acme.io + source_backup: barman:auth-pg/base/20260318T020000 + restore_target_time: "2026-03-17 23:59:00+00" + restore_cluster: auth-pg-drill + result: + status: success + observed_rpo: 3m + observed_rto: 22m + verification_query: "select count(*) from users where created_at < '2026-03-17 23:59:00'" + verification_result: 1842317 + issues: + - description: "WAL fetch parallelism bumped from 4 to 8 for better RTO" + action: "updated Cluster.spec.externalClusters[0].barmanObjectStore.wal.maxParallel to 8" + next_drill_due: 2026-06-18 +``` + +왜 좋은가: +- RPO/RTO 목표 vs 실측을 같이 기록 +- 검증 query 결과까지 남김 +- 다음 drill 예정일이 명시 → 90일 초과 시 경보 + +--- + +## 나쁜 예시 4: "Git에 manifest 있으니 복구 완료" + +``` +✗ manifests are in Git +✗ so restore is solved +``` + +문제: +- DB state, Vault state, MinIO objects, K3s cluster state 모두 복구 안 됨 +- Argo CD sync만으로는 runtime data가 돌아오지 않음 + +--- + +## 나쁜 예시 5: K3s etcd snapshot만 있으면 PVC도 복구된다고 오해 + +``` +✗ k3s etcd-snapshot restore → all data back +``` + +문제: +- etcd snapshot은 API object 선언만 복구. PVC 안의 파일은 복구 안 됨 +- 반드시 Velero + DB-level backup과 병행 diff --git a/docs/examples/infra/config-and-secrets.md b/docs/examples/infra/config-and-secrets.md new file mode 100644 index 0000000..af15af1 --- /dev/null +++ b/docs/examples/infra/config-and-secrets.md @@ -0,0 +1,642 @@ +# config / secrets 예시 + +모든 YAML은 `kubectl apply --dry-run=server -f -` 기준 clean. VSO는 Helm chart `hashicorp/vault-secrets-operator`로 `vault-secrets-operator` namespace에 설치되어 있고, Vault는 `vault` namespace(`https://vault.vault.svc:8200`)에서 기동 중이며, Kubernetes auth method(`auth/kubernetes`)가 활성화되어 있다고 가정한다. + +--- + +## 좋은 예시 1: 비기밀 ConfigMap (hash-suffixed by Kustomize) + +```yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: auth-server-config + namespace: auth-prod + labels: + app.kubernetes.io/name: auth-server + app.kubernetes.io/part-of: identity-platform +data: + application.yaml: | + server: + port: 8080 + shutdown: graceful + management: + endpoints: + web: + base-path: /actuator + exposure: + include: health,info,prometheus + server: + port: 9090 + spring: + main: + banner-mode: off + datasource: + hikari: + maximum-pool-size: 20 + connection-timeout: 5000 + logging: + level: + root: INFO + com.example.auth: INFO +``` + +**왜 좋은가:** + +- 비밀값은 하나도 없다(username/password/url 제외). Hikari pool size, log level, actuator 경로 같은 operational config만. +- Kustomize `configMapGenerator`로 hash suffix를 붙이면 Deployment가 자동 rollout. + +--- + +## 좋은 예시 2: VSO 전체 스택 (VaultConnection + VaultAuth + VaultStaticSecret + VaultDynamicSecret + VaultPKISecret) + +```yaml +apiVersion: v1 +kind: Namespace +metadata: + name: auth-prod + labels: + pod-security.kubernetes.io/enforce: restricted + pod-security.kubernetes.io/enforce-version: v1.29 +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: auth-server + namespace: auth-prod +automountServiceAccountToken: true +--- +apiVersion: secrets.hashicorp.com/v1beta1 +kind: VaultConnection +metadata: + name: vault + namespace: auth-prod +spec: + address: https://vault.vault.svc:8200 + skipTLSVerify: false + caCertSecretRef: vault-ca-bundle + headers: {} +--- +apiVersion: secrets.hashicorp.com/v1beta1 +kind: VaultAuth +metadata: + name: auth-server + namespace: auth-prod +spec: + vaultConnectionRef: vault + method: kubernetes + mount: kubernetes + kubernetes: + role: auth-server + serviceAccount: auth-server + audiences: + - vault +--- +apiVersion: secrets.hashicorp.com/v1beta1 +kind: VaultStaticSecret +metadata: + name: auth-server-oidc-client + namespace: auth-prod +spec: + vaultAuthRef: auth-server + mount: kv + type: kv-v2 + path: identity/auth-server/prod/oidc + refreshAfter: 1h + destination: + name: auth-server-oidc-client + create: true + type: Opaque + rolloutRestartTargets: + - kind: Deployment + name: auth-server +--- +apiVersion: secrets.hashicorp.com/v1beta1 +kind: VaultDynamicSecret +metadata: + name: auth-server-db + namespace: auth-prod +spec: + vaultAuthRef: auth-server + mount: database + path: creds/auth-server-role + destination: + name: auth-server-db + create: true + type: Opaque + transformation: + templates: + DB_URL: + text: 'jdbc:postgresql://identity-postgres.data-prod.svc:5432/auth?user={{ .Secrets.username }}&password={{ .Secrets.password }}&sslmode=require' + DB_USERNAME: + text: '{{ .Secrets.username }}' + DB_PASSWORD: + text: '{{ .Secrets.password }}' + rolloutRestartTargets: + - kind: Deployment + name: auth-server +--- +apiVersion: secrets.hashicorp.com/v1beta1 +kind: VaultPKISecret +metadata: + name: auth-server-internal-tls + namespace: auth-prod +spec: + vaultAuthRef: auth-server + mount: pki_int + role: auth-server + commonName: auth-server.auth-prod.svc + altNames: + - auth-server.auth-prod.svc.cluster.local + - auth-server + ttl: 24h + destination: + name: auth-server-internal-tls + create: true + type: kubernetes.io/tls + rolloutRestartTargets: + - kind: Deployment + name: auth-server +``` + +**왜 좋은가:** + +- Vault가 source of truth. 모든 비밀이 `kv/identity/auth-server/prod/*` 또는 database/PKI engine에서 발급. +- VSO가 결과물을 표준 Kubernetes Secret(`Opaque`, `kubernetes.io/tls`)으로 materialize. +- Dynamic DB credential은 Postgres role에서 TTL 기반 자동 발급/폐기. Rotation 시 `rolloutRestartTargets`로 Deployment rolling restart. +- PKI Secret은 `kubernetes.io/tls` 타입 → Traefik/앱 TLS에 그대로 소비 가능. + +--- + +## 좋은 예시 3: VSO Secret을 소비하는 Deployment (envFrom + volume 혼합) + +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: auth-server + namespace: auth-prod + labels: + app.kubernetes.io/name: auth-server + app.kubernetes.io/part-of: identity-platform + app.kubernetes.io/version: 1.42.0 + app.kubernetes.io/managed-by: argocd +spec: + replicas: 6 + revisionHistoryLimit: 5 + selector: + matchLabels: + app.kubernetes.io/name: auth-server + app.kubernetes.io/instance: auth-server + template: + metadata: + labels: + app.kubernetes.io/name: auth-server + app.kubernetes.io/instance: auth-server + app.kubernetes.io/part-of: identity-platform + app.kubernetes.io/version: 1.42.0 + spec: + serviceAccountName: auth-server + automountServiceAccountToken: true + securityContext: + runAsNonRoot: true + runAsUser: 10001 + runAsGroup: 10001 + fsGroup: 10001 + seccompProfile: + type: RuntimeDefault + containers: + - name: auth-server + image: registry.example.com/identity/auth-server@sha256:8f3c0a8c6b3a2a7a0f1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f6071 + imagePullPolicy: IfNotPresent + ports: + - name: http + containerPort: 8080 + - name: metrics + containerPort: 9090 + envFrom: + - configMapRef: + name: auth-server-config + - secretRef: + name: auth-server-db + - secretRef: + name: auth-server-oidc-client + volumeMounts: + - name: internal-tls + mountPath: /var/run/secrets/tls + readOnly: true + - name: appconfig + mountPath: /workspace/config + readOnly: true + - name: tmp + mountPath: /tmp + resources: + requests: + cpu: 200m + memory: 512Mi + limits: + cpu: "2" + memory: 1Gi + readinessProbe: + httpGet: + path: /actuator/health/readiness + port: http + livenessProbe: + httpGet: + path: /actuator/health/liveness + port: http + securityContext: + runAsNonRoot: true + runAsUser: 10001 + runAsGroup: 10001 + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: ["ALL"] + seccompProfile: + type: RuntimeDefault + - name: metrics-exporter + image: registry.example.com/platform/jmx-exporter@sha256:1111111111111111111111111111111111111111111111111111111111111111 + ports: + - name: jmx-metrics + containerPort: 9091 + resources: + requests: + cpu: 50m + memory: 64Mi + limits: + cpu: 200m + memory: 128Mi + securityContext: + runAsNonRoot: true + runAsUser: 10001 + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: ["ALL"] + seccompProfile: + type: RuntimeDefault + volumes: + - name: internal-tls + secret: + secretName: auth-server-internal-tls + defaultMode: 0400 + - name: appconfig + configMap: + name: auth-server-config + - name: tmp + emptyDir: + medium: Memory + sizeLimit: 64Mi + imagePullSecrets: + - name: registry-example-com +``` + +**왜 좋은가:** + +- VSO가 생성한 `auth-server-db`, `auth-server-oidc-client`를 envFrom으로 소비. 앱 코드는 `DB_USERNAME`, `DB_PASSWORD`, `OIDC_CLIENT_SECRET` 환경변수를 읽기만 함. +- TLS private key는 volume(`/var/run/secrets/tls`, mode 0400)으로만 마운트. env 노출 없음. +- `metrics-exporter` sidecar에는 **어떤 secret도 envFrom/volumeMount로 전달하지 않는다**. Scope 최소화. +- image는 digest pin, `imagePullPolicy: IfNotPresent`. + +--- + +## 나쁜 예시 1: plain Secret manifest + ConfigMap에 비밀 혼재 + +```yaml +apiVersion: v1 +kind: Secret +metadata: + name: auth-server-db + namespace: auth-prod +type: Opaque +stringData: + username: prod-admin + password: S3cur3P@ssw0rd! +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: auth-server-config + namespace: auth-prod +data: + application.yaml: | + spring: + datasource: + url: jdbc:postgresql://prod-db:5432/auth + username: prod-admin + password: S3cur3P@ssw0rd! +``` + +**문제:** + +- 운영 비밀이 Git에 평문으로 커밋된다. base64/stringData 여부와 무관. +- ConfigMap에 password가 들어가 있음 → RBAC `configmaps:get` 권한을 가진 모든 SA가 읽을 수 있음. +- secret source가 두 곳에 있어 회전 불가능. +- VSO/ESO/SealedSecrets 어느 경로에도 부합하지 않음. + +--- + +## 좋은 예시 4: ImagePullSecret을 VSO로 Vault에서 sync + +```yaml +apiVersion: secrets.hashicorp.com/v1beta1 +kind: VaultStaticSecret +metadata: + name: registry-example-com + namespace: auth-prod +spec: + vaultAuthRef: auth-server + mount: kv + type: kv-v2 + path: platform/registry/example-com + refreshAfter: 24h + destination: + name: registry-example-com + create: true + type: kubernetes.io/dockerconfigjson + transformation: + templates: + .dockerconfigjson: + text: | + { + "auths": { + "registry.example.com": { + "username": "{{ .Secrets.username }}", + "password": "{{ .Secrets.password }}", + "auth": "{{ printf "%s:%s" .Secrets.username .Secrets.password | b64enc }}" + } + } + } +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: auth-server + namespace: auth-prod +automountServiceAccountToken: true +imagePullSecrets: + - name: registry-example-com +``` + +**왜 좋은가:** + +- registry credential도 Vault가 SoT. 하드코딩 없음. +- VSO가 `kubernetes.io/dockerconfigjson` 타입 Secret을 생성. kubelet이 바로 인식. +- SA에 묶여 있어 Deployment마다 imagePullSecrets 반복 선언 불필요. + +--- + +## 좋은 예시 5: cert-manager + VSO 비교 — Ingress TLS는 cert-manager, internal mTLS는 VSO PKI + +cert-manager가 외부 공인 도메인용 `kubernetes.io/tls` Secret을 발급하고, VSO `VaultPKISecret`은 internal service mesh mTLS용 단기 인증서를 발급한다. 두 경로 모두 최종 형태는 `kubernetes.io/tls` Secret으로 동일하므로 앱은 secret name만 구분한다. + +```yaml +apiVersion: cert-manager.io/v1 +kind: Certificate +metadata: + name: auth-example-com + namespace: auth-prod +spec: + secretName: auth-example-com-tls + issuerRef: + kind: ClusterIssuer + name: letsencrypt-prod + dnsNames: + - auth.example.com + duration: 2160h + renewBefore: 360h +--- +apiVersion: secrets.hashicorp.com/v1beta1 +kind: VaultPKISecret +metadata: + name: auth-server-internal-tls + namespace: auth-prod +spec: + vaultAuthRef: auth-server + mount: pki_int + role: auth-server + commonName: auth-server.auth-prod.svc + ttl: 24h + destination: + name: auth-server-internal-tls + create: true + type: kubernetes.io/tls + rolloutRestartTargets: + - kind: Deployment + name: auth-server +``` + +**왜 좋은가:** + +- 외부 ACME 인증서는 공인 CA(Let's Encrypt), 내부는 조직 CA(Vault PKI)로 분리. +- 둘 다 같은 Secret 타입이라 Traefik/앱이 동일하게 소비 가능. +- VSO PKI는 24h TTL로 짧게 회전 → lateral movement window 최소화. + +--- + +## 좋은 예시 6: Vault Agent Injector가 K8s Secret 없이 파일로 템플릿 렌더링 + +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: legacy-report-generator + namespace: reports-prod +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: report-generator + template: + metadata: + labels: + app.kubernetes.io/name: report-generator + annotations: + vault.hashicorp.com/agent-inject: "true" + vault.hashicorp.com/role: "report-generator" + vault.hashicorp.com/agent-inject-secret-report.conf: "kv/data/reports/smtp" + vault.hashicorp.com/agent-inject-template-report.conf: | + {{- with secret "kv/data/reports/smtp" -}} + [smtp] + host = {{ .Data.data.host }} + port = {{ .Data.data.port }} + username = {{ .Data.data.username }} + password = {{ .Data.data.password }} + {{- end }} + vault.hashicorp.com/secret-volume-path-report.conf: "/vault/secrets" + vault.hashicorp.com/agent-inject-containers: "report-generator" + vault.hashicorp.com/agent-run-as-user: "10001" + vault.hashicorp.com/agent-run-as-group: "10001" + spec: + serviceAccountName: report-generator + automountServiceAccountToken: true + securityContext: + runAsNonRoot: true + runAsUser: 10001 + runAsGroup: 10001 + fsGroup: 10001 + seccompProfile: + type: RuntimeDefault + containers: + - name: report-generator + image: registry.example.com/reports/generator@sha256:2222222222222222222222222222222222222222222222222222222222222222 + ports: + - name: http + containerPort: 8080 + volumeMounts: + - name: tmp + mountPath: /tmp + resources: + requests: + cpu: 100m + memory: 256Mi + limits: + cpu: 500m + memory: 512Mi + securityContext: + runAsNonRoot: true + runAsUser: 10001 + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: ["ALL"] + seccompProfile: + type: RuntimeDefault + volumes: + - name: tmp + emptyDir: + medium: Memory + sizeLimit: 32Mi +``` + +**왜 좋은가:** + +- K8s Secret object가 **생성되지 않는다**. RBAC audit이 Secret API 호출 없이 Vault audit log로 대체된다. +- Vault Agent sidecar가 tmpfs에 템플릿 렌더링 → 앱은 파일만 읽음. +- legacy 앱이 INI/TOML 포맷 설정 파일을 요구할 때 적합. + +**VSO vs Vault Agent Injector:** + +| 항목 | VSO | Vault Agent Injector | +|---|---|---| +| 결과 | K8s Secret | Pod tmpfs 파일 | +| K8s API 노출 | Secret object 존재 | 없음 | +| 소비 방식 | envFrom/volume | file read | +| 회전 시 | `rolloutRestartTargets` | Agent re-render(인메모리) | +| 복잡도 | 낮음(CRD만) | 높음(sidecar/init) | +| 권장 | **운영 기본** | 템플릿/legacy 앱 | + +--- + +## 나쁜 예시 2: Vault Injector annotation을 모든 컨테이너에 적용 + env 렌더링 + +```yaml +metadata: + annotations: + vault.hashicorp.com/agent-inject: "true" + vault.hashicorp.com/role: "auth-server" + vault.hashicorp.com/agent-inject-secret-db: "kv/data/auth-server/db" + vault.hashicorp.com/agent-inject-template-db: | + {{- with secret "kv/data/auth-server/db" -}} + export DB_USERNAME={{ .Data.data.username }} + export DB_PASSWORD={{ .Data.data.password }} + {{- end }} +``` + +**문제:** + +- `agent-inject-containers` 미지정 → sidecar(metrics, proxy) 포함 모든 컨테이너의 `/vault/secrets`가 보임. +- `export DB_PASSWORD=...`를 `source`로 읽는 launcher 스크립트 → process env로 비밀이 흘러 `/proc//environ` 노출. +- dynamic lease renew를 활용하지 못하고, 회전 시 rollout trigger 없음. + +--- + +## 좋은 예시 7: SealedSecret (VSO 미도입 환경/bootstrap) + +```yaml +apiVersion: bitnami.com/v1alpha1 +kind: SealedSecret +metadata: + name: vault-bootstrap-token + namespace: vault +spec: + encryptedData: + token: AgCd9sK... (public key로 암호화된 blob) + template: + metadata: + name: vault-bootstrap-token + namespace: vault + type: Opaque +``` + +**왜 좋은가:** + +- Git 커밋 가능(public key로 암호화, cluster controller만 복호화). +- VSO 자체를 기동하기 위한 bootstrap credential(Vault root token, unseal key 대신 KMS auto-unseal 권장)에 적합. +- SealedSecrets controller가 `Secret`을 namespace에 materialize. + +**주의:** + +- 운영에서 **VSO가 기동되면 SealedSecrets 경로는 최소화**. 이중 source of truth 방지. +- Key 회전은 controller의 sealing key rotation 절차 준수. + +--- + +## 좋은 예시 8: EncryptionConfiguration for Secret at-rest (API Server 레벨) + +```yaml +apiVersion: apiserver.config.k8s.io/v1 +kind: EncryptionConfiguration +resources: + - resources: + - secrets + providers: + - kms: + apiVersion: v2 + name: platform-kms-v2 + endpoint: unix:///var/run/kmsplugin/socket.sock + timeout: 3s + - aescbc: + keys: + - name: fallback-2026-q1 + secret: c2VjcmV0LTMyLWJ5dGUtZmFsbGJhY2sta2V5LTIwMjZxMS1leGFtcGxl + - identity: {} +``` + +**K3s 활성화:** + +```yaml +# /etc/rancher/k3s/config.yaml +secrets-encryption: true +kube-apiserver-arg: + - "encryption-provider-config=/etc/rancher/k3s/encryption-config.yaml" + - "audit-policy-file=/etc/rancher/k3s/audit-policy.yaml" + - "audit-log-path=/var/log/k3s-audit.log" +``` + +**왜 좋은가:** + +- KMS v2 provider가 primary → envelope encryption, 키는 KMS 외부에 존재. +- `aescbc`는 fallback. `identity`는 마지막(평문), 기존 Secret을 재암호화하기 전 decryption용. +- K3s config.yaml에 `secrets-encryption: true`로 선언. 서버 재시작 후 `kubectl get secrets -A -o json | kubectl replace -f -`로 기존 Secret 재암호화. + +--- + +## 나쁜 예시 3: Kustomize secretGenerator로 운영 비밀 literal + +```yaml +# overlays/prod/kustomization.yaml +secretGenerator: + - name: auth-server-db + literals: + - username=prod-admin + - password=S3cur3P@ssw0rd! +``` + +**문제:** + +- 운영 비밀이 Git에 literal 평문 저장. +- Kustomize hash suffix는 비밀 보호가 아님. +- 회전 시 매번 Git 커밋 필요(감사/리뷰 시 비밀 노출). +- 운영은 VSO/ESO/SealedSecrets 경로로만 비밀을 배포해야 한다. diff --git a/docs/examples/infra/db-and-migration.md b/docs/examples/infra/db-and-migration.md new file mode 100644 index 0000000..8b683db --- /dev/null +++ b/docs/examples/infra/db-and-migration.md @@ -0,0 +1,499 @@ +# db / migration 예시 + +모든 YAML은 `kubectl apply` 가능하다. 상세 Flyway Job 예시는 `examples/infra/flyway.md` 참조. + +--- + +## 좋은 예시 1: auth-server와 keycloak DB 경계 분리 (CNPG 2 cluster) + +```yaml +--- +apiVersion: postgresql.cnpg.io/v1 +kind: Cluster +metadata: + name: auth-pg + namespace: data-prod + labels: + app.kubernetes.io/name: auth-pg + app.kubernetes.io/part-of: auth-platform + backup.platform.io/tier: gold +spec: + instances: 3 + imageName: ghcr.io/cloudnative-pg/postgresql:16.4-8 + bootstrap: + initdb: + database: auth + owner: auth_app + secret: {name: auth-pg-app} + storage: {size: 50Gi, storageClass: fast-ssd-retain} + walStorage: {size: 20Gi, storageClass: fast-ssd-retain} + monitoring: {enablePodMonitor: true} + backup: + retentionPolicy: "30d" + barmanObjectStore: + destinationPath: s3://acme-prod-pg-backups/auth-pg + s3Credentials: + accessKeyId: {name: cnpg-s3-credentials, key: ACCESS_KEY_ID} + secretAccessKey: {name: cnpg-s3-credentials, key: ACCESS_SECRET_KEY} + wal: {compression: gzip, maxParallel: 8} + data: {compression: gzip, immediateCheckpoint: true, jobs: 4} +--- +apiVersion: postgresql.cnpg.io/v1 +kind: Cluster +metadata: + name: keycloak-pg + namespace: data-prod + labels: + app.kubernetes.io/name: keycloak-pg + app.kubernetes.io/part-of: identity-platform + backup.platform.io/tier: gold +spec: + instances: 3 + imageName: ghcr.io/cloudnative-pg/postgresql:16.4-8 + bootstrap: + initdb: + database: keycloak + owner: keycloak + secret: {name: keycloak-pg-app} + storage: {size: 30Gi, storageClass: fast-ssd-retain} + walStorage: {size: 10Gi, storageClass: fast-ssd-retain} + monitoring: {enablePodMonitor: true} + backup: + retentionPolicy: "30d" + barmanObjectStore: + destinationPath: s3://acme-prod-pg-backups/keycloak-pg + s3Credentials: + accessKeyId: {name: cnpg-s3-credentials, key: ACCESS_KEY_ID} + secretAccessKey: {name: cnpg-s3-credentials, key: ACCESS_SECRET_KEY} + wal: {compression: gzip, maxParallel: 8} + data: {compression: gzip, jobs: 4} +``` + +왜 좋은가: +- auth와 keycloak이 별도 CNPG cluster → 장애 / 업그레이드 영향 분리 +- 각각 schema ownership이 분리되어 migration 파이프라인도 분리 가능 +- 백업 destination path도 분리 → retention / 암호화 정책 독립 + +❌ 나쁜 예시 1: 하나의 cluster의 하나의 database에 두 서비스 schema + +```yaml +# single CNPG cluster, database=shared +# auth-server uses schema "auth" +# keycloak uses schema "keycloak" +# one Flyway project manages both +``` + +문제: +- 서비스별 업그레이드 / restore 영향 격리 불가 +- Flyway history가 서로 섞임 +- 한 서비스가 lock을 오래 잡으면 다른 서비스가 멈춤 + +--- + +## 좋은 예시 2: migration을 Helm hook으로 app보다 먼저 실행 + +```yaml +--- +apiVersion: batch/v1 +kind: Job +metadata: + name: auth-flyway-migrate + namespace: auth-prod + labels: + app.kubernetes.io/name: auth-server + app.kubernetes.io/component: db-migration + app.kubernetes.io/managed-by: Helm + annotations: + "helm.sh/hook": "pre-upgrade,pre-install" + "helm.sh/hook-weight": "-10" + "helm.sh/hook-delete-policy": "before-hook-creation,hook-succeeded" +spec: + parallelism: 1 + completions: 1 + backoffLimit: 0 + activeDeadlineSeconds: 1800 + ttlSecondsAfterFinished: 86400 + template: + spec: + restartPolicy: Never + securityContext: + runAsNonRoot: true + runAsUser: 1000 + fsGroup: 1000 + seccompProfile: {type: RuntimeDefault} + containers: + - name: flyway + image: flyway/flyway@sha256:7d9f7c4e2a1b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d + args: ["-X", "migrate"] + env: + - {name: FLYWAY_URL, value: "jdbc:postgresql://auth-pg-rw.data-prod.svc:5432/auth"} + - {name: FLYWAY_USER, value: "auth_app"} + - {name: FLYWAY_LOCATIONS, value: "filesystem:/flyway/sql"} + - {name: FLYWAY_SCHEMAS, value: "auth_server"} + - {name: FLYWAY_DEFAULT_SCHEMA, value: "auth_server"} + - {name: FLYWAY_TABLE, value: "flyway_schema_history"} + - {name: FLYWAY_VALIDATE_ON_MIGRATE, value: "true"} + - {name: FLYWAY_BASELINE_ON_MIGRATE, value: "false"} + - {name: FLYWAY_CLEAN_DISABLED, value: "true"} + - name: FLYWAY_PASSWORD + valueFrom: {secretKeyRef: {name: auth-pg-app, key: password}} + resources: + requests: {cpu: "100m", memory: "256Mi"} + limits: {cpu: "1", memory: "1Gi"} + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: {drop: ["ALL"]} + volumeMounts: + - {name: sql, mountPath: /flyway/sql, readOnly: true} + - {name: tmp, mountPath: /tmp} + volumes: + - name: sql + configMap: {name: auth-flyway-sql} + - name: tmp + emptyDir: {} +``` + +왜 좋은가: +- Helm hook으로 app install/upgrade보다 **먼저** 실행 (`-10` weight) +- `before-hook-creation,hook-succeeded` 삭제 정책으로 이전 Job 깨끗이 정리 +- `cleanDisabled=true` 명시 (실수로 `flyway clean` 방지) +- `parallelism: 1`, `backoffLimit: 0`, `activeDeadlineSeconds: 1800` +- digest pinning, restricted PSA + +--- + +## 좋은 예시 3: Argo CD sync wave로 순서 지정 + +```yaml +--- +apiVersion: batch/v1 +kind: Job +metadata: + name: auth-flyway-migrate + namespace: auth-prod + labels: + app.kubernetes.io/name: auth-server + app.kubernetes.io/component: db-migration + annotations: + argocd.argoproj.io/sync-wave: "-1" + argocd.argoproj.io/hook: Sync + argocd.argoproj.io/hook-delete-policy: BeforeHookCreation +spec: + parallelism: 1 + completions: 1 + backoffLimit: 0 + activeDeadlineSeconds: 1800 + ttlSecondsAfterFinished: 86400 + template: + spec: + restartPolicy: Never + securityContext: + runAsNonRoot: true + runAsUser: 1000 + runAsGroup: 1000 + fsGroup: 1000 + seccompProfile: + type: RuntimeDefault + containers: + - name: flyway + image: flyway/flyway@sha256:7d9f7c4e2a1b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d + args: ["-X", "migrate"] + resources: + requests: { cpu: 100m, memory: 256Mi } + limits: { memory: 1Gi } + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: ["ALL"] +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: auth-server + namespace: auth-prod + labels: + app.kubernetes.io/name: auth-server + app.kubernetes.io/instance: auth-server-prod + annotations: + argocd.argoproj.io/sync-wave: "0" +spec: + replicas: 3 + selector: + matchLabels: + app.kubernetes.io/name: auth-server + app.kubernetes.io/instance: auth-server-prod + template: + metadata: + labels: + app.kubernetes.io/name: auth-server + app.kubernetes.io/instance: auth-server-prod + spec: + securityContext: + runAsNonRoot: true + runAsUser: 10001 + runAsGroup: 10001 + fsGroup: 10001 + seccompProfile: + type: RuntimeDefault + containers: + - name: auth-server + image: registry.example.com/identity/auth-server:1.24.0 + resources: + requests: { cpu: 500m, memory: 1Gi } + limits: { memory: 1536Mi } + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: ["ALL"] +``` + +왜 좋은가: +- Argo CD는 sync-wave가 낮은 것부터 실행 +- Helm hook과 혼용하지 않음 (한쪽만 사용) + +❌ 나쁜 예시 2: Helm hook + Argo CD hook 혼용 + +```yaml +annotations: + "helm.sh/hook": "pre-upgrade" + "argocd.argoproj.io/sync-wave": "-1" + "argocd.argoproj.io/hook": Sync +``` + +문제: +- Argo CD가 Helm chart를 렌더링할 때 Helm hook을 일반 리소스로 취급해 sync 순서가 꼬임 +- 실행이 중복되거나 누락됨 +- 한 방식으로 통일할 것 + +--- + +## 좋은 예시 4: Expand → Migrate → Contract 3단계 릴리즈 + +### 배경 +`users` 테이블의 `email` 컬럼 (NULL 허용)을 NOT NULL + 정규화된 `email_canonical` 컬럼으로 바꾸고 싶다. + +### Release 1 — Expand + +`V120__add_email_canonical_nullable.sql`: +```sql +-- flyway:executeInTransaction=false +ALTER TABLE users ADD COLUMN email_canonical text; +CREATE INDEX CONCURRENTLY idx_users_email_canonical ON users(email_canonical); +``` + +`V121__backfill_email_canonical.sql` (같은 릴리즈 또는 별도 배치 Job): +```sql +UPDATE users + SET email_canonical = lower(trim(email)) + WHERE email_canonical IS NULL + AND email IS NOT NULL; +``` + +앱은 쓰기: `email` + `email_canonical` 둘 다 채움. 읽기: 여전히 `email`. + +### Release 2 — Migrate + +앱 읽기 경로를 `email_canonical`로 전환. 새 가입/수정은 `email_canonical`만 보장. + +`V122__add_email_canonical_not_null.sql`: +```sql +-- 이 시점에는 모든 row에 email_canonical이 채워져 있어야 함 +ALTER TABLE users ALTER COLUMN email_canonical SET NOT NULL; +ALTER TABLE users ADD CONSTRAINT users_email_canonical_unique UNIQUE (email_canonical); +``` + +### Release 3 — Contract + +앱이 `email` 컬럼을 더 이상 읽지/쓰지 않는 버전으로 완전히 롤아웃된 뒤. + +`V130__drop_legacy_email_column.sql`: +```sql +ALTER TABLE users DROP COLUMN email; +``` + +왜 좋은가: +- 각 릴리즈가 N-1 ↔ N 동시 운영 가능 +- `CREATE INDEX CONCURRENTLY`는 `-- flyway:executeInTransaction=false`로 분리 +- Contract는 backfill + 앱 전환이 모두 끝난 뒤 별도 릴리즈 + +❌ 나쁜 예시 3: 한 릴리즈에 expand + contract + +```sql +-- V100__rename_email.sql +ALTER TABLE users RENAME COLUMN email TO email_old; +ALTER TABLE users ADD COLUMN email text NOT NULL DEFAULT ''; +-- 앱이 어느 버전이든 장애 발생 가능 +``` + +문제: +- rolling deploy 중간에 앱이 N-1 / N 모두 실행 → 컬럼 없음 / 이름 다름으로 에러 +- rollback 시 DB 상태가 앞서가 있어 N-1 앱이 기동 안 됨 + +--- + +## 좋은 예시 5: PITR 복구 계획 (CNPG) + +```yaml +--- +apiVersion: postgresql.cnpg.io/v1 +kind: Cluster +metadata: + name: auth-pg-restore + namespace: data-prod +spec: + instances: 3 + imageName: ghcr.io/cloudnative-pg/postgresql:16.4-8 + storage: {size: 50Gi, storageClass: fast-ssd-retain} + walStorage: {size: 20Gi, storageClass: fast-ssd-retain} + bootstrap: + recovery: + source: auth-pg-source + recoveryTarget: + targetTime: "2026-04-16 09:45:00+00" # 잘못된 migration 직전 + externalClusters: + - name: auth-pg-source + barmanObjectStore: + destinationPath: s3://acme-prod-pg-backups/auth-pg + s3Credentials: + accessKeyId: {name: cnpg-s3-credentials, key: ACCESS_KEY_ID} + secretAccessKey: {name: cnpg-s3-credentials, key: ACCESS_SECRET_KEY} + wal: {maxParallel: 8} +``` + +왜 좋은가: +- 운영 cluster는 건드리지 않고 `auth-pg-restore`로 복원 +- `recoveryTarget.targetTime`을 분단위로 지정 +- 복원 후 검증 → 운영 전환은 별도 runbook + +--- + +## 좋은 예시 6: non-transactional DDL을 별도 migration 파일로 + +`V200__create_idx_users_last_login.sql`: +```sql +-- flyway:executeInTransaction=false +-- Long-running DDL. Run in low-traffic window. +-- Runtime estimate: ~15min on 50M rows. +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_users_last_login + ON users(last_login_at); +``` + +왜 좋은가: +- `CREATE INDEX CONCURRENTLY`는 Postgres에서 트랜잭션 내 실행 불가 +- Flyway 8.2+ `executeInTransaction=false` directive로 파일 단위 제어 +- 주석에 runtime 추정치 / 영향 명시 + +❌ 나쁜 예시 4: 트랜잭션 내 CREATE INDEX CONCURRENTLY + +```sql +-- V200__.sql (기본 트랜잭션 모드) +CREATE INDEX CONCURRENTLY idx_users_last_login ON users(last_login_at); +-- → ERROR: CREATE INDEX CONCURRENTLY cannot run inside a transaction block +``` + +문제: +- Flyway가 자동으로 트랜잭션을 열기 때문에 실패 +- `-- flyway:executeInTransaction=false`가 필수 + +--- + +## 좋은 예시 7: 운영 절차 runbook snippet + +```text +# auth-server DB schema change — 2026-04-16 02:00 UTC maintenance window + +## Pre-check (T-1d) +1. Pending migration 검토: 로컬 `flyway info` +2. PR review + migration 영향 분석 문서 작성 (expand/migrate/contract 단계) +3. Backup 상태 확인: + kubectl -n data-prod get scheduledbackup auth-pg-daily + kubectl -n data-prod get backup -l cnpg.io/cluster=auth-pg --sort-by=.metadata.creationTimestamp + +## T-5min +1. On-demand backup: + cat < /backup/dump.sql +``` + +문제: +- PITR 불가, RPO = 24h +- replication slot / extension / large object 누락 +- 대규모 DB에서 restore 시간 폭증 +- 같은 cluster 안 PVC에 저장하면 동시 소실 + +--- + +## 나쁜 예시 7: U__ undo migration 작성 + +``` +flyway/ + V120__add_column.sql + U120__drop_column.sql ← OSS Flyway는 실행 불가 +``` + +문제: +- Flyway Community(OSS)는 undo 미지원 → `flyway undo`가 에러 +- rollback 전략은 forward-only migration + PITR로 대체 diff --git a/docs/examples/infra/flyway.md b/docs/examples/infra/flyway.md new file mode 100644 index 0000000..aba48ac --- /dev/null +++ b/docs/examples/infra/flyway.md @@ -0,0 +1,560 @@ +# Flyway 예시 + +전 예시는 `kubectl apply -f` 가능한 완성 매니페스트다. 1000+ 서비스 규모에서 복사/수정해 쓸 수 있도록 full manifest로 구성했다. + +--- + +## 좋은 예시 1: 완전한 Flyway Job (Helm hook 패턴) + +### (1) ConfigMap — migration SQL + +```yaml +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: auth-flyway-sql + namespace: auth-prod + labels: + app.kubernetes.io/name: auth-server + app.kubernetes.io/component: db-migration + app.kubernetes.io/part-of: auth-platform + app.kubernetes.io/managed-by: Helm +data: + V1__init_auth_schema.sql: | + CREATE TABLE IF NOT EXISTS users ( + id bigserial PRIMARY KEY, + email text NOT NULL, + display_name text, + created_at timestamptz NOT NULL DEFAULT now() + ); + CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email ON users(lower(email)); + V2__add_refresh_tokens.sql: | + CREATE TABLE IF NOT EXISTS refresh_tokens ( + id bigserial PRIMARY KEY, + user_id bigint NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_hash bytea NOT NULL, + expires_at timestamptz NOT NULL, + created_at timestamptz NOT NULL DEFAULT now() + ); + CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user_id ON refresh_tokens(user_id); + V3__add_last_login_column.sql: | + ALTER TABLE users ADD COLUMN IF NOT EXISTS last_login_at timestamptz; + V4__create_idx_last_login_concurrently.sql: | + -- flyway:executeInTransaction=false + -- Long-running DDL. Schedule in low-traffic window. + CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_users_last_login + ON users(last_login_at); + R__refresh_active_users_view.sql: | + CREATE OR REPLACE VIEW active_users AS + SELECT id, email, display_name, last_login_at + FROM users + WHERE last_login_at > now() - interval '30 days'; +``` + +### (2) Vault Secrets Operator — DB password + +```yaml +--- +apiVersion: secrets.hashicorp.com/v1beta1 +kind: VaultStaticSecret +metadata: + name: auth-pg-app + namespace: auth-prod + labels: + app.kubernetes.io/name: auth-server + app.kubernetes.io/component: db-migration +spec: + type: kv-v2 + mount: kv + path: auth-prod/postgres/app + destination: + name: auth-pg-app + create: true + type: Opaque + refreshAfter: 1h + vaultAuthRef: vault-auth-auth-prod +``` + +### (3) Flyway Job — pre-upgrade / pre-install + +```yaml +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: auth-flyway + namespace: auth-prod + labels: + app.kubernetes.io/name: auth-server + app.kubernetes.io/component: db-migration +--- +apiVersion: batch/v1 +kind: Job +metadata: + name: auth-flyway-migrate + namespace: auth-prod + labels: + app.kubernetes.io/name: auth-server + app.kubernetes.io/instance: auth-server-prod + app.kubernetes.io/component: db-migration + app.kubernetes.io/part-of: auth-platform + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/version: "2026.04.16" + annotations: + "helm.sh/hook": "pre-upgrade,pre-install" + "helm.sh/hook-weight": "-10" + "helm.sh/hook-delete-policy": "before-hook-creation,hook-succeeded" +spec: + parallelism: 1 + completions: 1 + backoffLimit: 0 + activeDeadlineSeconds: 1800 + ttlSecondsAfterFinished: 86400 + template: + metadata: + labels: + app.kubernetes.io/name: auth-server + app.kubernetes.io/component: db-migration + spec: + serviceAccountName: auth-flyway + restartPolicy: Never + securityContext: + runAsNonRoot: true + runAsUser: 1000 + runAsGroup: 1000 + fsGroup: 1000 + seccompProfile: {type: RuntimeDefault} + initContainers: + - name: flyway-info + image: flyway/flyway@sha256:7d9f7c4e2a1b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d + imagePullPolicy: IfNotPresent + args: ["info"] + env: &flywayEnv + - {name: FLYWAY_URL, value: "jdbc:postgresql://auth-pg-rw.data-prod.svc:5432/auth?sslmode=require"} + - {name: FLYWAY_USER, value: "auth_app"} + - {name: FLYWAY_LOCATIONS, value: "filesystem:/flyway/sql"} + - {name: FLYWAY_SCHEMAS, value: "auth_server"} + - {name: FLYWAY_DEFAULT_SCHEMA, value: "auth_server"} + - {name: FLYWAY_TABLE, value: "flyway_schema_history"} + - {name: FLYWAY_VALIDATE_ON_MIGRATE, value: "true"} + - {name: FLYWAY_BASELINE_ON_MIGRATE, value: "false"} + - {name: FLYWAY_OUT_OF_ORDER, value: "false"} + - {name: FLYWAY_MIXED, value: "false"} + - {name: FLYWAY_CLEAN_DISABLED, value: "true"} + - name: FLYWAY_PASSWORD + valueFrom: {secretKeyRef: {name: auth-pg-app, key: password}} + resources: + requests: {cpu: "50m", memory: "128Mi"} + limits: {cpu: "500m", memory: "512Mi"} + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: {drop: ["ALL"]} + volumeMounts: + - {name: sql, mountPath: /flyway/sql, readOnly: true} + - {name: tmp, mountPath: /tmp} + - name: flyway-validate + image: flyway/flyway@sha256:7d9f7c4e2a1b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d + imagePullPolicy: IfNotPresent + args: ["validate"] + env: *flywayEnv + resources: + requests: {cpu: "50m", memory: "128Mi"} + limits: {cpu: "500m", memory: "512Mi"} + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: {drop: ["ALL"]} + volumeMounts: + - {name: sql, mountPath: /flyway/sql, readOnly: true} + - {name: tmp, mountPath: /tmp} + containers: + - name: flyway-migrate + image: flyway/flyway@sha256:7d9f7c4e2a1b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d + imagePullPolicy: IfNotPresent + args: ["-X", "migrate"] + env: *flywayEnv + resources: + requests: {cpu: "100m", memory: "256Mi"} + limits: {cpu: "1", memory: "1Gi"} + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: {drop: ["ALL"]} + volumeMounts: + - {name: sql, mountPath: /flyway/sql, readOnly: true} + - {name: tmp, mountPath: /tmp} + volumes: + - name: sql + configMap: {name: auth-flyway-sql} + - name: tmp + emptyDir: {} +``` + +왜 좋은가: +- Helm hook으로 app Deployment보다 **먼저** 실행 (`pre-upgrade,pre-install`, weight `-10`) +- `before-hook-creation,hook-succeeded` 삭제 정책으로 과거 Job 정리 +- initContainer로 `info` + `validate`를 먼저 실행해 실패를 앞당김 +- 메인 container에서 `migrate` (advisory lock 덕분에 같은 Job이 중복 실행돼도 직렬화됨) +- `FLYWAY_CLEAN_DISABLED=true` (production 필수) +- `FLYWAY_BASELINE_ON_MIGRATE=false`, `FLYWAY_OUT_OF_ORDER=false` +- digest pinning, restricted PSA, anchor/alias로 env 중복 제거 +- `parallelism: 1`, `backoffLimit: 0`, `activeDeadlineSeconds: 1800` + +--- + +## 좋은 예시 2: Argo CD sync-wave 패턴 + +```yaml +--- +apiVersion: batch/v1 +kind: Job +metadata: + name: auth-flyway-migrate + namespace: auth-prod + labels: + app.kubernetes.io/name: auth-server + app.kubernetes.io/component: db-migration + app.kubernetes.io/managed-by: argocd + annotations: + argocd.argoproj.io/sync-wave: "-1" + argocd.argoproj.io/hook: Sync + argocd.argoproj.io/hook-delete-policy: BeforeHookCreation +spec: + parallelism: 1 + completions: 1 + backoffLimit: 0 + activeDeadlineSeconds: 1800 + ttlSecondsAfterFinished: 86400 + template: + spec: + restartPolicy: Never + securityContext: + runAsNonRoot: true + runAsUser: 1000 + runAsGroup: 1000 + fsGroup: 1000 + seccompProfile: + type: RuntimeDefault + containers: + # (containers 세부는 예시 1과 동일; 요지만 재현) + - name: flyway + image: flyway/flyway@sha256:7d9f7c4e2a1b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d + args: ["-X", "migrate"] + resources: + requests: { cpu: 100m, memory: 256Mi } + limits: { memory: 1Gi } + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: ["ALL"] +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: auth-server + namespace: auth-prod + annotations: + argocd.argoproj.io/sync-wave: "0" + labels: + app.kubernetes.io/name: auth-server + app.kubernetes.io/instance: auth-server-prod +spec: + replicas: 3 + selector: + matchLabels: + app.kubernetes.io/name: auth-server + app.kubernetes.io/instance: auth-server-prod + template: + metadata: + labels: + app.kubernetes.io/name: auth-server + app.kubernetes.io/instance: auth-server-prod + spec: + securityContext: + runAsNonRoot: true + runAsUser: 10001 + runAsGroup: 10001 + fsGroup: 10001 + seccompProfile: + type: RuntimeDefault + containers: + - name: auth-server + image: registry.example.com/identity/auth-server:1.24.0 + resources: + requests: { cpu: 500m, memory: 1Gi } + limits: { memory: 1536Mi } + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: ["ALL"] +``` + +왜 좋은가: +- Argo CD가 wave `-1` → `0` 순서로 sync +- Helm hook과 혼용하지 않음 +- `BeforeHookCreation` 정책으로 이전 Job 정리 후 새 Job 실행 + +--- + +## 좋은 예시 3: non-transactional DDL 전용 migration + +`V4__create_idx_last_login_concurrently.sql`: +```sql +-- flyway:executeInTransaction=false +-- CREATE INDEX CONCURRENTLY는 Postgres에서 트랜잭션 내 실행 불가. +-- Flyway 8.2+ directive로 파일 단위 트랜잭션 비활성화. +-- Runtime estimate: 약 15분 (50M rows 기준). +-- Deploy window: 주간 트래픽 저점 (예: 화요일 03:00 UTC) +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_users_last_login + ON users(last_login_at); +``` + +왜 좋은가: +- 파일 단독으로 분리 (다른 statement 없음) +- 주석에 runtime / window 명시 +- `IF NOT EXISTS`로 재실행 안전성 (CREATE INDEX CONCURRENTLY 실패 시 INVALID 인덱스가 남을 수 있음 — 별도 cleanup 필요) + +❌ 나쁜 예시 1: 트랜잭션 내 CREATE INDEX CONCURRENTLY + +```sql +-- V4__.sql (executeInTransaction directive 없음) +CREATE INDEX CONCURRENTLY idx_users_last_login ON users(last_login_at); +``` + +문제: +- Flyway가 자동으로 트랜잭션을 열어 실행 → `ERROR: CREATE INDEX CONCURRENTLY cannot run inside a transaction block` +- 해결: `-- flyway:executeInTransaction=false` directive + +--- + +## 좋은 예시 4: history table schema를 명시적으로 분리 + +env: +```yaml +- {name: FLYWAY_CREATE_SCHEMAS, value: "false"} +- {name: FLYWAY_INIT_SQL, value: "CREATE SCHEMA IF NOT EXISTS auth_server; CREATE SCHEMA IF NOT EXISTS flyway_history"} +- {name: FLYWAY_DEFAULT_SCHEMA, value: "flyway_history"} +- {name: FLYWAY_SCHEMAS, value: "flyway_history,auth_server"} +- {name: FLYWAY_TABLE, value: "flyway_schema_history"} +``` + +왜 좋은가: +- history table은 `flyway_history.flyway_schema_history` +- migration 대상 schema는 `auth_server` +- `createSchemas=false` 조건 하에서 `initSql`로 schema 사전 생성 + +--- + +## 좋은 예시 5: 운영 절차 (Helm + on-demand CNPG backup 연계) + +```bash +# 1. pending migration 확인 (로컬) +docker run --rm -v $PWD/sql:/flyway/sql:ro \ + -e FLYWAY_URL=jdbc:postgresql://stage.../auth \ + -e FLYWAY_USER=auth_app -e FLYWAY_PASSWORD=... \ + flyway/flyway@sha256:... info + +# 2. PR review + migration 영향 분석 + +# 3. 운영 배포 직전 on-demand backup +cat < 5001 # Spegel p2p gossip +nc -zv 6443 # Local registry + K3s supervisor +``` + +**왜 좋은가:** + +- 공식 문서 기준 포트 (`TCP 5001 + TCP 6443`) 정확히 반영 +- external mirror (Harbor) + intra-cluster p2p 공유 조합 → airgap 경계 대비 +- `embedded-registry: true`가 **모든 노드 (server+agent)의 config.yaml에 동일**하게 박혀야 함 + +--- + +## 좋은 예시 5: local-path를 dev/test에만 제한 (StorageClass 레벨) + +```yaml +# local-path: default false, only used when explicitly requested +apiVersion: storage.k8s.io/v1 +kind: StorageClass +metadata: + name: local-path + annotations: + storageclass.kubernetes.io/is-default-class: "false" + labels: + app.kubernetes.io/name: local-path + app.kubernetes.io/instance: local-path-dev + app.kubernetes.io/component: storage + app.kubernetes.io/part-of: platform + app.kubernetes.io/managed-by: argocd + example.com/environment: dev + example.com/storage-tier: local-ephemeral +provisioner: rancher.io/local-path +reclaimPolicy: Delete +volumeBindingMode: WaitForFirstConsumer +--- +apiVersion: storage.k8s.io/v1 +kind: StorageClass +metadata: + name: longhorn-replicated + annotations: + storageclass.kubernetes.io/is-default-class: "true" + labels: + app.kubernetes.io/name: longhorn + app.kubernetes.io/instance: longhorn-prod + app.kubernetes.io/component: storage + app.kubernetes.io/part-of: platform + app.kubernetes.io/managed-by: argocd + example.com/environment: prod + example.com/storage-tier: replicated-persistent +provisioner: driver.longhorn.io +allowVolumeExpansion: true +reclaimPolicy: Retain +volumeBindingMode: WaitForFirstConsumer +parameters: + numberOfReplicas: "3" + staleReplicaTimeout: "30" + fromBackup: "" + fsType: "ext4" + dataLocality: "best-effort" +``` + +**왜 좋은가:** + +- `local-path`는 default가 아니고 `example.com/storage-tier: local-ephemeral`로 dev에서만 수용 +- prod default는 Longhorn replicated (3 replica) + `reclaimPolicy: Retain` +- DB/Vault/MinIO PVC는 `storageClassName: longhorn-replicated` 명시 + +--- + +## 좋은 예시 6: registries.yaml에서 production pull-through mirror + +```yaml +# /etc/rancher/k3s/registries.yaml (every node) +mirrors: + docker.io: + endpoint: + - "https://harbor.prod.example.internal/v2/dockerhub-proxy" + quay.io: + endpoint: + - "https://harbor.prod.example.internal/v2/quay-proxy" + registry.k8s.io: + endpoint: + - "https://harbor.prod.example.internal/v2/k8s-proxy" + ghcr.io: + endpoint: + - "https://harbor.prod.example.internal/v2/ghcr-proxy" +configs: + "harbor.prod.example.internal": + tls: + ca_file: "/etc/rancher/k3s/harbor-ca.crt" + auth: + username: "robot$k3s-pull" + password: "__HARBOR_PULL_TOKEN__" +``` + +**왜 좋은가:** + +- public registry rate limit / downtime이 클러스터 pull을 못 죽임 +- Harbor에서 CVE scan + image signing 검증 +- 모든 노드에 동일 파일 (Ansible/Fleet push) + +--- + +## 나쁜 예시 1: packaged traefik.yaml 직접 edit + +```bash +ssh k3s-server-1 +sudo vim /var/lib/rancher/k3s/server/manifests/traefik.yaml +# added forwardedHeaders.trustedIPs inline +sudo systemctl restart k3s +``` + +**문제:** K3s는 재시작 시 이 파일을 packaged 기본값으로 overwrite한다. 커스터마이징이 조용히 사라지고 서버별로 drift까지 생긴다. `HelmChartConfig`만 허용되는 경로. + +--- + +## 나쁜 예시 2: server 간 서로 다른 critical 플래그 + +```yaml +# k3s-server-1: /etc/rancher/k3s/config.yaml +cluster-cidr: "10.42.0.0/16" +disable: [ traefik, servicelb ] +``` + +```yaml +# k3s-server-2: /etc/rancher/k3s/config.yaml +cluster-cidr: "10.44.0.0/16" # mismatched +disable: [ traefik ] # mismatched +``` + +**문제:** `critical configuration value mismatch` 로 server-2의 join이 실패하거나, 최악의 경우 이전 값이 캐시되어 silent drift가 생긴다. critical 값은 **Git 하나의 파일**로 통일해야 한다. + +--- + +## 나쁜 예시 3: embedded registry mirror를 켜고 firewall 포트 미개방 + +```yaml +# /etc/rancher/k3s/config.yaml (all nodes) +embedded-registry: true +``` + +```bash +# On each node, firewalld / iptables only allows 6443, 10250, 8472 +# Port 5001 is CLOSED between nodes +``` + +**문제:** Spegel은 **TCP 5001 (p2p) + TCP 6443 (registry + supervisor)** 양쪽이 모든 노드 간 reachable해야 한다. 5001이 막혀있으면 p2p gossip 실패로 image sharing이 작동하지 않고, pull 지연이 오히려 커진다. 공식 기준: `https://docs.k3s.io/installation/registry-mirror`. + +--- + +## 나쁜 예시 4: 운영 AddOn을 서버마다 scp로 push + +```bash +scp ingress-custom.yaml k3s-server-1:/var/lib/rancher/k3s/server/manifests/ +# forgot server-2 and server-3 +``` + +**문제:** K3s는 이 디렉터리를 server 간 동기화하지 않는다. 리더가 server-2로 바뀌면 AddOn이 사라진 것처럼 보인다. Git + ArgoCD/Flux가 단일 진입점이어야 한다. + +--- + +## 나쁜 예시 5: prod postgres StatefulSet을 `local-path`에 배치 + +```yaml +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: data-postgres-0 + namespace: prod-data-postgres +spec: + storageClassName: local-path + accessModes: [ ReadWriteOnce ] + resources: + requests: + storage: 200Gi +``` + +**문제:** local-path = 노드 hostPath. 노드가 죽으면 PVC 데이터도 죽는다. 스냅샷 불가, 복제 불가, 마이그레이션 불가. prod DB는 Longhorn replicated / Ceph RBD / 외부 CSI 필수. + +--- + +## 나쁜 예시 6: Traefik 유지하면서 `HelmChartConfig` 이름을 잘못 박음 + +```yaml +apiVersion: helm.cattle.io/v1 +kind: HelmChartConfig +metadata: + name: traefik-custom # WRONG: must match the packaged HelmChart name + namespace: kube-system +spec: + valuesContent: |- + deployment: + replicas: 3 +``` + +**문제:** `HelmChartConfig`의 `metadata.name`은 K3s가 생성한 `HelmChart`와 **이름·namespace 모두 일치**해야 override가 merge된다. `traefik-custom`은 무시되고, override가 반영되지 않는다. 올바른 이름은 `traefik`. diff --git a/docs/examples/infra/keycloak.md b/docs/examples/infra/keycloak.md new file mode 100644 index 0000000..e972c1e --- /dev/null +++ b/docs/examples/infra/keycloak.md @@ -0,0 +1,668 @@ +# Keycloak 예시 + +Keycloak 26+ (Quarkus distribution) + Keycloak Operator 기준. 모든 YAML은 그대로 `kubectl apply`로 적용 가능한 완전한 manifest다. + +--- + +## 좋은 예시 1: optimized 이미지 빌드 (두 단계) + +`kc.sh build`로 Quarkus augmentation을 굽고, 실행 이미지를 분리한다. + +```dockerfile +# Dockerfile.keycloak +FROM quay.io/keycloak/keycloak:26.0.7 AS builder + +ENV KC_DB=postgres +ENV KC_HEALTH_ENABLED=true +ENV KC_METRICS_ENABLED=true +ENV KC_CACHE=ispn +ENV KC_CACHE_STACK=jdbc-ping +ENV KC_FEATURES=token-exchange,admin-fine-grained-authz +ENV KC_HTTP_ENABLED=true + +RUN /opt/keycloak/bin/kc.sh build + +FROM quay.io/keycloak/keycloak:26.0.7 + +COPY --from=builder /opt/keycloak/ /opt/keycloak/ + +USER 1000 + +ENTRYPOINT ["/opt/keycloak/bin/kc.sh", "start", "--optimized"] +``` + +**왜 좋은가:** + +- 빌드 단계에서 augmentation 완료, 런타임은 runtime-only config만 수신 +- `--optimized` 플래그로 매 기동 시 build 재실행 방지 (cold start 50% 단축) +- v26+ `--proxy` 제거 대응: legacy 옵션이 build 시 포함되지 않음 + +--- + +## 나쁜 예시 1: dev mode / 매 기동 build + +```yaml +args: + - start-dev +``` + +또는 + +```yaml +args: + - start +``` + +**문제:** + +- `start-dev`는 hostname-strict=false, H2 in-memory DB, TLS 해제 — production 부적합 +- `start`는 optimized 이미지가 아니면 매 기동마다 Quarkus augmentation 수행 → cold start 2배+ +- v26에서 `--proxy edge` 같은 legacy 옵션은 아예 기동 실패 + +--- + +## 좋은 예시 2: Keycloak Operator Keycloak CR (1차 권장) + +```yaml +--- +apiVersion: v1 +kind: Namespace +metadata: + name: keycloak + labels: + pod-security.kubernetes.io/enforce: restricted + pod-security.kubernetes.io/audit: restricted +--- +apiVersion: v1 +kind: Secret +metadata: + name: keycloak-db-secret + namespace: keycloak +type: Opaque +stringData: + username: keycloak + password: REPLACE_VIA_VSO +--- +apiVersion: v1 +kind: Secret +metadata: + name: keycloak-tls + namespace: keycloak +type: kubernetes.io/tls +data: + tls.crt: LS0tLS1CRUdJTi... # cert-manager 발급 권장 + tls.key: LS0tLS1CRUdJTi... +--- +apiVersion: k8s.keycloak.org/v2alpha1 +kind: Keycloak +metadata: + name: keycloak + namespace: keycloak + labels: + app.kubernetes.io/name: keycloak + app.kubernetes.io/instance: keycloak-prod + app.kubernetes.io/part-of: identity-platform + app.kubernetes.io/managed-by: keycloak-operator +spec: + instances: 3 + image: registry.example.com/platform/keycloak:26.0.7-optimized + startOptimized: true + db: + vendor: postgres + host: keycloak-db-rw.keycloak.svc.cluster.local + port: 5432 + database: keycloak + usernameSecret: + name: keycloak-db-secret + key: username + passwordSecret: + name: keycloak-db-secret + key: password + poolMinSize: 5 + poolInitialSize: 5 + poolMaxSize: 20 + hostname: + hostname: https://auth.example.com + admin: https://admin-auth.example.com + strict: true + backchannelDynamic: false + http: + httpEnabled: true + tlsSecret: keycloak-tls + proxy: + headers: xforwarded + features: + enabled: + - token-exchange + - admin-fine-grained-authz + additionalOptions: + - name: cache + value: ispn + - name: cache-stack + value: jdbc-ping + - name: log-console-output + value: json + - name: metrics-enabled + value: "true" + - name: health-enabled + value: "true" + resources: + requests: + cpu: 500m + memory: 1Gi + limits: + cpu: "2" + memory: 2Gi + scheduling: + topologySpreadConstraints: + - maxSkew: 1 + topologyKey: topology.kubernetes.io/zone + whenUnsatisfiable: ScheduleAnyway + labelSelector: + matchLabels: + app: keycloak + affinity: + podAntiAffinity: + preferredDuringSchedulingIgnoredDuringExecution: + - weight: 100 + podAffinityTerm: + topologyKey: kubernetes.io/hostname + labelSelector: + matchLabels: + app: keycloak +--- +apiVersion: policy/v1 +kind: PodDisruptionBudget +metadata: + name: keycloak + namespace: keycloak +spec: + minAvailable: 2 + unhealthyPodEvictionPolicy: AlwaysAllow + selector: + matchLabels: + app: keycloak +``` + +**왜 좋은가:** + +- Operator가 StatefulSet, Service, cache stack 설정을 자동 관리 +- hostname v2 (full URL, admin host 분리, strict=true, backchannelDynamic=false) 명시 +- `startOptimized: true`로 Operator가 `kc.sh start --optimized` 실행 +- PDB `minAvailable: 2` + topologySpread로 zone-level disruption 방어 + +--- + +## 나쁜 예시 2: 수제 Deployment + `--proxy edge` + +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: keycloak +spec: + replicas: 1 + template: + spec: + containers: + - name: keycloak + image: quay.io/keycloak/keycloak:26.0.7 + args: ["start", "--proxy", "edge"] + env: + - name: KC_HOSTNAME + value: auth.example.com + - name: KC_HOSTNAME_STRICT + value: "false" +``` + +**문제:** + +- `--proxy` 옵션은 v26에서 제거되어 기동 실패 +- `KC_HOSTNAME`에 scheme 없는 호스트명 단독 전달 → v2 검증에서 경고 +- `KC_HOSTNAME_STRICT=false`는 proxy hop이 Host 헤더를 조작할 수 있는 공격 벡터를 열어둠 +- replicas: 1 + Deployment → rolling update 시 Infinispan cluster membership 이슈 + 단일 장애 + +--- + +## 좋은 예시 3: Probe (management port 9000) + +```yaml +ports: + - name: http + containerPort: 8080 + protocol: TCP + - name: management + containerPort: 9000 + protocol: TCP + +startupProbe: + httpGet: + path: /health/started + port: 9000 + scheme: HTTP + periodSeconds: 5 + failureThreshold: 60 + timeoutSeconds: 3 + +readinessProbe: + httpGet: + path: /health/ready + port: 9000 + scheme: HTTP + periodSeconds: 10 + failureThreshold: 3 + timeoutSeconds: 3 + +livenessProbe: + httpGet: + path: /health/live + port: 9000 + scheme: HTTP + initialDelaySeconds: 60 + periodSeconds: 30 + failureThreshold: 3 + timeoutSeconds: 3 +``` + +**왜 좋은가:** + +- 9000은 management port (`KC_HTTP_MANAGEMENT_PORT` 기본값) +- startupProbe 5분 유예: JVM + Quarkus + DB migration cold start 수용 +- readiness는 `/health/ready` (DB connectivity 포함), liveness는 `/health/live` (프로세스 생존) + +--- + +## 나쁜 예시 3: Probe를 8080 `/` 로 설정 + +```yaml +readinessProbe: + httpGet: + path: / + port: 8080 + periodSeconds: 3 + failureThreshold: 2 +``` + +**문제:** + +- 8080 `/`는 redirect 응답이고 DB / cache readiness를 검증하지 않음 +- `failureThreshold: 2` + `periodSeconds: 3`은 cold start 중 pod 재시작 유발 +- health endpoint가 켜져 있어도 사용하지 않아 관찰 포인트 상실 + +--- + +## 좋은 예시 4: Service + ServiceMonitor + +```yaml +--- +apiVersion: v1 +kind: Service +metadata: + name: keycloak + namespace: keycloak + labels: + app.kubernetes.io/name: keycloak + app.kubernetes.io/instance: keycloak-prod +spec: + type: ClusterIP + selector: + app: keycloak + ports: + - name: http + port: 8080 + targetPort: 8080 + - name: management + port: 9000 + targetPort: 9000 +--- +apiVersion: v1 +kind: Service +metadata: + name: keycloak-headless + namespace: keycloak +spec: + type: ClusterIP + clusterIP: None + selector: + app: keycloak + ports: + - name: http + port: 8080 + targetPort: 8080 +--- +apiVersion: monitoring.coreos.com/v1 +kind: ServiceMonitor +metadata: + name: keycloak + namespace: keycloak + labels: + app.kubernetes.io/name: keycloak + release: kube-prometheus-stack +spec: + selector: + matchLabels: + app.kubernetes.io/name: keycloak + endpoints: + - port: management + path: /metrics + interval: 30s + scrapeTimeout: 10s +``` + +**왜 좋은가:** + +- ClusterIP Service가 사용자 트래픽용(8080), management(9000)을 분리 expose +- Headless service는 cache peer discovery 보조 (jdbc-ping에서는 불필요하지만 DNS_PING fallback 대비) +- ServiceMonitor는 management port의 `/metrics`만 scrape + +--- + +## 좋은 예시 5: Ingress — SSO host + Admin host 분리 + +```yaml +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: keycloak-sso + namespace: keycloak + annotations: + cert-manager.io/cluster-issuer: letsencrypt-prod + nginx.ingress.kubernetes.io/proxy-buffer-size: "128k" + nginx.ingress.kubernetes.io/proxy-body-size: "4m" +spec: + ingressClassName: nginx + tls: + - hosts: + - auth.example.com + secretName: keycloak-sso-tls + rules: + - host: auth.example.com + http: + paths: + - path: /realms/ + pathType: Prefix + backend: + service: + name: keycloak + port: + number: 8080 + - path: /resources/ + pathType: Prefix + backend: + service: + name: keycloak + port: + number: 8080 + - path: /.well-known/ + pathType: Prefix + backend: + service: + name: keycloak + port: + number: 8080 + - path: /js/ + pathType: Prefix + backend: + service: + name: keycloak + port: + number: 8080 +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: keycloak-admin + namespace: keycloak + annotations: + cert-manager.io/cluster-issuer: letsencrypt-prod + nginx.ingress.kubernetes.io/whitelist-source-range: "10.0.0.0/8,192.168.0.0/16" + nginx.ingress.kubernetes.io/auth-url: "https://oauth2-proxy.example.com/oauth2/auth" +spec: + ingressClassName: nginx + tls: + - hosts: + - admin-auth.example.com + secretName: keycloak-admin-tls + rules: + - host: admin-auth.example.com + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: keycloak + port: + number: 8080 +``` + +**왜 좋은가:** + +- SSO host는 `/realms/`, `/resources/`, `/.well-known/`, `/js/` 만 공개 (필요 최소) +- Admin host는 별도 hostname + IP whitelist + forward-auth 2중 보호 +- `/metrics`, `/health*`, `/admin/`이 SSO host에 노출되지 않음 + +--- + +## 나쁜 예시 4: 전체 공개 + 9000 노출 + +```yaml +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: keycloak +spec: + rules: + - host: auth.example.com + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: keycloak + port: + number: 8080 + - path: /metrics + pathType: Prefix + backend: + service: + name: keycloak + port: + number: 9000 +``` + +**문제:** + +- `/` 공개 → `/admin/` 포함 전부 외부 노출 → credential stuffing / brute force 표면 확장 +- `/metrics`는 인증이 없는 운영 데이터 endpoint → 정보 유출 +- 9000 management port가 인터넷에 노출 → health/metrics 둘 다 오픈 + +--- + +## 좋은 예시 6: KeycloakRealmImport CR + +```yaml +--- +apiVersion: v1 +kind: Secret +metadata: + name: platform-realm + namespace: keycloak +type: Opaque +stringData: + realm.json: | + { + "realm": "platform", + "enabled": true, + "sslRequired": "external", + "registrationAllowed": false, + "loginWithEmailAllowed": true, + "accessTokenLifespan": 300, + "clients": [ + { + "clientId": "auth-server", + "protocol": "openid-connect", + "publicClient": false, + "standardFlowEnabled": true, + "redirectUris": ["https://auth-server.example.com/*"], + "webOrigins": ["https://auth-server.example.com"] + } + ], + "roles": { + "realm": [ + {"name": "platform-admin"}, + {"name": "platform-user"} + ] + } + } +--- +apiVersion: k8s.keycloak.org/v2alpha1 +kind: KeycloakRealmImport +metadata: + name: platform-realm + namespace: keycloak +spec: + keycloakCRName: keycloak + realm: + realm: platform + enabled: true + sslRequired: external + registrationAllowed: false + loginWithEmailAllowed: true + accessTokenLifespan: 300 +``` + +**왜 좋은가:** + +- Realm을 선언적으로 관리 (GitOps 연계) +- Operator가 `keycloak` CR ready 이후 server-side import Job을 자동 생성 +- client secret처럼 민감한 값은 별도 Vault 경로로 분리, realm JSON은 Git 안전 + +--- + +## 나쁜 예시 5: kcadm.sh pipeline 직접 호출 + +```bash +# CI pipeline +kcadm.sh config credentials \ + --server https://auth.example.com \ + --realm master \ + --user admin \ + --password $KEYCLOAK_ADMIN_PASSWORD + +kcadm.sh create realms -s realm=platform -s enabled=true +kcadm.sh create clients -r platform -s clientId=auth-server +``` + +**문제:** + +- 상태가 선언적이지 않아 drift 탐지 불가 +- admin credential이 CI runner 환경에 상주 +- 실패 시 재실행 안전성(idempotency) 없음 +- GitOps 원칙과 충돌 + +--- + +## 좋은 예시 7: SecurityContext + Resource + +```yaml +securityContext: + runAsNonRoot: true + runAsUser: 1000 + runAsGroup: 1000 + fsGroup: 1000 + seccompProfile: + type: RuntimeDefault + +containers: + - name: keycloak + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: + - ALL + env: + - name: JAVA_OPTS_APPEND + value: "-XX:MaxRAMPercentage=70 -XX:InitialRAMPercentage=50 -Djgroups.dns.query=keycloak-headless.keycloak.svc.cluster.local" + resources: + requests: + cpu: 500m + memory: 1Gi + limits: + cpu: "2" + memory: 2Gi + volumeMounts: + - name: tmp + mountPath: /tmp + - name: data + mountPath: /opt/keycloak/data + +volumes: + - name: tmp + emptyDir: {} + - name: data + emptyDir: {} +``` + +**왜 좋은가:** + +- Restricted PSS 전부 충족: non-root, no privilege escalation, RO root fs, cap drop ALL +- `MaxRAMPercentage=70`은 JVM이 container limit의 70%까지만 heap 사용 (나머지는 direct memory / metaspace) +- `readOnlyRootFilesystem: true` + emptyDir 마운트로 runtime write path 격리 + +--- + +## 좋은 예시 8: Vault에서 DB credential 주입 (VSO) + +```yaml +--- +apiVersion: secrets.hashicorp.com/v1beta1 +kind: VaultStaticSecret +metadata: + name: keycloak-db + namespace: keycloak +spec: + vaultAuthRef: default + mount: kv + path: keycloak/db + type: kv-v2 + refreshAfter: 1h + destination: + name: keycloak-db-secret + create: true + overwrite: true + transformation: + excludeRaw: true + templates: + username: + text: '{{ .Secrets.username }}' + password: + text: '{{ .Secrets.password }}' +``` + +**왜 좋은가:** + +- Vault KV v2의 `keycloak/db`에서 credential을 K8s Secret으로 동기화 +- 1시간 주기 refresh, VSO가 Pod를 재시작시켜 rotation 적용 가능 (별도 `rolloutRestartTargets` 설정 시) +- Git에 평문 credential이 없다 + +--- + +## 나쁜 예시 6: env에 평문 credential + +```yaml +env: + - name: KC_DB_PASSWORD + value: "SuperSecret123!" + - name: KEYCLOAK_ADMIN_PASSWORD + value: "admin" +``` + +**문제:** + +- Git에 평문 저장 → 권한 있는 모든 인원이 조회 가능 +- 기본 `admin/admin` credential → bootstrap 직후 자동화된 스캐너에 탈취 위험 +- rotation 경로 없음 diff --git a/docs/examples/infra/kustomize.md b/docs/examples/infra/kustomize.md new file mode 100644 index 0000000..4e46c8a --- /dev/null +++ b/docs/examples/infra/kustomize.md @@ -0,0 +1,548 @@ +# Kustomize 예시 + +모든 예시는 Kustomize v5 문법 기준. 렌더 검증: + +```bash +kubectl kustomize | kubectl apply --server-side --field-manager=ci --dry-run=server -f - +``` + +--- + +## 좋은 예시 1: base / components / overlays 전체 구조 + 실제 base `kustomization.yaml` + +```text +k8s/ + base/ + app/units/identity/auth/ + kustomization.yaml + deployment.yaml + service.yaml + servicemonitor.yaml + pdb.yaml + hpa.yaml + components/ + with-topology-spread-zone/ + kustomization.yaml + patch.yaml + with-pdb-tier1/ + kustomization.yaml + patch.yaml + overlays/ + prod/kr-main/ + kustomization.yaml + patches/ + auth-resources.yaml + auth-ingress-host.yaml +``` + +```yaml +# k8s/base/app/units/identity/auth/kustomization.yaml +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - deployment.yaml + - service.yaml + - servicemonitor.yaml + - pdb.yaml + - hpa.yaml +labels: + - pairs: + app.kubernetes.io/name: auth + app.kubernetes.io/component: api + app.kubernetes.io/part-of: identity-platform + includeSelectors: false + includeTemplates: true +``` + +**왜 좋은가:** + +- base가 환경·region을 모른다 (namespace / replicas / host / image tag 전부 없음) +- `labels:` (v5) 사용, `commonLabels` 없음 → selector immutability 안전 +- `includeTemplates: true`로 Pod label에는 전파되어 observability 쿼리 가능 +- selector에 들어가는 label은 base의 Deployment 내부에서 명시적으로 고정 + +--- + +## 좋은 예시 2: base Deployment (완전 apply-ready) + +```yaml +# k8s/base/app/units/identity/auth/deployment.yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: auth + labels: + app.kubernetes.io/name: auth + app.kubernetes.io/instance: auth + app.kubernetes.io/component: api + app.kubernetes.io/part-of: identity-platform + app.kubernetes.io/managed-by: argocd + annotations: + example.com/owner-email: identity-sre@example.com +spec: + replicas: 2 + revisionHistoryLimit: 5 + strategy: + type: RollingUpdate + rollingUpdate: + maxSurge: 25% + maxUnavailable: 0 + selector: + matchLabels: + app.kubernetes.io/name: auth + app.kubernetes.io/instance: auth + app.kubernetes.io/component: api + template: + metadata: + labels: + app.kubernetes.io/name: auth + app.kubernetes.io/instance: auth + app.kubernetes.io/component: api + app.kubernetes.io/part-of: identity-platform + app.kubernetes.io/managed-by: argocd + annotations: + prometheus.io/scrape: "true" + prometheus.io/port: "8081" + prometheus.io/path: "/actuator/prometheus" + spec: + serviceAccountName: auth + automountServiceAccountToken: false + securityContext: + runAsNonRoot: true + runAsUser: 10001 + runAsGroup: 10001 + fsGroup: 10001 + seccompProfile: + type: RuntimeDefault + terminationGracePeriodSeconds: 45 + topologySpreadConstraints: + - maxSkew: 1 + topologyKey: topology.kubernetes.io/zone + whenUnsatisfiable: ScheduleAnyway + labelSelector: + matchLabels: + app.kubernetes.io/name: auth + app.kubernetes.io/instance: auth + app.kubernetes.io/component: api + containers: + - name: auth + image: registry.example.com/auth:placeholder + imagePullPolicy: IfNotPresent + ports: + - name: http + containerPort: 8080 + protocol: TCP + - name: management + containerPort: 8081 + protocol: TCP + env: + - name: JAVA_TOOL_OPTIONS + value: "-XX:MaxRAMPercentage=75 -XX:+UseG1GC" + envFrom: + - configMapRef: + name: auth-config + - secretRef: + name: auth-secrets + resources: + requests: + cpu: 250m + memory: 512Mi + limits: + cpu: "2" + memory: 1Gi + startupProbe: + httpGet: + path: /actuator/health/liveness + port: management + periodSeconds: 5 + failureThreshold: 30 + livenessProbe: + httpGet: + path: /actuator/health/liveness + port: management + periodSeconds: 10 + timeoutSeconds: 3 + failureThreshold: 3 + readinessProbe: + httpGet: + path: /actuator/health/readiness + port: management + periodSeconds: 5 + timeoutSeconds: 2 + failureThreshold: 3 + securityContext: + runAsNonRoot: true + runAsUser: 10001 + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: + - ALL + volumeMounts: + - name: tmp + mountPath: /tmp + - name: cache + mountPath: /app/cache + volumes: + - name: tmp + emptyDir: + sizeLimit: 64Mi + - name: cache + emptyDir: + sizeLimit: 256Mi +``` + +**왜 좋은가:** + +- image는 `:placeholder`, overlay의 `images:`가 digest로 patch → base는 버전 모름 +- `revisionHistoryLimit: 5` → 대규모 cluster에서 ReplicaSet 누적 방지 +- PodSecurity restricted 호환 (non-root, seccomp RuntimeDefault, capabilities drop ALL, readOnlyRootFilesystem) +- startup/liveness/readiness 3종이 타이밍 분리 (startup 150s, liveness 30s, readiness 15s 윈도우) +- topologySpreadConstraints로 zone별 분산 +- `automountServiceAccountToken: false` (ServiceAccount token을 쓰지 않는 워크로드) + +--- + +## 좋은 예시 3: overlay prod/kr-main — 환경 차이만 + +```yaml +# k8s/overlays/prod/kr-main/kustomization.yaml +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +namespace: prod-identity-auth +resources: + - ../../../base/app/units/identity/auth +components: + - ../../../components/with-topology-spread-zone + - ../../../components/with-pdb-tier1 +labels: + - pairs: + example.com/environment: prod + example.com/region: kr-main + example.com/slo-tier: tier-1 + includeSelectors: false + includeTemplates: true +images: + - name: registry.example.com/auth + digest: "sha256:f1a2b3c4d5e6f7081920aabbccddeeff00112233445566778899aabbccddeeff" +replicas: + - name: auth + count: 6 +patches: + - target: + kind: Deployment + name: auth + path: patches/auth-resources.yaml + - target: + kind: Ingress + name: auth-public + patch: |- + - op: replace + path: /spec/rules/0/host + value: auth.example.com + - op: replace + path: /spec/tls/0/hosts/0 + value: auth.example.com +``` + +```yaml +# k8s/overlays/prod/kr-main/patches/auth-resources.yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: auth + labels: + app.kubernetes.io/name: auth + app.kubernetes.io/instance: auth +spec: + selector: + matchLabels: + app.kubernetes.io/name: auth + app.kubernetes.io/instance: auth + template: + metadata: + labels: + app.kubernetes.io/name: auth + app.kubernetes.io/instance: auth + spec: + securityContext: + runAsNonRoot: true + runAsUser: 10001 + runAsGroup: 10001 + fsGroup: 10001 + seccompProfile: + type: RuntimeDefault + containers: + - name: auth + image: registry.example.com/auth-server:1.24.0 + resources: + requests: + cpu: 500m + memory: 1Gi + limits: + cpu: "2" + memory: 2Gi + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: ["ALL"] +``` + +**왜 좋은가:** + +- overlay 자체가 짧음 (base를 재작성하지 않음) +- digest 기반 image pinning +- `components:`로 zone spread + PDB tier-1을 재사용 +- `labels:` 사용, `includeSelectors: false` → selector immutability 안전 +- replicas override는 HPA minReplicas와 일치 (HPA base에서 `minReplicas: 6`으로 설정되어 있다고 가정) + +--- + +## 좋은 예시 4: Kustomize Component — `with-pdb-tier1` + +```yaml +# k8s/components/with-pdb-tier1/kustomization.yaml +apiVersion: kustomize.config.k8s.io/v1alpha1 +kind: Component +resources: + - pdb.yaml +``` + +```yaml +# k8s/components/with-pdb-tier1/pdb.yaml +apiVersion: policy/v1 +kind: PodDisruptionBudget +metadata: + name: auth + labels: + app.kubernetes.io/name: auth + app.kubernetes.io/instance: auth + app.kubernetes.io/component: api + app.kubernetes.io/part-of: identity-platform + app.kubernetes.io/managed-by: argocd + example.com/slo-tier: tier-1 +spec: + minAvailable: 50% + unhealthyPodEvictionPolicy: AlwaysAllow + selector: + matchLabels: + app.kubernetes.io/name: auth + app.kubernetes.io/instance: auth + app.kubernetes.io/component: api +``` + +**왜 좋은가:** + +- `kind: Component`로 선언 → 여러 overlay에서 `components:` 키로 재사용 +- tier-1의 PDB 정책(50% minAvailable)이 단일 파일에 고정 +- 다른 tier는 별도 component (`with-pdb-tier2`, `with-pdb-tier3`) + +--- + +## 좋은 예시 5: ConfigMap generator + hash suffix를 활용한 자동 rollout + +```yaml +# k8s/base/app/units/identity/auth/kustomization.yaml (with generator) +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - deployment.yaml + - service.yaml +configMapGenerator: + - name: auth-config + files: + - application.yaml=config/application.yaml + - logback.xml=config/logback.xml + options: + labels: + app.kubernetes.io/name: auth + app.kubernetes.io/component: config +generatorOptions: + disableNameSuffixHash: false +``` + +**왜 좋은가:** + +- ConfigMap 내용 변경 시 hash suffix가 바뀜 → Deployment가 새 이름을 참조 → rolling update 자동 트리거 +- annotation 기반 "checksum" hack 불필요 +- Secret은 generator로 만들지 않고 External Secrets로 관리 + +--- + +## 좋은 예시 6: HPA v2 + behavior (base 리소스) + +```yaml +# k8s/base/app/units/identity/auth/hpa.yaml +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: auth + labels: + app.kubernetes.io/name: auth + app.kubernetes.io/instance: auth + app.kubernetes.io/component: api + app.kubernetes.io/part-of: identity-platform + app.kubernetes.io/managed-by: argocd +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: auth + minReplicas: 2 + maxReplicas: 20 + metrics: + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: 70 + - type: Resource + resource: + name: memory + target: + type: Utilization + averageUtilization: 80 + behavior: + scaleDown: + stabilizationWindowSeconds: 300 + policies: + - type: Percent + value: 25 + periodSeconds: 60 + scaleUp: + stabilizationWindowSeconds: 0 + policies: + - type: Percent + value: 100 + periodSeconds: 30 + - type: Pods + value: 4 + periodSeconds: 30 + selectPolicy: Max +``` + +**왜 좋은가:** + +- HPA v2 behavior로 scaleDown stabilization (5분) vs scaleUp aggressive (즉시) 분리 +- overlay는 `minReplicas` / `maxReplicas`만 override하고 behavior는 상속 + +--- + +## 나쁜 예시 1: `commonLabels`로 environment 주입 → selector immutable 에러 + +```yaml +# k8s/overlays/prod/kustomization.yaml (BAD) +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +namespace: prod-identity-auth +resources: + - ../../base/app/units/identity/auth +commonLabels: + example.com/environment: prod +``` + +**문제:** `commonLabels`는 `spec.selector.matchLabels`에 자동 주입된다. 이미 live 상태인 Deployment/StatefulSet에 apply하면 `The Deployment "auth" is invalid: spec.selector: Invalid value: ...: field is immutable` 로 차단. 해결: `labels:` + `includeSelectors: false`로 교체. + +--- + +## 나쁜 예시 2: overlay가 base를 거의 재작성 + +```text +k8s/base/app/units/identity/auth/deployment.yaml (150 lines) +k8s/overlays/prod/deployment.yaml (140 lines, 95% identical) +k8s/overlays/staging/deployment.yaml (140 lines) +k8s/overlays/dev/deployment.yaml (135 lines) +``` + +**문제:** overlay가 base의 95%를 복붙 + 몇 줄 수정. drift 발생 시점부터 base가 의미 없어진다. 해결: overlay는 `patches:` + `images:` + `replicas:` + `labels:`만 쓰고 전체 리소스는 base에서 가져온다. + +--- + +## 나쁜 예시 3: 운영 secret을 `secretGenerator`로 plaintext Git 커밋 + +```yaml +# k8s/overlays/prod/kustomization.yaml (BAD) +secretGenerator: + - name: auth-secrets + literals: + - OAUTH_CLIENT_SECRET=s3cr3t-prod-value + - DB_PASSWORD=prod-db-password +``` + +**문제:** plaintext secret이 Git에 박힌다. 해결: External Secrets Operator + Vault / AWS Secrets Manager / Bitwarden Secrets. 또는 SealedSecrets (public key encrypted). + +--- + +## 나쁜 예시 4: `patchesStrategicMerge` / `patchesJson6902` (deprecated) + +```yaml +# k8s/overlays/prod/kustomization.yaml (BAD, v5 deprecated) +patchesStrategicMerge: + - patches/auth-resources.yaml +patchesJson6902: + - target: + group: apps + version: v1 + kind: Deployment + name: auth + path: patches/auth-env.yaml +``` + +**문제:** 두 필드는 Kustomize v5에서 deprecated (여전히 동작하지만 신규 사용 금지). 하나의 `patches:` 필드로 통합되어 strategic merge + JSON patch 양쪽을 지원하므로 혼재할 이유 없음. 해결: `patches:` 단일 키 사용. + +--- + +## 나쁜 예시 5: base에 환경 host / domain 고정 + +```yaml +# k8s/base/app/units/identity/auth/ingress.yaml (BAD) +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: auth-public +spec: + rules: + - host: auth.example.com # prod host hardcoded in base + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: auth + port: + number: 8080 +``` + +**문제:** base가 prod를 전제한다. dev/staging overlay가 host를 교체하려고 `patches:`를 추가해야 하고, base는 더 이상 환경 중립이 아니다. 해결: base에서는 host를 placeholder (`auth.placeholder.invalid`)로 두고 overlay `patches:`에서 주입. + +--- + +## 나쁜 예시 6: `bases:` 사용 (v2.1에서 `resources:`로 통합됨) + +```yaml +# k8s/overlays/prod/kustomization.yaml (BAD) +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +bases: + - ../../base/app/units/identity/auth +``` + +**문제:** `bases:`는 v2.1에서 `resources:`에 흡수됨. 신규 코드에서 사용 금지. 해결: `resources:` 사용. + +--- + +## 나쁜 예시 7: HPA가 있는 Deployment에 overlay `replicas:`로 고정값 주입 + +```yaml +# k8s/overlays/prod/kustomization.yaml (BAD — conflicts with HPA) +replicas: + - name: auth + count: 3 +``` + +(한편 HPA는 `minReplicas: 6 / maxReplicas: 20`) + +**문제:** Kustomize가 `replicas: 3`으로 apply → HPA가 즉시 6으로 끌어올림 → 매 ArgoCD sync마다 `out-of-sync` flap. 해결: HPA 활성 리소스에서는 overlay `replicas:`를 쓰지 않고, HPA `minReplicas`를 환경별로 patch. diff --git a/docs/examples/infra/minio.md b/docs/examples/infra/minio.md new file mode 100644 index 0000000..b0af153 --- /dev/null +++ b/docs/examples/infra/minio.md @@ -0,0 +1,855 @@ +# MinIO 예시 + +MinIO Operator + Tenant CRD (`minio.min.io/v2`) + KES + Vault transit 기준. 모든 manifest는 `kubectl apply` 적용 가능한 완전한 형태다. + +--- + +## 좋은 예시 1: Namespace + Tenant configuration Secret + +```yaml +--- +apiVersion: v1 +kind: Namespace +metadata: + name: minio-prod + labels: + pod-security.kubernetes.io/enforce: restricted + pod-security.kubernetes.io/audit: restricted + app.kubernetes.io/part-of: storage-platform +--- +apiVersion: v1 +kind: Secret +metadata: + name: minio-tenant-env + namespace: minio-prod +type: Opaque +stringData: + config.env: | + export MINIO_ROOT_USER="REPLACE_VIA_VSO" + export MINIO_ROOT_PASSWORD="REPLACE_VIA_VSO" + export MINIO_STORAGE_CLASS_STANDARD="EC:4" + export MINIO_STORAGE_CLASS_RRS="EC:2" + export MINIO_BROWSER_REDIRECT_URL="https://minio-console.internal.example.com" + export MINIO_SERVER_URL="https://s3.example.com" + export MINIO_IDENTITY_OPENID_CONFIG_URL="https://auth.example.com/realms/platform/.well-known/openid-configuration" + export MINIO_IDENTITY_OPENID_CLIENT_ID="minio" + export MINIO_IDENTITY_OPENID_CLAIM_NAME="policy" + export MINIO_IDENTITY_OPENID_SCOPES="openid,profile,email" + export MINIO_PROMETHEUS_AUTH_TYPE="jwt" +--- +# 실 운영에서는 VSO가 이 Secret을 채움 +apiVersion: secrets.hashicorp.com/v1beta1 +kind: VaultStaticSecret +metadata: + name: minio-root-creds + namespace: minio-prod +spec: + vaultAuthRef: default + mount: kv + path: minio/prod/root + type: kv-v2 + refreshAfter: 24h + destination: + name: minio-tenant-env + create: false + overwrite: true + transformation: + excludeRaw: true + templates: + config.env: + text: | + export MINIO_ROOT_USER="{{ .Secrets.username }}" + export MINIO_ROOT_PASSWORD="{{ .Secrets.password }}" + export MINIO_STORAGE_CLASS_STANDARD="EC:4" + export MINIO_BROWSER_REDIRECT_URL="https://minio-console.internal.example.com" + export MINIO_SERVER_URL="https://s3.example.com" + export MINIO_IDENTITY_OPENID_CONFIG_URL="https://auth.example.com/realms/platform/.well-known/openid-configuration" + export MINIO_IDENTITY_OPENID_CLIENT_ID="minio" + export MINIO_IDENTITY_OPENID_CLIENT_SECRET="{{ .Secrets.oidc_client_secret }}" + export MINIO_IDENTITY_OPENID_CLAIM_NAME="policy" + export MINIO_IDENTITY_OPENID_SCOPES="openid,profile,email" + export MINIO_PROMETHEUS_AUTH_TYPE="jwt" +``` + +**왜 좋은가:** + +- Tenant configuration은 **shell-source 형식**(`export KEY=VALUE`) Secret으로 전달 (Operator 규약) +- Root credential을 Vault KV에서 VSO가 주입 — Git에 평문 없음 +- OIDC 통합 (Keycloak), storage class EC:4, Prometheus JWT auth 한 파일에 고정 +- `MINIO_SERVER_URL`로 외부 S3 endpoint 명시 (presigned URL 생성 시 사용) + +--- + +## 좋은 예시 2: Tenant CR — 4 server × 4 volume + KES + TLS + +```yaml +apiVersion: minio.min.io/v2 +kind: Tenant +metadata: + name: minio + namespace: minio-prod + labels: + app.kubernetes.io/name: minio + app.kubernetes.io/instance: minio-prod + app.kubernetes.io/part-of: storage-platform + app.kubernetes.io/managed-by: minio-operator + annotations: + prometheus.io/path: /minio/v2/metrics/cluster + prometheus.io/port: "9000" + prometheus.io/scrape: "true" +spec: + image: quay.io/minio/minio:RELEASE.2025-01-20T14-49-07Z + imagePullPolicy: IfNotPresent + mountPath: /export + + configuration: + name: minio-tenant-env + + requestAutoCert: true + certConfig: + commonName: minio.minio-prod.svc.cluster.local + organizationName: + - example.com + dnsNames: + - minio.minio-prod.svc.cluster.local + - "*.minio-hl.minio-prod.svc.cluster.local" + - s3.example.com + + pools: + - name: pool-0 + servers: 4 + volumesPerServer: 4 + volumeClaimTemplate: + metadata: + name: data + spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 500Gi + storageClassName: local-xfs-retain + resources: + requests: + cpu: 500m + memory: 2Gi + limits: + cpu: "4" + memory: 8Gi + securityContext: + runAsUser: 1000 + runAsGroup: 1000 + runAsNonRoot: true + fsGroup: 1000 + fsGroupChangePolicy: OnRootMismatch + seccompProfile: + type: RuntimeDefault + containerSecurityContext: + runAsUser: 1000 + runAsGroup: 1000 + runAsNonRoot: true + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: + - ALL + affinity: + podAntiAffinity: + requiredDuringSchedulingIgnoredDuringExecution: + - labelSelector: + matchLabels: + v1.min.io/tenant: minio + v1.min.io/pool: pool-0 + topologyKey: kubernetes.io/hostname + topologySpreadConstraints: + - maxSkew: 1 + topologyKey: topology.kubernetes.io/zone + whenUnsatisfiable: ScheduleAnyway + labelSelector: + matchLabels: + v1.min.io/tenant: minio + tolerations: + - key: storage + operator: Equal + value: dedicated + effect: NoSchedule + + features: + bucketDNS: false + domains: + console: https://minio-console.internal.example.com + minio: + - https://s3.example.com + + kes: + image: quay.io/minio/kes:2025-01-16T16-24-39Z + replicas: 2 + kesSecret: + name: kes-configuration + imagePullPolicy: IfNotPresent + resources: + requests: + cpu: 100m + memory: 128Mi + limits: + cpu: 500m + memory: 512Mi + securityContext: + runAsUser: 1000 + runAsGroup: 1000 + runAsNonRoot: true + fsGroup: 1000 + containerSecurityContext: + runAsNonRoot: true + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: + - ALL + + prometheusOperator: true + + podManagementPolicy: Parallel + + exposeServices: + minio: true + console: false + + logging: + anonymous: false + json: true + quiet: false +--- +apiVersion: policy/v1 +kind: PodDisruptionBudget +metadata: + name: minio + namespace: minio-prod +spec: + minAvailable: 3 + unhealthyPodEvictionPolicy: AlwaysAllow + selector: + matchLabels: + v1.min.io/tenant: minio +``` + +**왜 좋은가:** + +- `servers × volumesPerServer = 4 × 4 = 16` drive → erasure coding 최소 요건 충족, `EC:4` 기본 parity (4 drive 장애 허용) +- `requestAutoCert: true` + `certConfig.dnsNames`로 Operator가 API/Console TLS 자동 발급 +- `podAntiAffinity` hostname required → 한 node에 MinIO pod 복수 배치 금지 (EC 의미 보존) +- KES가 별도 2 replica로 사이드카 없이 Deployment로 분리 (Tenant CR에서 관리됨) +- `exposeServices.console: false` → Console은 Tenant Service에서 Ingress로 별도 처리만 허용 +- `minAvailable: 3` → 4 server 중 1 동시 drain까지 허용 (write quorum 보존) + +--- + +## 나쁜 예시 1: 단일 Deployment로 MinIO + +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: minio +spec: + replicas: 1 + template: + spec: + containers: + - name: minio + image: minio/minio + args: ["server", "/data"] + env: + - name: MINIO_ROOT_USER + value: minioadmin + - name: MINIO_ROOT_PASSWORD + value: minioadmin + volumeMounts: + - name: data + mountPath: /data + volumes: + - name: data + emptyDir: {} +``` + +**문제:** + +- Single-drive MinIO → erasure coding 없음, 1 drive 장애 = 전체 data loss +- Deployment = 재시작 시 PVC binding 보장 없음, 복수 replica 시 동일 volume 충돌 +- emptyDir → pod 재시작 시 모든 object 사라짐 +- 기본 `minioadmin/minioadmin` credential → 공개 인터넷 스캐너가 수 분 내 탈취 +- Operator + Tenant가 자동화하는 인증서, 서비스, headless, auto-restart를 전부 수제로 다시 만들어야 함 + +--- + +## 좋은 예시 3: KES configuration + Vault transit + +```yaml +--- +apiVersion: v1 +kind: Secret +metadata: + name: kes-configuration + namespace: minio-prod +type: Opaque +stringData: + server-config.yaml: | + version: v1 + address: 0.0.0.0:7373 + + admin: + identity: disabled + + tls: + key: /tmp/kes/server.key + cert: /tmp/kes/server.cert + + policy: + minio-app: + allow: + - /v1/key/create/minio-* + - /v1/key/generate/minio-* + - /v1/key/decrypt/minio-* + - /v1/key/bulk/decrypt/minio-* + - /v1/key/list/minio-* + - /v1/status + - /v1/metrics + - /v1/api + identities: + - ${MINIO_KES_IDENTITY} + + keystore: + vault: + endpoint: https://vault.vault.svc.cluster.local:8200 + engine: transit + version: v1 + namespace: "" + prefix: minio + approle: + id: ${VAULT_APPROLE_ID} + secret: ${VAULT_APPROLE_SECRET} + retry: 15s + tls: + ca: /tmp/kes/vault-ca.crt + status: + ping: 10s +--- +# Vault AppRole credential은 VSO 또는 별도 Secret으로 주입 +apiVersion: secrets.hashicorp.com/v1beta1 +kind: VaultStaticSecret +metadata: + name: kes-vault-approle + namespace: minio-prod +spec: + vaultAuthRef: default + mount: kv + path: minio/kes/approle + type: kv-v2 + refreshAfter: 24h + destination: + name: kes-vault-approle + create: true + overwrite: true +``` + +그리고 bucket에 SSE-KMS 적용: + +```bash +mc alias set minio https://s3.example.com $ROOT_USER $ROOT_PASS + +# Vault transit에 key 생성 +mc admin kms key create minio minio-critical + +# bucket에 SSE-KMS 기본 적용 +mc encrypt set sse-kms minio-critical minio/critical-bucket +``` + +**왜 좋은가:** + +- KES가 Vault transit을 key store로 사용 → master key는 Vault가 관리, MinIO는 DEK만 캐시 +- KES policy로 `minio-*` prefix key만 access 허용 (최소 권한) +- AppRole credential은 Vault → VSO → Secret 경로 +- bucket level SSE-KMS → 업로드되는 모든 object가 per-object DEK로 자동 암호화 + +--- + +## 나쁜 예시 2: KES 없이 평문 저장 + +```yaml +# Tenant CR +spec: + kes: {} # 미설정 + # ... SSE 설정 없음 +``` + +```bash +mc cp secret.pdf minio/bucket/secret.pdf +# object가 disk에 평문 저장 +``` + +**문제:** + +- PVC가 탈취되거나 물리 drive가 반출되면 평문 유출 +- 감사/규제 요구(GDPR, PCI-DSS, ISO 27001) 위반 +- SSE-S3를 대신 쓰더라도 master key가 MinIO 자체에 있어 키 라이프사이클 관리 불가 + +--- + +## 좋은 예시 4: Probe — live + cluster-read + +Tenant CR이 자동으로 probe를 구성하지만, 커스텀 오버라이드가 필요할 때: + +```yaml +spec: + pools: + - name: pool-0 + # ... + containers: + - name: minio + livenessProbe: + httpGet: + path: /minio/health/live + port: 9000 + scheme: HTTPS + initialDelaySeconds: 60 + periodSeconds: 30 + timeoutSeconds: 5 + failureThreshold: 3 + readinessProbe: + httpGet: + path: /minio/health/cluster/read + port: 9000 + scheme: HTTPS + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 3 + startupProbe: + httpGet: + path: /minio/health/live + port: 9000 + scheme: HTTPS + periodSeconds: 5 + failureThreshold: 60 + timeoutSeconds: 5 +``` + +**왜 좋은가:** + +- `readinessProbe`는 `/minio/health/cluster/read` → **read quorum** 검사. rolling update 중에도 read가 가능하면 Service에 남아있음 +- `/minio/health/cluster` (write quorum)을 readiness로 쓰면 rolling 재시작 시 pod가 전부 빠져 완전 unavailable +- `livenessProbe`는 단순 프로세스 생존만 확인 → 일시적 quorum 상실로 pod 강제 재시작 방지 +- HTTPS scheme (requestAutoCert과 일치) + +--- + +## 나쁜 예시 3: readiness를 write quorum으로 + +```yaml +readinessProbe: + httpGet: + path: /minio/health/cluster + port: 9000 + periodSeconds: 5 + failureThreshold: 1 +``` + +**문제:** + +- rolling update로 pod 1개를 재시작하면 write quorum이 일시적으로 무너져 살아있는 pod들도 NotReady +- Service가 endpoint를 전부 제거 → **읽기도 불가능** +- `failureThreshold: 1` + 5초 주기 → 한 번 느린 응답으로 pod 빠짐 + +--- + +## 좋은 예시 5: Ingress — API는 공개, Console은 내부 + +```yaml +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: minio-api + namespace: minio-prod + annotations: + cert-manager.io/cluster-issuer: letsencrypt-prod + nginx.ingress.kubernetes.io/proxy-body-size: "0" + nginx.ingress.kubernetes.io/proxy-request-buffering: "off" + nginx.ingress.kubernetes.io/proxy-buffering: "off" + nginx.ingress.kubernetes.io/backend-protocol: HTTPS +spec: + ingressClassName: nginx + tls: + - hosts: + - s3.example.com + secretName: minio-api-ingress-tls + rules: + - host: s3.example.com + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: minio + port: + number: 443 +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: minio-console + namespace: minio-prod + annotations: + cert-manager.io/cluster-issuer: letsencrypt-prod + nginx.ingress.kubernetes.io/whitelist-source-range: "10.0.0.0/8,192.168.0.0/16" + nginx.ingress.kubernetes.io/auth-url: "https://oauth2-proxy.example.com/oauth2/auth" + nginx.ingress.kubernetes.io/backend-protocol: HTTPS +spec: + ingressClassName: nginx-internal + tls: + - hosts: + - minio-console.internal.example.com + secretName: minio-console-ingress-tls + rules: + - host: minio-console.internal.example.com + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: minio-console + port: + number: 9443 +``` + +**왜 좋은가:** + +- API Ingress는 `proxy-body-size: 0` + request/response buffering off → 대용량 multipart upload 지원 +- Console은 내부 ingress class + IP whitelist + OIDC forward-auth 2중 보호 +- `backend-protocol: HTTPS` → MinIO의 auto-cert TLS를 TLS passthrough 형태로 전달 (인증서 SAN 보존) + +--- + +## 나쁜 예시 4: Console 외부 공개 + +```yaml +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: minio-all +spec: + rules: + - host: minio.example.com + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: minio + port: + number: 9090 +``` + +**문제:** + +- Console이 인터넷에 그대로 노출 → root/admin credential brute force 표면 확장 +- OIDC forward-auth 없음 → 기본 login 페이지가 공격자에게 노출 +- IP 제한 없음 +- Bucket 목록, access key 관리, 사용자 관리가 모두 공개 domain에 위치 + +--- + +## 좋은 예시 6: ServiceMonitor (Prometheus bearer-token) + +먼저 MinIO 내부에서 scrape token 발급: + +```bash +mc admin prometheus generate minio cluster +# 출력에 bearer token과 scrape config가 나옴 +``` + +그 결과 token을 Secret로 저장: + +```yaml +--- +apiVersion: v1 +kind: Secret +metadata: + name: minio-prometheus-token + namespace: minio-prod +type: Opaque +stringData: + token: "eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9..." +--- +apiVersion: monitoring.coreos.com/v1 +kind: ServiceMonitor +metadata: + name: minio + namespace: minio-prod + labels: + app.kubernetes.io/name: minio + release: kube-prometheus-stack +spec: + selector: + matchLabels: + v1.min.io/tenant: minio + endpoints: + - port: https-minio + scheme: https + path: /minio/v2/metrics/cluster + interval: 30s + scrapeTimeout: 10s + bearerTokenSecret: + name: minio-prometheus-token + key: token + tlsConfig: + insecureSkipVerify: false + ca: + secret: + name: minio-tls + key: ca.crt + serverName: minio.minio-prod.svc.cluster.local +``` + +**왜 좋은가:** + +- `MINIO_PROMETHEUS_AUTH_TYPE=jwt` 와 매칭 (기본값) +- `/minio/v2/metrics/cluster`는 cluster-wide view (replication lag, bucket 사용량, API latency) +- TLS 검증 유지 (`insecureSkipVerify: false`, CA bundle 제공) +- 외부 노출 없이 내부 scrape만 + +--- + +## 좋은 예시 7: Bucket 초기화 (Job) — versioning + Object Lock + lifecycle + +```yaml +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: minio-bootstrap + namespace: minio-prod +data: + init.sh: | + #!/bin/sh + set -eu + + mc alias set minio https://minio.minio-prod.svc.cluster.local "$MINIO_ROOT_USER" "$MINIO_ROOT_PASSWORD" --api S3v4 + + # Object Lock은 bucket 생성 시점에만 활성화 가능 + mc mb --with-lock minio/critical-audit || true + mc retention set --default COMPLIANCE 2555d minio/critical-audit # 7년 보관 + + # Versioning + lifecycle + mc mb minio/app-data || true + mc version enable minio/app-data + mc ilm add --expire-noncurrent-days 90 minio/app-data + mc ilm add --expire-incomplete-upload-days 7 minio/app-data + + # SSE-KMS 기본 적용 + mc encrypt set sse-kms minio-app-key minio/app-data + mc encrypt set sse-kms minio-critical-key minio/critical-audit + + # Service account 발급 (앱 전용, 최소 권한 policy) + mc admin policy create minio auth-server-rw /policies/auth-server-rw.json + mc admin user svcacct add minio "$MINIO_ROOT_USER" \ + --access-key "$AUTH_SERVER_ACCESS_KEY" \ + --secret-key "$AUTH_SERVER_SECRET_KEY" \ + --policy /policies/auth-server-rw.json || true + + echo "bootstrap complete" + + auth-server-rw.json: | + { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": ["s3:PutObject", "s3:GetObject", "s3:DeleteObject", "s3:ListBucket"], + "Resource": ["arn:aws:s3:::app-data/*", "arn:aws:s3:::app-data"] + } + ] + } +--- +apiVersion: batch/v1 +kind: Job +metadata: + name: minio-bootstrap + namespace: minio-prod +spec: + backoffLimit: 3 + ttlSecondsAfterFinished: 86400 + template: + spec: + restartPolicy: OnFailure + serviceAccountName: minio-bootstrap + containers: + - name: mc + image: quay.io/minio/mc:RELEASE.2025-01-17T23-25-50Z + command: ["/bin/sh", "/scripts/init.sh"] + resources: + requests: { cpu: 100m, memory: 128Mi } + limits: { memory: 256Mi } + env: + - name: MINIO_ROOT_USER + valueFrom: + secretKeyRef: + name: minio-root-creds + key: username + - name: MINIO_ROOT_PASSWORD + valueFrom: + secretKeyRef: + name: minio-root-creds + key: password + - name: AUTH_SERVER_ACCESS_KEY + valueFrom: + secretKeyRef: + name: auth-server-minio-svcacct + key: access_key + - name: AUTH_SERVER_SECRET_KEY + valueFrom: + secretKeyRef: + name: auth-server-minio-svcacct + key: secret_key + volumeMounts: + - name: scripts + mountPath: /scripts + - name: policies + mountPath: /policies + securityContext: + runAsNonRoot: true + runAsUser: 1000 + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: + - ALL + volumes: + - name: scripts + configMap: + name: minio-bootstrap + defaultMode: 0755 + items: + - key: init.sh + path: init.sh + - name: policies + configMap: + name: minio-bootstrap + items: + - key: auth-server-rw.json + path: auth-server-rw.json +``` + +**왜 좋은가:** + +- `mc mb --with-lock`은 bucket 생성 시점에만 Object Lock 활성화 가능 — Job이 그 타이밍을 보장 +- COMPLIANCE 모드 7년 retention = 감사/규제 요구 충족 (root도 bypass 불가) +- `app-data` bucket은 versioning + lifecycle (90일 noncurrent expire + 7일 incomplete abort) +- service account는 특정 bucket prefix만 접근 가능한 policy로 제한 +- `backoffLimit: 3` + idempotent 명령 (`|| true`) → 재실행 안전 + +--- + +## 나쁜 예시 5: mc mirror만으로 DR + +```bash +# 매일 자정 crontab +mc mirror minio/critical remote-minio/critical-backup +``` + +**문제:** + +- `mc mirror`는 **현재 object만** 동기화 — 버전 히스토리 유실 +- Object Lock 상태, bucket policy, IAM 설정 미복제 +- 메타데이터 중 일부(tag, legal hold) 누락 +- RPO = 1일 (하루 단위 손실), replication은 async ms 단위 RPO +- DR 연습(resync 절차) 불가 + +대안: `mc admin replicate add` (site replication, IAM + bucket + object 전부 async 동기화). + +--- + +## 좋은 예시 8: Bucket replication + +```bash +# source alias 설정 +mc alias set source https://minio.minio-prod.svc.cluster.local $SRC_USER $SRC_PASS +mc alias set target https://minio.minio-dr.svc.cluster.local $TGT_USER $TGT_PASS + +# target에 replication 전용 user + policy +mc admin policy create target replication-target /policies/replication.json +mc admin user add target replication-bot $(openssl rand -hex 16) +mc admin policy attach target replication-target --user replication-bot + +# source에서 remote target 등록 +mc replicate add source/app-data \ + --remote-bucket https://replication-bot:PASS@minio.minio-dr.svc.cluster.local/app-data \ + --replicate "delete,delete-marker,existing-objects,metadata-sync" \ + --priority 1 +``` + +`replication.json`: + +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "s3:GetBucketVersioning", + "s3:PutBucketVersioning", + "s3:GetReplicationConfiguration", + "s3:ReplicateObject", + "s3:ReplicateDelete", + "s3:ReplicateTags", + "s3:GetObjectVersion", + "s3:GetObjectVersionTagging", + "s3:GetObjectVersionForReplication" + ], + "Resource": ["arn:aws:s3:::app-data/*", "arn:aws:s3:::app-data"] + } + ] +} +``` + +**왜 좋은가:** + +- `existing-objects` 옵션으로 기존 데이터 backfill +- `delete` + `delete-marker`로 삭제도 복제 (true mirror) +- 전용 replication user + 최소 권한 policy +- async 복제, bucket versioning 전제 + +--- + +## 좋은 예시 9: Keycloak OIDC STS 로그인 + +Keycloak에 `minio` client 생성 후: + +```bash +# 앱에서 JWT를 받은 다음 +curl -X POST https://s3.example.com/ \ + -d "Action=AssumeRoleWithWebIdentity" \ + -d "Version=2011-06-15" \ + -d "WebIdentityToken=${KEYCLOAK_ID_TOKEN}" \ + -d "DurationSeconds=3600" +``` + +응답의 `AccessKeyId`, `SecretAccessKey`, `SessionToken`을 S3 SDK에 주입. + +```bash +# AWS CLI 예시 +aws configure set aws_access_key_id "$STS_ACCESS_KEY" +aws configure set aws_secret_access_key "$STS_SECRET_KEY" +aws configure set aws_session_token "$STS_SESSION_TOKEN" +aws s3 ls s3://app-data/ --endpoint-url https://s3.example.com +``` + +**왜 좋은가:** + +- 앱/사용자는 Keycloak에 로그인만 하면 됨 — MinIO에 user 등록 불필요 +- 임시 credential (1시간 TTL) → 유출 시 피해 제한 +- JWT의 `policy` claim이 MinIO 정책과 자동 매핑 +- 장기 access key 배포 없음 diff --git a/docs/examples/infra/network-ingress-tls.md b/docs/examples/infra/network-ingress-tls.md new file mode 100644 index 0000000..9916862 --- /dev/null +++ b/docs/examples/infra/network-ingress-tls.md @@ -0,0 +1,578 @@ +# network / ingress / TLS 예시 + +모든 YAML은 `kubectl apply --dry-run=server -f -` 기준 clean. Traefik은 `ingress-traefik` namespace에 IngressClass `traefik`으로 설치되어 있다고 가정한다. cert-manager는 `cert-manager` namespace에 설치되어 있다. + +--- + +## 좋은 예시 1: cert-manager ClusterIssuer (staging + prod) + DNS-01 wildcard + +```yaml +apiVersion: cert-manager.io/v1 +kind: ClusterIssuer +metadata: + name: letsencrypt-staging +spec: + acme: + server: https://acme-staging-v02.api.letsencrypt.org/directory + email: platform@example.com + privateKeySecretRef: + name: letsencrypt-staging-account-key + solvers: + - http01: + ingress: + ingressClassName: traefik +--- +apiVersion: cert-manager.io/v1 +kind: ClusterIssuer +metadata: + name: letsencrypt-prod +spec: + acme: + server: https://acme-v02.api.letsencrypt.org/directory + email: platform@example.com + privateKeySecretRef: + name: letsencrypt-prod-account-key + solvers: + - http01: + ingress: + ingressClassName: traefik + selector: + dnsZones: + - example.com +--- +apiVersion: cert-manager.io/v1 +kind: ClusterIssuer +metadata: + name: letsencrypt-prod-dns +spec: + acme: + server: https://acme-v02.api.letsencrypt.org/directory + email: platform@example.com + privateKeySecretRef: + name: letsencrypt-prod-dns-account-key + solvers: + - dns01: + route53: + region: us-east-1 + hostedZoneID: Z2FDTNDATAQYW2 + selector: + dnsZones: + - example.com +``` + +**왜 좋은가:** + +- Staging issuer로 먼저 발급 테스트(LE rate limit 절약). 검증 후 prod로 교체. +- HTTP-01 solver는 `ingressClassName: traefik`으로 challenge Ingress가 정확히 Traefik만 수락. +- DNS-01 solver는 wildcard(`*.example.com`) 발급에 필수. Route53 hosted zone ID 고정. + +--- + +## 좋은 예시 2: Certificate CRD로 TLS Secret 자동 생성 + Ingress 재사용 + +```yaml +apiVersion: cert-manager.io/v1 +kind: Certificate +metadata: + name: auth-example-com + namespace: auth-prod +spec: + secretName: auth-example-com-tls + secretTemplate: + annotations: + reflector.v1.k8s.emberstack.com/reflection-allowed: "false" + labels: + app.kubernetes.io/part-of: identity-platform + duration: 2160h # 90d + renewBefore: 360h # 15d + privateKey: + algorithm: ECDSA + size: 256 + rotationPolicy: Always + usages: + - server auth + - digital signature + - key encipherment + dnsNames: + - auth.example.com + issuerRef: + kind: ClusterIssuer + name: letsencrypt-prod +``` + +**왜 좋은가:** + +- cert-manager가 `auth-example-com-tls`라는 `kubernetes.io/tls` Secret을 자동 생성·회전(15일 전). +- ECDSA P-256 + 회전 정책으로 key lifecycle 관리. +- `usages` 명시로 SAN certificate의 Extended Key Usage 제어. + +--- + +## 좋은 예시 3: Traefik Middleware(HSTS + HTTPS redirect) + TLSOption + +```yaml +apiVersion: traefik.io/v1alpha1 +kind: Middleware +metadata: + name: https-redirect + namespace: ingress-traefik +spec: + redirectScheme: + scheme: https + permanent: true +--- +apiVersion: traefik.io/v1alpha1 +kind: Middleware +metadata: + name: security-headers + namespace: ingress-traefik +spec: + headers: + stsSeconds: 31536000 + stsIncludeSubdomains: true + stsPreload: true + forceSTSHeader: true + contentTypeNosniff: true + browserXssFilter: true + referrerPolicy: strict-origin-when-cross-origin + frameDeny: true +--- +apiVersion: traefik.io/v1alpha1 +kind: TLSOption +metadata: + name: modern-tls + namespace: ingress-traefik +spec: + minVersion: VersionTLS12 + sniStrict: true + cipherSuites: + - TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256 + - TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256 + - TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384 + - TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384 + - TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305 + - TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305 + curvePreferences: + - CurveP521 + - CurveP384 +``` + +**왜 좋은가:** + +- HSTS preload 조건(`max-age>=31536000` + `includeSubDomains` + `preload`)을 모두 만족. +- TLS 1.2+ 강제, 취약 cipher 제거. `sniStrict: true`로 SNI 없는 클라이언트 차단. +- 재사용 가능한 platform middleware — 각 namespace Ingress가 참조. + +--- + +## 좋은 예시 4: auth-server Service + Ingress (TLS, HSTS, HTTPS redirect) + +```yaml +apiVersion: v1 +kind: Service +metadata: + name: auth-server + namespace: auth-prod + labels: + app.kubernetes.io/name: auth-server + app.kubernetes.io/component: api + app.kubernetes.io/part-of: identity-platform +spec: + type: ClusterIP + selector: + app.kubernetes.io/name: auth-server + app.kubernetes.io/instance: auth-server + ports: + - name: http + port: 80 + targetPort: http + protocol: TCP + appProtocol: http + - name: metrics + port: 9090 + targetPort: metrics + protocol: TCP + appProtocol: http +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: auth-server + namespace: auth-prod + labels: + app.kubernetes.io/name: auth-server + app.kubernetes.io/part-of: identity-platform + annotations: + cert-manager.io/cluster-issuer: letsencrypt-prod + traefik.ingress.kubernetes.io/router.entrypoints: websecure + traefik.ingress.kubernetes.io/router.tls: "true" + traefik.ingress.kubernetes.io/router.tls.options: ingress-traefik-modern-tls@kubernetescrd + traefik.ingress.kubernetes.io/router.middlewares: ingress-traefik-https-redirect@kubernetescrd,ingress-traefik-security-headers@kubernetescrd +spec: + ingressClassName: traefik + tls: + - hosts: + - auth.example.com + secretName: auth-example-com-tls + rules: + - host: auth.example.com + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: auth-server + port: + name: http +``` + +**왜 좋은가:** + +- `ingressClassName: traefik` 필드 사용, deprecated annotation 미사용. +- `cert-manager.io/cluster-issuer` annotation으로 TLS Secret(`auth-example-com-tls`)이 자동 발급. +- Traefik middleware 체인으로 HSTS + HTTPS redirect + TLSOption 적용. +- Service는 named port `http`, `metrics` 분리. Ingress는 `http`만 라우팅, metrics는 NetworkPolicy로 Prometheus만 허용. + +--- + +## 나쁜 예시 1: deprecated ingress.class annotation + TLS 누락 + +```yaml +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: auth-server + namespace: auth-prod + annotations: + kubernetes.io/ingress.class: traefik +spec: + rules: + - host: auth.example.com + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: auth-server + port: + number: 80 +``` + +**문제:** + +- `kubernetes.io/ingress.class` annotation은 1.22부터 deprecated. 일부 컨트롤러는 무시한다. +- `spec.tls` 없음 → 평문 HTTP로 노출. 인증 시스템에는 특히 부적절. +- HSTS/HTTPS redirect 미적용. +- Service 포트를 `number: 80`으로 hard-code. named port drift에 취약. + +--- + +## 좋은 예시 5: Keycloak은 `/realms/`, `/resources/`, `/.well-known/`만 공개 + +```yaml +apiVersion: cert-manager.io/v1 +kind: Certificate +metadata: + name: sso-example-com + namespace: keycloak +spec: + secretName: sso-example-com-tls + duration: 2160h + renewBefore: 360h + privateKey: + algorithm: ECDSA + size: 256 + dnsNames: + - sso.example.com + issuerRef: + kind: ClusterIssuer + name: letsencrypt-prod +--- +apiVersion: v1 +kind: Service +metadata: + name: keycloak + namespace: keycloak + labels: + app.kubernetes.io/name: keycloak +spec: + type: ClusterIP + selector: + app.kubernetes.io/name: keycloak + ports: + - name: http + port: 8080 + targetPort: http + protocol: TCP + appProtocol: http + - name: management + port: 9000 + targetPort: management + protocol: TCP + appProtocol: http +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: keycloak + namespace: keycloak + annotations: + cert-manager.io/cluster-issuer: letsencrypt-prod + traefik.ingress.kubernetes.io/router.entrypoints: websecure + traefik.ingress.kubernetes.io/router.tls: "true" + traefik.ingress.kubernetes.io/router.tls.options: ingress-traefik-modern-tls@kubernetescrd + traefik.ingress.kubernetes.io/router.middlewares: ingress-traefik-https-redirect@kubernetescrd,ingress-traefik-security-headers@kubernetescrd +spec: + ingressClassName: traefik + tls: + - hosts: + - sso.example.com + secretName: sso-example-com-tls + rules: + - host: sso.example.com + http: + paths: + - path: /realms/ + pathType: Prefix + backend: + service: + name: keycloak + port: + name: http + - path: /resources/ + pathType: Prefix + backend: + service: + name: keycloak + port: + name: http + - path: /.well-known/ + pathType: Prefix + backend: + service: + name: keycloak + port: + name: http +``` + +**왜 좋은가:** + +- Keycloak 공식 권장 공개 경로만 노출. +- `/admin/`, `/metrics`, `/health`는 Ingress에 없음 → 외부에서 접근 불가. +- `management`(9000) 포트는 Service에만 존재하고 Ingress에는 없음. + +--- + +## 나쁜 예시 2: host 없는 defaultBackend + admin 노출 + +```yaml +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: catch-all + namespace: keycloak +spec: + ingressClassName: traefik + defaultBackend: + service: + name: keycloak + port: + number: 8080 + rules: + - http: + paths: + - path: /admin + pathType: Prefix + backend: + service: + name: keycloak + port: + number: 9000 +``` + +**문제:** + +- `defaultBackend`가 모든 host의 unmatched 요청을 Keycloak으로 포워딩 → 다른 앱 공격면 확대. +- `/admin`을 관리 포트 9000으로 프록시 → Keycloak 공식 권고 위반, 관리 콘솔 외부 노출. +- TLS/HSTS/redirect 미적용. + +--- + +## 좋은 예시 6: Traefik IngressRoute + Middleware(TLS 1.2, rate-limit, BasicAuth) + +```yaml +apiVersion: traefik.io/v1alpha1 +kind: Middleware +metadata: + name: rate-limit + namespace: auth-prod +spec: + rateLimit: + average: 100 + burst: 200 + period: 1s +--- +apiVersion: traefik.io/v1alpha1 +kind: IngressRoute +metadata: + name: auth-server + namespace: auth-prod +spec: + entryPoints: + - websecure + routes: + - kind: Rule + match: Host(`auth.example.com`) && PathPrefix(`/api/v1`) + services: + - kind: Service + name: auth-server + port: http + scheme: http + passHostHeader: true + middlewares: + - name: https-redirect + namespace: ingress-traefik + - name: security-headers + namespace: ingress-traefik + - name: rate-limit + namespace: auth-prod + tls: + secretName: auth-example-com-tls + options: + name: modern-tls + namespace: ingress-traefik +``` + +**왜 좋은가:** + +- Traefik CRD 네이티브. match 표현이 강력(Host + PathPrefix 조합, Header match 가능). +- Middleware 체인(HSTS + redirect + rate-limit)을 순서대로 지정. +- `TLSOption`을 Route마다 override 가능(특정 host만 mTLS 요구 등). + +--- + +## 좋은 예시 7: Vault/DB는 Ingress 없이 ClusterIP + +```yaml +apiVersion: v1 +kind: Service +metadata: + name: vault + namespace: vault + labels: + app.kubernetes.io/name: vault +spec: + type: ClusterIP + selector: + app.kubernetes.io/name: vault + ports: + - name: https + port: 8200 + targetPort: https + protocol: TCP + appProtocol: https + - name: cluster + port: 8201 + targetPort: cluster + protocol: TCP +--- +apiVersion: v1 +kind: Service +metadata: + name: identity-postgres + namespace: data-prod + labels: + app.kubernetes.io/name: postgres + app.kubernetes.io/instance: identity-postgres +spec: + type: ClusterIP + clusterIP: None + selector: + app.kubernetes.io/name: postgres + app.kubernetes.io/instance: identity-postgres + ports: + - name: postgres + port: 5432 + targetPort: postgres + protocol: TCP + appProtocol: postgresql +``` + +**왜 좋은가:** + +- Vault/Postgres 둘 다 Ingress 없음 → 외부 L7 공격면 0. +- Postgres는 headless(`clusterIP: None`) → StatefulSet Pod에 직접 DNS. +- named port `https`/`postgres` 사용. + +--- + +## 나쁜 예시 3: 운영 DB를 NodePort로 공개 + +```yaml +apiVersion: v1 +kind: Service +metadata: + name: identity-postgres + namespace: data-prod +spec: + type: NodePort + ports: + - port: 5432 + targetPort: 5432 + nodePort: 30032 +``` + +**문제:** + +- 모든 노드의 30032 포트가 외부에서 접근 가능 → DB가 인터넷에 노출될 수 있음. +- TLS/mTLS/NetworkPolicy 어디서도 통제 불가. +- `services.nodeports: 0` quota를 걸어 namespace 단에서 차단해야 한다. + +--- + +## 좋은 예시 8: K3s Traefik HelmChartConfig override + +```yaml +apiVersion: helm.cattle.io/v1 +kind: HelmChartConfig +metadata: + name: traefik + namespace: kube-system +spec: + valuesContent: |- + deployment: + replicas: 3 + service: + spec: + externalTrafficPolicy: Local + ports: + web: + redirectTo: + port: websecure + priority: 10 + websecure: + tls: + enabled: true + ingressClass: + enabled: true + isDefaultClass: true + additionalArguments: + - "--providers.kubernetesingress.ingressclass=traefik" + - "--metrics.prometheus=true" + - "--entrypoints.websecure.http.tls.options=modern-tls@kubernetescrd" + resources: + requests: + cpu: 200m + memory: 256Mi + limits: + cpu: "1" + memory: 512Mi +``` + +**왜 좋은가:** + +- K3s packaged Traefik manifest는 건드리지 않고, override만 선언적으로 관리. +- replica 3, `externalTrafficPolicy: Local`로 source IP 보존. +- 기본 websecure에 `modern-tls` TLSOption을 묶어 platform 전역 TLS 정책 통일. diff --git a/docs/examples/infra/observability-health.md b/docs/examples/infra/observability-health.md new file mode 100644 index 0000000..564ea5b --- /dev/null +++ b/docs/examples/infra/observability-health.md @@ -0,0 +1,601 @@ +# observability / health 예시 + +--- + +## 좋은 예시 1: ServiceMonitor (kube-prometheus-stack 표준) + +```yaml +--- +apiVersion: monitoring.coreos.com/v1 +kind: ServiceMonitor +metadata: + name: auth-server + namespace: auth-prod + labels: + app.kubernetes.io/name: auth-server + app.kubernetes.io/instance: auth-server-prod + app.kubernetes.io/part-of: identity-platform + release: kube-prometheus-stack +spec: + namespaceSelector: + matchNames: + - auth-prod + selector: + matchLabels: + app.kubernetes.io/name: auth-server + app.kubernetes.io/instance: auth-server-prod + endpoints: + - port: metrics # named port (required) + path: /actuator/prometheus + scheme: http + interval: 30s + scrapeTimeout: 10s + honorLabels: false + relabelings: + - sourceLabels: [__meta_kubernetes_pod_name] + targetLabel: pod + - sourceLabels: [__meta_kubernetes_namespace] + targetLabel: namespace + - sourceLabels: [__meta_kubernetes_pod_label_app_kubernetes_io_version] + targetLabel: version + - action: labeldrop + regex: "pod_template_hash|controller_revision_hash" + metricRelabelings: + - sourceLabels: [__name__] + regex: "jvm_gc_pause_seconds_.*" + action: keep + - sourceLabels: [__name__] + regex: "debug_.*" + action: drop +``` + +**왜 좋은가:** + +- `namespaceSelector` 명시로 암묵적 전체 허용 방지. +- `port: metrics` 는 Service/Deployment의 named port를 참조 → 포트 번호 변경에 내성. +- `interval / scrapeTimeout` 관계 유지 (timeout < interval). +- `relabelings` 로 pod / namespace / version label 정리, noise label drop. +- `metricRelabelings` 로 불필요 metric drop (cardinality / storage 절감). +- `release: kube-prometheus-stack` label 로 Operator가 선택. + +--- + +## 좋은 예시 2: ServiceMonitor with bearer token (Vault telemetry) + +```yaml +--- +apiVersion: v1 +kind: Secret +metadata: + name: vault-metrics-token + namespace: vault +type: Opaque +stringData: + token: "hvs.xxxx.prometheus-readonly" +--- +apiVersion: monitoring.coreos.com/v1 +kind: ServiceMonitor +metadata: + name: vault + namespace: vault + labels: + app.kubernetes.io/name: vault + app.kubernetes.io/instance: vault-prod + release: kube-prometheus-stack +spec: + namespaceSelector: + matchNames: + - vault + selector: + matchLabels: + app.kubernetes.io/name: vault + app.kubernetes.io/instance: vault-prod + endpoints: + - port: https + path: /v1/sys/metrics + params: + format: ["prometheus"] + scheme: https + interval: 30s + scrapeTimeout: 10s + bearerTokenSecret: + name: vault-metrics-token + key: token + tlsConfig: + insecureSkipVerify: false + ca: + secret: + name: vault-ca + key: ca.crt + serverName: vault.vault.svc + relabelings: + - sourceLabels: [__meta_kubernetes_pod_name] + targetLabel: pod +``` + +**왜 좋은가:** + +- Vault `/sys/metrics` 는 read token 필수. `bearerTokenSecret` 참조로 Operator가 주입. +- TLS CA pinning + serverName 으로 MitM 방지. +- `params` 로 Prometheus format 요청. + +--- + +## 좋은 예시 3: PodMonitor (Service 없는 워크로드) + +```yaml +--- +apiVersion: monitoring.coreos.com/v1 +kind: PodMonitor +metadata: + name: batch-worker + namespace: batch + labels: + release: kube-prometheus-stack +spec: + namespaceSelector: + matchNames: + - batch + selector: + matchLabels: + app.kubernetes.io/name: batch-worker + podMetricsEndpoints: + - port: metrics + path: /metrics + interval: 30s + scrapeTimeout: 10s + relabelings: + - sourceLabels: [__meta_kubernetes_pod_name] + targetLabel: pod +``` + +**왜 좋은가:** + +- Job / headless workload처럼 Service 뒤에 없는 경우 PodMonitor로 직접 pod 매칭. + +--- + +## 좋은 예시 4: Annotation-based fallback (Operator 없는 환경 only) + +```yaml +--- +apiVersion: v1 +kind: Service +metadata: + name: legacy-app + namespace: legacy + annotations: + prometheus.io/scrape: "true" + prometheus.io/port: "8081" + prometheus.io/path: "/metrics" + prometheus.io/scheme: "http" +spec: + selector: + app.kubernetes.io/name: legacy-app + ports: + - name: http + port: 80 + targetPort: 8080 + - name: metrics + port: 8081 + targetPort: 8081 +``` + +**왜 좋은가 (조건부):** + +- kube-prometheus-stack이 없는 legacy 환경에서만 유효. +- Operator가 있으면 ServiceMonitor로 전환. + +--- + +## 좋은 예시 5: NetworkPolicy — prometheus namespace만 metrics scrape 허용 + +```yaml +--- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: auth-server-default-deny + namespace: auth-prod +spec: + podSelector: + matchLabels: + app.kubernetes.io/name: auth-server + policyTypes: ["Ingress", "Egress"] + ingress: [] + egress: [] +--- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: auth-server-allow-metrics + namespace: auth-prod +spec: + podSelector: + matchLabels: + app.kubernetes.io/name: auth-server + policyTypes: ["Ingress"] + ingress: + - from: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: monitoring + podSelector: + matchLabels: + app.kubernetes.io/name: prometheus + ports: + - port: metrics + protocol: TCP +--- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: auth-server-allow-http-from-ingress + namespace: auth-prod +spec: + podSelector: + matchLabels: + app.kubernetes.io/name: auth-server + policyTypes: ["Ingress"] + ingress: + - from: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: ingress-nginx + ports: + - port: http + protocol: TCP +``` + +**왜 좋은가:** + +- default-deny → allow-list 패턴. +- metrics port는 monitoring namespace의 prometheus pod만. +- http port는 ingress controller namespace만. + +--- + +## 좋은 예시 6: JSON structured log (Spring Boot logback) + +```xml + + + + + trace_id + span_id + request_id + {"service":"auth-server"} + + + + + + +``` + +Actual output: + +```json +{"timestamp":"2026-04-16T09:31:42.017Z","level":"INFO","service":"auth-server","trace_id":"4bf92f3577b34da6a3ce929d0e0e4736","span_id":"00f067aa0ba902b7","logger":"c.e.auth.LoginController","thread":"http-nio-8080-exec-3","message":"login success","user_id_hash":"ab12..."} +``` + +**왜 좋은가:** + +- ISO 8601 UTC timestamp. +- trace_id / span_id 가 MDC에서 자동 주입 → Tempo / Jaeger와 correlate. +- service label이 customFields로 고정. +- user_id는 hashed → cardinality/PII 안전. + +--- + +## 좋은 예시 7: OpenTelemetry Collector (DaemonSet agent + Deployment gateway) + +```yaml +--- +apiVersion: opentelemetry.io/v1beta1 +kind: OpenTelemetryCollector +metadata: + name: otel-agent + namespace: observability +spec: + mode: daemonset + image: otel/opentelemetry-collector-contrib:0.101.0 + config: + receivers: + otlp: + protocols: + grpc: + endpoint: 0.0.0.0:4317 + http: + endpoint: 0.0.0.0:4318 + processors: + batch: + send_batch_size: 1024 + timeout: 5s + k8sattributes: + passthrough: false + extract: + metadata: + - k8s.pod.name + - k8s.namespace.name + - k8s.node.name + exporters: + otlp/gateway: + endpoint: otel-gateway.observability.svc:4317 + tls: + insecure: true + service: + pipelines: + traces: + receivers: [otlp] + processors: [k8sattributes, batch] + exporters: [otlp/gateway] + metrics: + receivers: [otlp] + processors: [k8sattributes, batch] + exporters: [otlp/gateway] +--- +apiVersion: opentelemetry.io/v1beta1 +kind: OpenTelemetryCollector +metadata: + name: otel-gateway + namespace: observability +spec: + mode: deployment + replicas: 3 + image: otel/opentelemetry-collector-contrib:0.101.0 + config: + receivers: + otlp: + protocols: + grpc: + endpoint: 0.0.0.0:4317 + processors: + batch: + send_batch_size: 2048 + timeout: 5s + tail_sampling: + decision_wait: 10s + policies: + - name: errors-keep + type: status_code + status_code: { status_codes: [ERROR] } + - name: slow-keep + type: latency + latency: { threshold_ms: 500 } + - name: default-10pct + type: probabilistic + probabilistic: { sampling_percentage: 10 } + attributes/redact: + actions: + - key: http.request.header.authorization + action: delete + - key: user.email + action: hash + exporters: + otlp/tempo: + endpoint: tempo.observability.svc:4317 + tls: + insecure: true + prometheusremotewrite: + endpoint: http://prometheus.monitoring.svc:9090/api/v1/write + service: + pipelines: + traces: + receivers: [otlp] + processors: [attributes/redact, tail_sampling, batch] + exporters: [otlp/tempo] + metrics: + receivers: [otlp] + processors: [batch] + exporters: [prometheusremotewrite] +``` + +**왜 좋은가:** + +- agent (DaemonSet) → gateway (Deployment) 2단 구조. +- gateway에서 tail-based sampling (error + slow + 10% 나머지). +- PII redaction을 gateway에서 중앙 처리. +- agent가 node-local이라 app은 localhost endpoint만 알면 됨. + +--- + +## 좋은 예시 8: Loki + Grafana Alloy DaemonSet (log shipping) + +```yaml +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: alloy-config + namespace: observability +data: + config.alloy: | + discovery.kubernetes "pods" { + role = "pod" + } + discovery.relabel "pods" { + targets = discovery.kubernetes.pods.targets + rule { + source_labels = ["__meta_kubernetes_namespace"] + target_label = "namespace" + } + rule { + source_labels = ["__meta_kubernetes_pod_label_app_kubernetes_io_name"] + target_label = "service" + } + } + loki.source.kubernetes "pods" { + targets = discovery.relabel.pods.output + forward_to = [loki.write.default.receiver] + } + loki.write "default" { + endpoint { + url = "http://loki.observability.svc:3100/loki/api/v1/push" + } + } +``` + +**왜 좋은가:** + +- Alloy DaemonSet이 node-level log tail. +- label은 namespace / service 두 개로 제한 (cardinality 안전). + +--- + +## 좋은 예시 9: `kubectl events` (1.27+ stable) + +```bash +# cluster-wide live watch, warnings only +kubectl events -A --types=Warning --watch + +# specific pod +kubectl events -n auth-prod --for pod/auth-server-abc123 + +# last hour +kubectl events -n auth-prod --since=1h +``` + +**왜 좋은가:** + +- `--for` 로 특정 오브젝트 event 만 필터링. +- `--watch` 가 `get events -w` 보다 안정적. +- timestamp sort 기본 제공. + +--- + +## 나쁜 예시 1: `/metrics` 를 Ingress로 외부 공개 + +```yaml +apiVersion: networking.k8s.io/v1 +kind: Ingress +spec: + rules: + - host: auth.example.com + http: + paths: + - path: /metrics # BAD + pathType: Prefix + backend: + service: + name: auth-server + port: + number: 8081 +``` + +**문제:** + +- Prometheus metric으로 내부 구조 / error rate / version 노출. +- DoS vector (scrape 비용). +- audit / compliance 위반. + +**Fix:** metrics port는 외부 비공개, NetworkPolicy로 monitoring namespace만 허용. + +--- + +## 나쁜 예시 2: high-cardinality label + +```yaml +# app code +http_requests_total{user_id="12345", path="/users/12345/orders/98765", request_id="a1b2c3..."} +``` + +**문제:** + +- user_id × path × request_id = 수백만 time series → Prometheus OOM. +- query 성능 붕괴. + +**Fix:** + +``` +http_requests_total{route="/users/:id/orders/:id", method="GET", status_class="2xx"} +``` + +- route template 화, status는 bucket (2xx/4xx/5xx). +- user_id 는 logging에만, metric label 금지. + +--- + +## 나쁜 예시 3: ServiceMonitor에 namespaceSelector 없음 + +```yaml +apiVersion: monitoring.coreos.com/v1 +kind: ServiceMonitor +spec: + selector: + matchLabels: + app: my-app + # namespaceSelector 없음 → Operator 설정에 따라 전체 cluster scan + endpoints: + - port: metrics +``` + +**문제:** + +- 암묵적으로 너무 넓은 범위 (Operator 설정에 따라 다름). +- 동일 label 를 다른 namespace에서 쓰면 의도치 않은 scrape. + +**Fix:** `namespaceSelector.matchNames` 명시. + +--- + +## 나쁜 예시 4: probe가 `/metrics` 사용 + +```yaml +readinessProbe: + httpGet: + path: /metrics # BAD + port: 8081 + periodSeconds: 5 +``` + +**문제:** + +- `/metrics` 는 비용이 큰 endpoint (모든 registry dump). +- periodSeconds 5초 × N pod = unnecessary load. +- readiness 의미와 무관. + +**Fix:** `/actuator/health/readiness` 같은 전용 shallow endpoint. + +--- + +## 나쁜 예시 5: 로그에 access token 그대로 + +``` +2026-04-16T09:32:11.002 INFO Exchanging code for token: access_token=eyJhbGciOi... +``` + +**문제:** + +- token이 log index에 그대로 저장 → 유출 리스크. +- 중앙 로그 시스템 (OpenSearch / Loki) 에 영구 보관. + +**Fix:** + +- 애플리케이션에서 token 값 로깅 금지. +- 중앙 파이프라인에 regex redaction (`access_token=[^ ]+` → `access_token=***`). +- debug 로그에서도 masking. + +--- + +## 나쁜 예시 6: 로그를 PVC / file로 적재 + +```yaml +volumeMounts: + - name: app-logs + mountPath: /var/log/app # BAD +volumes: + - name: app-logs + persistentVolumeClaim: + claimName: app-logs-pvc +``` + +**문제:** + +- 컨테이너 표준 (stdout/stderr) 위반. +- Pod 삭제 시 로그 손실 또는 orphan PVC. +- `kubectl logs` 로 안 보임. +- node log agent가 수집 못 함. + +**Fix:** stdout/stderr로 출력, DaemonSet agent가 수집. diff --git a/docs/examples/infra/operations-runbook-upgrade-rollback.md b/docs/examples/infra/operations-runbook-upgrade-rollback.md new file mode 100644 index 0000000..58e5b10 --- /dev/null +++ b/docs/examples/infra/operations-runbook-upgrade-rollback.md @@ -0,0 +1,744 @@ +# operations / runbook / upgrade / rollback 예시 + +--- + +## 좋은 예시 1: 표준 application 변경 절차 + +```bash +# 1) render +kubectl kustomize k8s/overlays/prod > /tmp/render.yaml + +# 2) diff +kubectl diff -k k8s/overlays/prod + +# 3) apply +kubectl apply -k k8s/overlays/prod + +# 4) rollout status with timeout +kubectl rollout status deployment/auth-server -n auth-prod --timeout=10m + +# 5) smoke test +curl -fsS https://auth.internal.example.com/actuator/health/readiness + +# 6) SLO dashboard check (p99 latency, error rate) +``` + +**왜 좋은가:** + +- render / diff / apply / status / post-check 가 명시적으로 분리. +- `--timeout` 으로 무한 대기 방지. +- post-check가 단순 curl 이 아니라 readiness endpoint 대상. + +--- + +## 좋은 예시 2: Deployment rollingUpdate 파라미터 워크로드별 튜닝 + +```yaml +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: auth-server + namespace: auth-prod + labels: + app.kubernetes.io/name: auth-server + app.kubernetes.io/instance: auth-server-prod +spec: + replicas: 10 + strategy: + type: RollingUpdate + rollingUpdate: + maxSurge: 25% + maxUnavailable: 10% # latency-sensitive면 0 + selector: + matchLabels: + app.kubernetes.io/name: auth-server + app.kubernetes.io/instance: auth-server-prod + template: + metadata: + labels: + app.kubernetes.io/name: auth-server + app.kubernetes.io/instance: auth-server-prod + spec: + securityContext: + runAsNonRoot: true + runAsUser: 10001 + runAsGroup: 10001 + fsGroup: 10001 + seccompProfile: + type: RuntimeDefault + containers: + - name: auth-server + image: registry.example.com/identity/auth-server:1.25.0 + ports: + - { name: http, containerPort: 8080 } + resources: + requests: { cpu: 500m, memory: 1Gi } + limits: { memory: 1536Mi } + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: { drop: ["ALL"] } +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: legacy-singleton + namespace: legacy +spec: + replicas: 1 + strategy: + type: Recreate # singleton이며 동시성 금지 + selector: + matchLabels: + app.kubernetes.io/name: legacy-singleton + template: + metadata: + labels: + app.kubernetes.io/name: legacy-singleton + spec: + securityContext: + runAsNonRoot: true + runAsUser: 10001 + runAsGroup: 10001 + fsGroup: 10001 + seccompProfile: + type: RuntimeDefault + containers: + - name: app + image: registry.example.com/legacy/singleton:1.0.0 + resources: + requests: { cpu: 100m, memory: 256Mi } + limits: { memory: 512Mi } + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: { drop: ["ALL"] } +``` + +**왜 좋은가:** + +- fleet 규모에 맞춘 maxSurge/maxUnavailable. +- singleton 에 Recreate (PVC ReadWriteOnce 전제 충족). + +--- + +## 좋은 예시 3: Argo Rollouts canary with AnalysisTemplate + +```yaml +--- +apiVersion: argoproj.io/v1alpha1 +kind: AnalysisTemplate +metadata: + name: success-rate + namespace: auth-prod +spec: + args: + - name: service-name + metrics: + - name: success-rate + interval: 1m + count: 5 + successCondition: result[0] >= 0.99 + failureLimit: 2 + provider: + prometheus: + address: http://prometheus.monitoring.svc:9090 + query: | + sum(rate(http_requests_total{service="{{args.service-name}}",status_class=~"2.."}[2m])) + / + sum(rate(http_requests_total{service="{{args.service-name}}"}[2m])) + - name: p99-latency + interval: 1m + count: 5 + successCondition: result[0] <= 0.5 + failureLimit: 2 + provider: + prometheus: + address: http://prometheus.monitoring.svc:9090 + query: | + histogram_quantile(0.99, + sum by (le) (rate(http_request_duration_seconds_bucket{service="{{args.service-name}}"}[2m])) + ) +--- +apiVersion: argoproj.io/v1alpha1 +kind: Rollout +metadata: + name: auth-server + namespace: auth-prod + labels: + app.kubernetes.io/name: auth-server + app.kubernetes.io/instance: auth-server-prod +spec: + replicas: 10 + revisionHistoryLimit: 5 + selector: + matchLabels: + app.kubernetes.io/name: auth-server + app.kubernetes.io/instance: auth-server-prod + template: + metadata: + labels: + app.kubernetes.io/name: auth-server + app.kubernetes.io/instance: auth-server-prod + spec: + containers: + - name: auth-server + image: registry.example.com/identity/auth-server:1.25.0 + ports: + - name: http + containerPort: 8080 + resources: + requests: + cpu: "500m" + memory: "1Gi" + limits: + memory: "1536Mi" + readinessProbe: + httpGet: + path: /actuator/health/readiness + port: http + strategy: + canary: + canaryService: auth-server-canary + stableService: auth-server-stable + trafficRouting: + nginx: + stableIngress: auth-server + steps: + - setWeight: 10 + - pause: { duration: 2m } + - analysis: + templates: + - templateName: success-rate + args: + - name: service-name + value: auth-server + - setWeight: 25 + - pause: { duration: 5m } + - analysis: + templates: + - templateName: success-rate + args: + - name: service-name + value: auth-server + - setWeight: 50 + - pause: { duration: 10m } + - analysis: + templates: + - templateName: success-rate + args: + - name: service-name + value: auth-server + - setWeight: 100 +--- +apiVersion: v1 +kind: Service +metadata: + name: auth-server-stable + namespace: auth-prod +spec: + selector: + app.kubernetes.io/name: auth-server + app.kubernetes.io/instance: auth-server-prod + ports: + - name: http + port: 80 + targetPort: http +--- +apiVersion: v1 +kind: Service +metadata: + name: auth-server-canary + namespace: auth-prod +spec: + selector: + app.kubernetes.io/name: auth-server + app.kubernetes.io/instance: auth-server-prod + ports: + - name: http + port: 80 + targetPort: http +``` + +**왜 좋은가:** + +- `AnalysisTemplate` 이 Prometheus success-rate + p99 latency 를 동시에 측정. +- `failureLimit: 2` → 두 번 실패 시 자동 abort. +- canary step: 10% → 25% → 50% → 100% 각 단계에 pause + analysis. +- stable/canary Service 두 개 + NGINX ingress traffic routing. + +--- + +## 좋은 예시 4: K3s System Upgrade Controller Plan (server + agent) + +```yaml +--- +apiVersion: v1 +kind: Namespace +metadata: + name: system-upgrade +--- +apiVersion: v1 +kind: Secret +metadata: + name: k3s-upgrade-token + namespace: system-upgrade +type: Opaque +stringData: + # 실제 환경은 K3S_TOKEN 값 + token: "REPLACE_WITH_NODE_TOKEN" +--- +apiVersion: upgrade.cattle.io/v1 +kind: Plan +metadata: + name: k3s-server + namespace: system-upgrade + labels: + k3s-upgrade: server +spec: + concurrency: 1 + nodeSelector: + matchExpressions: + - { key: node-role.kubernetes.io/control-plane, operator: In, values: ["true"] } + serviceAccountName: system-upgrade + cordon: true + drain: + force: true + deleteEmptydirData: true + ignoreDaemonSets: true + skipWaitForDeleteTimeout: 60 + upgrade: + image: rancher/k3s-upgrade + version: v1.30.3+k3s1 +--- +apiVersion: upgrade.cattle.io/v1 +kind: Plan +metadata: + name: k3s-agent + namespace: system-upgrade + labels: + k3s-upgrade: agent +spec: + concurrency: 1 + nodeSelector: + matchExpressions: + - { key: node-role.kubernetes.io/control-plane, operator: NotIn, values: ["true"] } + serviceAccountName: system-upgrade + prepare: + image: rancher/k3s-upgrade + args: ["prepare", "k3s-server"] # server plan 완료 대기 + cordon: true + drain: + force: true + deleteEmptydirData: true + ignoreDaemonSets: true + skipWaitForDeleteTimeout: 60 + upgrade: + image: rancher/k3s-upgrade + version: v1.30.3+k3s1 +``` + +**왜 좋은가:** + +- server-plan → agent-plan 분리 + agent 가 `prepare` 로 server 완료 대기. +- `concurrency: 1` → 한 번에 한 노드만 업그레이드 (가용성 보호). +- `cordon + drain` → PDB 존중. +- `deleteEmptydirData: true, ignoreDaemonsets: true` 표준. +- `version` 명시 (channel 사용 시 의도치 않은 upgrade 가능). + +--- + +## 좋은 예시 5: ArgoCD sync wave + PreSync migration hook + +```yaml +--- +apiVersion: batch/v1 +kind: Job +metadata: + name: flyway-migrate + namespace: auth-prod + annotations: + argocd.argoproj.io/hook: PreSync + argocd.argoproj.io/hook-delete-policy: BeforeHookCreation + argocd.argoproj.io/sync-wave: "-1" + labels: + app.kubernetes.io/name: auth-server + app.kubernetes.io/instance: auth-server-prod + app.kubernetes.io/component: db-migration +spec: + backoffLimit: 0 + activeDeadlineSeconds: 600 + ttlSecondsAfterFinished: 86400 + template: + spec: + restartPolicy: Never + securityContext: + runAsNonRoot: true + runAsUser: 1000 + runAsGroup: 1000 + fsGroup: 1000 + seccompProfile: + type: RuntimeDefault + containers: + - name: flyway + image: flyway/flyway:10.15.0 + args: ["-url=jdbc:postgresql://postgres:5432/auth", "validate", "info", "migrate"] + envFrom: + - secretRef: + name: auth-db + resources: + requests: { cpu: 100m, memory: 128Mi } + limits: { memory: 512Mi } + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: ["ALL"] + volumeMounts: + - { name: tmp, mountPath: /tmp } + volumes: + - name: tmp + emptyDir: {} +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: auth-server + namespace: auth-prod + annotations: + argocd.argoproj.io/sync-wave: "0" + labels: + app.kubernetes.io/name: auth-server + app.kubernetes.io/instance: auth-server-prod + # ... (생략) +--- +apiVersion: batch/v1 +kind: Job +metadata: + name: smoke-test + namespace: auth-prod + annotations: + argocd.argoproj.io/hook: PostSync + argocd.argoproj.io/hook-delete-policy: HookSucceeded + argocd.argoproj.io/sync-wave: "1" +spec: + backoffLimit: 2 + activeDeadlineSeconds: 300 + ttlSecondsAfterFinished: 3600 + template: + spec: + restartPolicy: OnFailure + securityContext: + runAsNonRoot: true + runAsUser: 1000 + runAsGroup: 1000 + fsGroup: 1000 + seccompProfile: + type: RuntimeDefault + containers: + - name: smoke + image: registry.example.com/tools/smoke:1.4.0 + args: ["--target", "https://auth.internal.example.com"] + resources: + requests: { cpu: 50m, memory: 64Mi } + limits: { memory: 128Mi } + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: ["ALL"] +``` + +**왜 좋은가:** + +- PreSync Job 으로 Flyway migrate 가 app rollout 앞 단계에 실행. +- PostSync Job 으로 smoke test 자동 실행. +- sync-wave 로 순서 명시 (-1 → 0 → 1). +- `BeforeHookCreation` 으로 이전 Job 충돌 방지. + +--- + +## 좋은 예시 6: blue/green via two Services (수동 패턴) + +```yaml +--- +apiVersion: v1 +kind: Service +metadata: + name: auth-server # live traffic + namespace: auth-prod +spec: + selector: + app.kubernetes.io/name: auth-server + app.kubernetes.io/instance: auth-server-prod + version: blue # <- 이 label만 바꾸면 cutover + ports: + - name: http + port: 80 + targetPort: http +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: auth-server-blue + namespace: auth-prod +spec: + replicas: 3 + selector: + matchLabels: + app.kubernetes.io/name: auth-server + version: blue + template: + metadata: + labels: + app.kubernetes.io/name: auth-server + app.kubernetes.io/instance: auth-server-prod + version: blue + spec: + securityContext: + runAsNonRoot: true + runAsUser: 10001 + runAsGroup: 10001 + fsGroup: 10001 + seccompProfile: + type: RuntimeDefault + containers: + - name: auth-server + image: registry.example.com/identity/auth-server:1.24.0 + resources: + requests: { cpu: 500m, memory: 1Gi } + limits: { memory: 1536Mi } + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: ["ALL"] +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: auth-server-green + namespace: auth-prod +spec: + replicas: 3 + selector: + matchLabels: + app.kubernetes.io/name: auth-server + version: green + template: + metadata: + labels: + app.kubernetes.io/name: auth-server + app.kubernetes.io/instance: auth-server-prod + version: green + spec: + securityContext: + runAsNonRoot: true + runAsUser: 10001 + runAsGroup: 10001 + fsGroup: 10001 + seccompProfile: + type: RuntimeDefault + containers: + - name: auth-server + image: registry.example.com/identity/auth-server:1.25.0 + resources: + requests: { cpu: 500m, memory: 1Gi } + limits: { memory: 1536Mi } + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: ["ALL"] +``` + +Cutover: + +```bash +kubectl patch svc auth-server -n auth-prod \ + -p '{"spec":{"selector":{"app.kubernetes.io/name":"auth-server","app.kubernetes.io/instance":"auth-server-prod","version":"green"}}}' +``` + +**왜 좋은가:** + +- Service selector version label 하나로 전환 / rollback. +- canary 가 아니라 instant cutover. +- 데이터 호환성이 깨진 경우만 사용. + +--- + +## 좋은 예시 7: node maintenance flow + +```bash +NODE=worker-3 + +# 1) cordon +kubectl cordon "${NODE}" + +# 2) drain (PDB 존중) +kubectl drain "${NODE}" \ + --ignore-daemonsets \ + --delete-emptydir-data \ + --grace-period=30 \ + --timeout=10m + +# 3) 작업 수행 (OS patch, reboot, ...) + +# 4) 복귀 +kubectl uncordon "${NODE}" + +# 5) 재배치 확인 +kubectl get pods -A -o wide --field-selector spec.nodeName="${NODE}" +``` + +**왜 좋은가:** + +- cordon → drain → uncordon 표준 시퀀스. +- PDB 위반 시 drain 이 대기, `--timeout=10m` 로 무한 대기 방지. +- 플래그 조합이 표준. + +--- + +## 좋은 예시 8: Git revision rollback + +```bash +# 1) 이전 release tag 체크아웃 +git checkout v1.24.0 + +# 2) diff +kubectl diff -k k8s/overlays/prod + +# 3) apply +kubectl apply -k k8s/overlays/prod + +# 4) rollout status +kubectl rollout status deployment/auth-server -n auth-prod --timeout=10m +``` + +**왜 좋은가:** + +- live-cluster 수정이 아니라 declarative source of truth 기준. +- 재현 가능. +- `kubectl rollout undo` 대비 audit trail 이 명확 (Git commit 기반). + +--- + +## 나쁜 예시 1: diff 없이 apply + +```bash +kubectl apply -k k8s/overlays/prod +``` + +**문제:** + +- 실제 변경 범위를 모른 채 적용. +- review / 승인 / 검증 프로세스 약화. +- 의도치 않은 리소스 삭제/수정 가능 (특히 pruned resource). + +**Fix:** `kubectl diff -k` 선행. + +--- + +## 나쁜 예시 2: migration을 app startup에 숨김 + +```yaml +# Deployment container +command: ["/bin/sh", "-c", "flyway migrate && java -jar app.jar"] +``` + +**문제:** + +- app rollout 실패와 schema 변경 실패가 섞임. +- rollout 중 여러 replica 가 동시에 migrate → race condition / lock contention. +- 롤백 시 schema 변경이 남음. + +**Fix:** PreSync Job 또는 별도 CI 단계로 Flyway migrate 를 분리. + +--- + +## 나쁜 예시 3: rollout undo 로 DB rollback 기대 + +```bash +kubectl rollout undo deployment/auth-server +# ... 이제 DB schema 도 되돌아갔을 것이다? +``` + +**문제:** + +- rollout undo 는 workload pod template 만 되돌린다. +- schema 변경은 남아 있음 → 이전 버전 app이 새 schema 와 mismatch → 500 error. +- **rollback ≠ DB rollback**. + +**Fix:** schema 는 expand/contract 패턴으로 forward-compatible. 이전 버전 코드가 새 schema 에서도 동작하도록 릴리스를 분리. + +--- + +## 나쁜 예시 4: 운영 노드 manifests 디렉터리 직접 편집 + +```bash +ssh k3s-server-1 +vim /var/lib/rancher/k3s/server/manifests/auth-server.yaml +``` + +**문제:** + +- Git source of truth 우회. +- 멀티 서버 간 동기화 없음. +- packaged AddOn 동작과 충돌 가능. +- ArgoCD 가 drift 로 인식하고 되돌릴 수 있음. + +**Fix:** Git PR → render → diff → apply 흐름. + +--- + +## 나쁜 예시 5: Recreate strategy 를 stateless app에 사용 + +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: auth-server +spec: + replicas: 5 + strategy: + type: Recreate # BAD - stateless 인데 downtime 발생 +``` + +**문제:** + +- 모든 replica 동시 종료 → full downtime. +- rolling update 의 장점 (점진 전환, rollback 용이) 상실. + +**Fix:** stateless app은 `RollingUpdate` + 워크로드별 maxSurge/maxUnavailable 튜닝. + +--- + +## 나쁜 예시 6: PDB 없이 drain + +```bash +kubectl drain worker-3 --ignore-daemonsets --delete-emptydir-data +``` + +**문제:** + +- PDB 가 없으면 critical workload 가 동시에 evict → downtime. +- 특히 replica < 3 이면 완전 손실. + +**Fix:** PDB 설계 선결 조건. 좋은 예시 7 참조. + +--- + +## 나쁜 예시 7: `kubectl rollout status` 에 timeout 없음 + +```bash +kubectl rollout status deployment/auth-server -n auth-prod +# 무한 대기 가능 +``` + +**문제:** + +- rollout 이 hang 상태일 때 CI/CD pipeline 이 무한 대기. +- 자동화 실패 원인이 숨는다. + +**Fix:** 항상 `--timeout=10m` (워크로드별 조정). diff --git a/docs/examples/infra/resources-probes-availability.md b/docs/examples/infra/resources-probes-availability.md new file mode 100644 index 0000000..56e2335 --- /dev/null +++ b/docs/examples/infra/resources-probes-availability.md @@ -0,0 +1,659 @@ +# resources / probes / availability 예시 + +아래 예시는 1000+ 서비스를 운영하는 기준선이다. 모든 YAML은 그대로 `kubectl apply -f` 가능한 형태이며, 라벨 / probe / PDB / HPA / topologySpread / ServiceMonitor / NetworkPolicy가 한 세트로 맞물린다. + +--- + +## 좋은 예시 1: auth-server 완전 매니페스트 세트 (Burstable + HPA) + +### Deployment + +```yaml +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: auth-server + namespace: auth-prod + labels: + app.kubernetes.io/name: auth-server + app.kubernetes.io/instance: auth-server-prod + app.kubernetes.io/version: "1.24.0" + app.kubernetes.io/component: api + app.kubernetes.io/part-of: identity-platform + app.kubernetes.io/managed-by: argocd +spec: + replicas: 3 + revisionHistoryLimit: 10 + strategy: + type: RollingUpdate + rollingUpdate: + maxSurge: 25% + maxUnavailable: 0 + selector: + matchLabels: + app.kubernetes.io/name: auth-server + app.kubernetes.io/instance: auth-server-prod + template: + metadata: + labels: + app.kubernetes.io/name: auth-server + app.kubernetes.io/instance: auth-server-prod + app.kubernetes.io/version: "1.24.0" + app.kubernetes.io/component: api + app.kubernetes.io/part-of: identity-platform + spec: + serviceAccountName: auth-server + terminationGracePeriodSeconds: 45 + securityContext: + runAsNonRoot: true + runAsUser: 10001 + fsGroup: 10001 + seccompProfile: + type: RuntimeDefault + topologySpreadConstraints: + - maxSkew: 1 + topologyKey: topology.kubernetes.io/zone + whenUnsatisfiable: DoNotSchedule + labelSelector: + matchLabels: + app.kubernetes.io/name: auth-server + app.kubernetes.io/instance: auth-server-prod + - maxSkew: 1 + topologyKey: kubernetes.io/hostname + whenUnsatisfiable: ScheduleAnyway + labelSelector: + matchLabels: + app.kubernetes.io/name: auth-server + app.kubernetes.io/instance: auth-server-prod + containers: + - name: auth-server + image: registry.example.com/identity/auth-server:1.24.0 + imagePullPolicy: IfNotPresent + ports: + - name: http + containerPort: 8080 + - name: metrics + containerPort: 8081 + env: + - name: JAVA_OPTS + value: "-XX:MaxRAMPercentage=75.0 -XX:+UseG1GC" + resources: + requests: + cpu: "500m" + memory: "1Gi" + limits: + memory: "1536Mi" + startupProbe: + httpGet: + path: /actuator/health/started + port: http + periodSeconds: 5 + failureThreshold: 24 + timeoutSeconds: 3 + readinessProbe: + httpGet: + path: /actuator/health/readiness + port: http + periodSeconds: 5 + failureThreshold: 3 + timeoutSeconds: 2 + livenessProbe: + httpGet: + path: /actuator/health/liveness + port: http + periodSeconds: 15 + failureThreshold: 3 + timeoutSeconds: 3 + lifecycle: + preStop: + exec: + command: ["/bin/sh", "-c", "sleep 15"] + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: ["ALL"] +``` + +**왜 좋은가:** + +- QoS는 의도적으로 Burstable (CPU limit 생략으로 throttling 회피, memory는 1.5× headroom). +- startup probe가 최대 120초 (24×5) cold start를 덮으며 그 전까지 readiness/liveness는 실행되지 않는다. +- topologySpreadConstraints로 zone 장애 격리 + host 분산. +- app.kubernetes.io/* 표준 라벨 full set. +- preStop sleep 15초로 endpoint 제거 전파 시간을 확보한다. +- rolling update `maxUnavailable: 0`으로 항상 N replica 이상 유지. + +### Service + PDB + +```yaml +--- +apiVersion: v1 +kind: Service +metadata: + name: auth-server + namespace: auth-prod + labels: + app.kubernetes.io/name: auth-server + app.kubernetes.io/instance: auth-server-prod +spec: + type: ClusterIP + selector: + app.kubernetes.io/name: auth-server + app.kubernetes.io/instance: auth-server-prod + ports: + - name: http + port: 80 + targetPort: http + - name: metrics + port: 8081 + targetPort: metrics +--- +apiVersion: policy/v1 +kind: PodDisruptionBudget +metadata: + name: auth-server + namespace: auth-prod + labels: + app.kubernetes.io/name: auth-server + app.kubernetes.io/instance: auth-server-prod +spec: + maxUnavailable: 1 + unhealthyPodEvictionPolicy: AlwaysAllow + selector: + matchLabels: + app.kubernetes.io/name: auth-server + app.kubernetes.io/instance: auth-server-prod +``` + +### HorizontalPodAutoscaler v2 with behavior block + +```yaml +--- +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: auth-server + namespace: auth-prod + labels: + app.kubernetes.io/name: auth-server + app.kubernetes.io/instance: auth-server-prod +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: auth-server + minReplicas: 3 + maxReplicas: 20 + metrics: + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: 70 + - type: Pods + pods: + metric: + name: http_requests_in_flight + target: + type: AverageValue + averageValue: "50" + behavior: + scaleUp: + stabilizationWindowSeconds: 0 + policies: + - type: Percent + value: 100 + periodSeconds: 30 + - type: Pods + value: 4 + periodSeconds: 30 + selectPolicy: Max + scaleDown: + stabilizationWindowSeconds: 300 + policies: + - type: Percent + value: 25 + periodSeconds: 60 + selectPolicy: Max +``` + +**왜 좋은가:** + +- Resource metric과 custom Pods metric을 동시에 평가. +- scaleUp stabilization 0s → 스파이크에 즉시 반응. +- scaleDown 300s stabilization + 25%/min rate → flapping 방지. +- Pods metric은 pod당 in-flight request 수 (label cardinality 안전). + +### ServiceMonitor (kube-prometheus-stack) + +```yaml +--- +apiVersion: monitoring.coreos.com/v1 +kind: ServiceMonitor +metadata: + name: auth-server + namespace: auth-prod + labels: + app.kubernetes.io/name: auth-server + app.kubernetes.io/instance: auth-server-prod + release: kube-prometheus-stack +spec: + namespaceSelector: + matchNames: + - auth-prod + selector: + matchLabels: + app.kubernetes.io/name: auth-server + app.kubernetes.io/instance: auth-server-prod + endpoints: + - port: metrics + path: /actuator/prometheus + scheme: http + interval: 30s + scrapeTimeout: 10s + honorLabels: false + relabelings: + - sourceLabels: [__meta_kubernetes_pod_name] + targetLabel: pod + - sourceLabels: [__meta_kubernetes_namespace] + targetLabel: namespace + - action: labeldrop + regex: "pod_template_hash" +``` + +### NetworkPolicy (scrape만 prometheus namespace에서 허용) + +```yaml +--- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: auth-server-metrics-from-prom + namespace: auth-prod + labels: + app.kubernetes.io/name: auth-server + app.kubernetes.io/instance: auth-server-prod +spec: + podSelector: + matchLabels: + app.kubernetes.io/name: auth-server + app.kubernetes.io/instance: auth-server-prod + policyTypes: ["Ingress"] + ingress: + - from: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: monitoring + podSelector: + matchLabels: + app.kubernetes.io/name: prometheus + ports: + - port: metrics + protocol: TCP + - from: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: ingress-nginx + ports: + - port: http + protocol: TCP +``` + +--- + +## 좋은 예시 2: Keycloak — Guaranteed QoS + slow startup + +```yaml +--- +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: keycloak + namespace: identity + labels: + app.kubernetes.io/name: keycloak + app.kubernetes.io/instance: keycloak-prod + app.kubernetes.io/version: "24.0.4" + app.kubernetes.io/component: identity-provider + app.kubernetes.io/part-of: identity-platform +spec: + serviceName: keycloak-headless + replicas: 3 + selector: + matchLabels: + app.kubernetes.io/name: keycloak + app.kubernetes.io/instance: keycloak-prod + template: + metadata: + labels: + app.kubernetes.io/name: keycloak + app.kubernetes.io/instance: keycloak-prod + app.kubernetes.io/version: "24.0.4" + spec: + terminationGracePeriodSeconds: 60 + securityContext: + runAsNonRoot: true + runAsUser: 1000 + runAsGroup: 1000 + fsGroup: 1000 + seccompProfile: + type: RuntimeDefault + topologySpreadConstraints: + - maxSkew: 1 + topologyKey: topology.kubernetes.io/zone + whenUnsatisfiable: DoNotSchedule + labelSelector: + matchLabels: + app.kubernetes.io/name: keycloak + app.kubernetes.io/instance: keycloak-prod + containers: + - name: keycloak + image: quay.io/keycloak/keycloak:24.0.4 + args: ["start"] + ports: + - name: http + containerPort: 8080 + - name: mgmt + containerPort: 9000 + resources: + requests: + cpu: "1" + memory: "2Gi" + limits: + cpu: "1" + memory: "2Gi" + startupProbe: + httpGet: + path: /health/started + port: mgmt + periodSeconds: 10 + failureThreshold: 30 + timeoutSeconds: 5 + readinessProbe: + httpGet: + path: /health/ready + port: mgmt + periodSeconds: 10 + failureThreshold: 3 + livenessProbe: + httpGet: + path: /health/live + port: mgmt + periodSeconds: 30 + failureThreshold: 3 + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: ["ALL"] + volumeMounts: + - { name: tmp, mountPath: /tmp } + - { name: data, mountPath: /opt/keycloak/data } + volumes: + - name: tmp + emptyDir: {} + - name: data + emptyDir: {} +--- +apiVersion: policy/v1 +kind: PodDisruptionBudget +metadata: + name: keycloak + namespace: identity +spec: + maxUnavailable: 1 + unhealthyPodEvictionPolicy: AlwaysAllow + selector: + matchLabels: + app.kubernetes.io/name: keycloak + app.kubernetes.io/instance: keycloak-prod +``` + +**왜 좋은가:** + +- Guaranteed QoS (request == limit 모든 리소스) → eviction 우선순위 최고. +- startup budget = 10s × 30 = 300s, Keycloak cold boot p99 덮음. +- management port 9000에만 health, HTTP 8080은 traffic 전용. +- PDB maxUnavailable: 1로 3-node infinispan cluster 중 최소 2개 생존 보장. + +--- + +## 좋은 예시 3: 1.29+ native sidecar (log forwarder) + +```yaml +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: report-worker + namespace: reporting + labels: + app.kubernetes.io/name: report-worker + app.kubernetes.io/instance: report-worker-prod +spec: + replicas: 2 + selector: + matchLabels: + app.kubernetes.io/name: report-worker + app.kubernetes.io/instance: report-worker-prod + template: + metadata: + labels: + app.kubernetes.io/name: report-worker + app.kubernetes.io/instance: report-worker-prod + spec: + securityContext: + runAsNonRoot: true + runAsUser: 10001 + runAsGroup: 10001 + fsGroup: 10001 + seccompProfile: + type: RuntimeDefault + initContainers: + - name: schema-check + image: registry.example.com/tools/schema-check:1.2.0 + command: ["/bin/schema-check", "--fail-fast"] + resources: + requests: { cpu: 50m, memory: 64Mi } + limits: { memory: 128Mi } + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + runAsNonRoot: true + capabilities: + drop: ["ALL"] + - name: log-forwarder + image: grafana/alloy:v1.2.0 + restartPolicy: Always # <- native sidecar (1.29+) + args: ["run", "/etc/alloy/config.alloy"] + resources: + requests: { cpu: 50m, memory: 128Mi } + limits: { memory: 256Mi } + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + runAsNonRoot: true + capabilities: + drop: ["ALL"] + volumeMounts: + - name: alloy-config + mountPath: /etc/alloy + - name: shared-logs + mountPath: /var/log/app + containers: + - name: worker + image: registry.example.com/reporting/worker:2.3.1 + resources: + requests: + cpu: "200m" + memory: "512Mi" + limits: + memory: "768Mi" + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + runAsNonRoot: true + capabilities: + drop: ["ALL"] + volumeMounts: + - name: shared-logs + mountPath: /var/log/app + - name: tmp + mountPath: /tmp + volumes: + - name: alloy-config + configMap: + name: alloy-config + - name: shared-logs + emptyDir: {} + - name: tmp + emptyDir: {} +``` + +**왜 좋은가:** + +- `restartPolicy: Always` on init container = native sidecar 패턴 (1.29+). +- init container 순서: schema-check 완료 → log-forwarder sidecar 시작 → main container. +- sidecar는 main 종료 후 SIGTERM 받음 (log flush 가능). + +--- + +## 나쁜 예시 1: CPU limit 기계적 설정 (throttling 유발) + +```yaml +resources: + requests: + cpu: "500m" + memory: "1Gi" + limits: + cpu: "500m" # BAD + memory: "1Gi" +``` + +**문제:** + +- Linux CFS quota가 100ms period 내 burst만으로도 throttle을 발생시킨다. +- p99 latency가 간헐적으로 튀어도 원인이 숨는다 (metric은 평균 usage 기준). +- Google SRE / Tim Hockin 공식 가이드: "대부분의 워크로드에서 CPU limit를 제거하라". + +**Fix:** CPU는 request만, memory만 limit로. + +--- + +## 나쁜 예시 2: liveness로 readiness 대신함 + +```yaml +livenessProbe: + httpGet: + path: /actuator/health # BAD - deep check + port: 8080 + periodSeconds: 5 + failureThreshold: 2 +# readiness 없음 +``` + +**문제:** + +- deep `/actuator/health`는 DB/외부 의존성 포함. DB blip → 모든 Pod 재시작 → cascading failure. +- 트래픽 수용 준비 상태를 표현할 수단이 없다. + +**Fix:** startup / readiness / liveness 세 축 분리. liveness는 `/health/live` 같은 shallow check. + +--- + +## 나쁜 예시 3: podAntiAffinity로 spread 시도 (legacy) + +```yaml +affinity: + podAntiAffinity: + requiredDuringSchedulingIgnoredDuringExecution: + - labelSelector: + matchExpressions: + - key: app + operator: In + values: ["auth-server"] + topologyKey: kubernetes.io/hostname +``` + +**문제:** + +- replica 수가 노드 수보다 많으면 스케줄 불가. +- zone 분산이 회계되지 않는다 (skew 개념 없음). +- maxSkew 튜닝 불가. + +**Fix:** topologySpreadConstraints 사용 (좋은 예시 1 참조). + +--- + +## 나쁜 예시 4: replica 1 서비스에 PDB + +```yaml +apiVersion: policy/v1 +kind: PodDisruptionBudget +spec: + minAvailable: 1 # BAD - with replica=1 + selector: + matchLabels: + app: singleton-app +``` + +**문제:** + +- node drain이 영구 블록된다 (`PDB violation`). +- Kubernetes 업그레이드가 불가능해진다. + +**Fix:** replica 1은 PDB 제거. 필요 시 replica 2+로 늘리고 PDB 적용. + +--- + +## 나쁜 예시 5: HPA v1 스타일 (behavior 없음) + +```yaml +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: auth-server + minReplicas: 1 + maxReplicas: 10 + metrics: + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: 80 +# behavior block 없음 +``` + +**문제:** + +- 기본 scale-down stabilization 300s지만 scale-up도 쓸데없이 보수적. +- 트래픽 burst에 대응 지연. +- 트래픽 drop 뒤 flapping 발생 가능 (policy 정의 없음). + +**Fix:** `behavior` block 필수 (좋은 예시 1 참조). + +--- + +## 나쁜 예시 6: limit만 있고 request 없음 + +```yaml +resources: + limits: + cpu: "1" + memory: "1Gi" +``` + +**문제:** + +- Kubernetes가 request = limit로 복사 → 암묵적 Guaranteed. +- 스케줄러 회계가 과대 평가되어 cluster density 저하. +- 의도한 QoS class와 다름. + +**Fix:** requests 명시 필수. diff --git a/docs/examples/infra/scripts.md b/docs/examples/infra/scripts.md new file mode 100644 index 0000000..f66a4b6 --- /dev/null +++ b/docs/examples/infra/scripts.md @@ -0,0 +1,602 @@ +# infra scripts 예시 + +--- + +## 좋은 예시 1: `scripts/lib/common.sh` (공통 라이브러리) + +```bash +#!/usr/bin/env bash +# common.sh - shared helpers. source this from bin/ scripts. +# do NOT execute directly. + +# shellcheck disable=SC2034 # variables may be used by callers +readonly COMMON_SH_LOADED=1 + +log() { + local level="$1"; shift + local ts + ts="$(date -u +'%Y-%m-%dT%H:%M:%SZ')" + printf '%s [%s] %s\n' "${ts}" "${level}" "$*" >&2 +} + +info() { log INFO "$@"; } +warn() { log WARN "$@"; } +error() { log ERROR "$@"; } +fatal() { log FATAL "$@"; exit 1; } + +require_cmd() { + local cmd="$1" + command -v "${cmd}" >/dev/null 2>&1 \ + || fatal "required command not found: ${cmd}" +} + +require_env() { + local name="$1" + local val="${!name:-}" + [[ -n "${val}" ]] || fatal "required env var not set: ${name}" +} + +confirm() { + # usage: confirm "delete namespace foo?" || return 1 + local prompt="${1:-continue?}" + if [[ "${CONFIRM:-no}" == "yes" || "${YES:-0}" -eq 1 ]]; then + return 0 + fi + local reply + printf '%s [y/N] ' "${prompt}" >&2 + read -r reply + [[ "${reply}" == "y" || "${reply}" == "Y" ]] +} + +mask_secrets() { + sed -E \ + -e 's/(password=)[^ ]+/\1***/g' \ + -e 's/(token=)[^ ]+/\1***/g' \ + -e 's/(Authorization: Bearer )[A-Za-z0-9._-]+/\1***/g' +} + +retry() { + local max="$1"; shift + local delay="$1"; shift + local n=0 + until "$@"; do + n=$((n + 1)) + if (( n >= max )); then + error "retry exhausted after ${max} attempts: $*" + return 1 + fi + warn "retry $n/$max failed, sleeping ${delay}s" + sleep "${delay}" + done +} +``` + +**왜 좋은가:** + +- log 함수가 ISO 8601 UTC + LEVEL + stderr. +- require_cmd / require_env / confirm / mask_secrets / retry 가 재사용 가능한 작은 단위. +- shellcheck suppression 은 이유 주석과 함께. + +--- + +## 좋은 예시 2: `scripts/bin/render-diff-apply` (render → diff → apply wrapper) + +```bash +#!/usr/bin/env bash +set -euo pipefail +IFS=$'\n\t' + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=../lib/common.sh +source "${SCRIPT_DIR}/../lib/common.sh" + +usage() { + cat <<'EOF' >&2 +Usage: render-diff-apply [OPTIONS] + + --overlay PATH kustomize overlay directory (required) + --context NAME kube context name (required) + --namespace NS target namespace (optional, derived from overlay) + --timeout DUR rollout status timeout (default: 10m) + --yes skip interactive confirmation for apply + --dry-run render + diff only, no apply + -h, --help show this help + +Environment: + CONFIRM=yes non-interactive confirmation (alternative to --yes) + +Examples: + render-diff-apply --overlay k8s/overlays/prod --context prod-eu + CONFIRM=yes render-diff-apply --overlay k8s/overlays/prod --context prod-eu --timeout 15m +EOF +} + +parse_args() { + OVERLAY="" + CONTEXT="" + NAMESPACE="" + TIMEOUT="10m" + YES=0 + DRY_RUN=0 + + while [[ $# -gt 0 ]]; do + case "$1" in + --overlay) OVERLAY="$2"; shift 2 ;; + --context) CONTEXT="$2"; shift 2 ;; + --namespace) NAMESPACE="$2"; shift 2 ;; + --timeout) TIMEOUT="$2"; shift 2 ;; + --yes) YES=1; shift ;; + --dry-run) DRY_RUN=1; shift ;; + -h|--help) usage; exit 0 ;; + *) usage; fatal "unknown arg: $1" ;; + esac + done + + [[ -n "${OVERLAY}" ]] || { usage; fatal "--overlay is required"; } + [[ -n "${CONTEXT}" ]] || { usage; fatal "--context is required"; } + [[ -d "${OVERLAY}" ]] || fatal "overlay not found: ${OVERLAY}" +} + +kctx() { + kubectl --context="${CONTEXT}" "$@" +} + +render() { + local out="$1" + info "rendering ${OVERLAY}" + kubectl kustomize "${OVERLAY}" > "${out}" + info "rendered $(wc -l < "${out}") lines to ${out}" +} + +validate() { + local rendered="$1" + info "server-side dry-run validation" + kctx apply -f "${rendered}" --dry-run=server >/dev/null +} + +show_diff() { + info "computing diff" + # kubectl diff exit code: 0 no diff, 1 diff, >1 error + set +e + kctx diff -k "${OVERLAY}" + local rc=$? + set -e + case "${rc}" in + 0) info "no diff" ;; + 1) info "diff present" ;; + *) fatal "diff failed with code ${rc}" ;; + esac + return "${rc}" +} + +apply_overlay() { + info "applying ${OVERLAY} to context=${CONTEXT}" + kctx apply -k "${OVERLAY}" +} + +watch_rollout() { + [[ -n "${NAMESPACE}" ]] || return 0 + local deployments + deployments="$(kctx -n "${NAMESPACE}" get deploy -o jsonpath='{.items[*].metadata.name}' || true)" + for d in ${deployments}; do + info "rollout status: deployment/${d}" + retry 3 5 kctx -n "${NAMESPACE}" rollout status "deployment/${d}" --timeout="${TIMEOUT}" + done +} + +main() { + parse_args "$@" + require_cmd kubectl + require_cmd kustomize + + TMPDIR="$(mktemp -d)" + trap 'rm -rf "${TMPDIR}"' EXIT INT TERM + + local rendered="${TMPDIR}/rendered.yaml" + render "${rendered}" + validate "${rendered}" + + local diff_rc=0 + show_diff || diff_rc=$? + + if (( DRY_RUN == 1 )); then + info "dry-run mode: skipping apply" + exit 0 + fi + + if (( diff_rc == 0 )); then + info "no changes, nothing to apply" + exit 0 + fi + + if (( YES != 1 )) && [[ "${CONFIRM:-no}" != "yes" ]]; then + confirm "apply changes to context=${CONTEXT} overlay=${OVERLAY}?" \ + || fatal "aborted by user" + fi + + apply_overlay + watch_rollout + info "done" +} + +main "$@" +``` + +**왜 좋은가:** + +- strict mode + trap + usage + main "$@" + log 전부 포함. +- `--yes` / `CONFIRM=yes` 이중 gate. +- `--dry-run=server` validation 이 apply 전 필수. +- `kubectl diff` 의 exit code (0/1/>1) 정확히 분기. +- retry 함수로 rollout status 불안정성 흡수. +- secret 을 argv / 로그에 쓰지 않음. +- jsonpath 로 deployment 목록 파싱, regex 없음. + +--- + +## 좋은 예시 3: `scripts/bin/backup-k3s` (etcd snapshot backup, destructive-aware) + +```bash +#!/usr/bin/env bash +set -euo pipefail +IFS=$'\n\t' + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=../lib/common.sh +source "${SCRIPT_DIR}/../lib/common.sh" + +usage() { + cat <<'EOF' >&2 +Usage: backup-k3s [OPTIONS] + + --node HOST server node to snapshot on (required) + --s3-endpoint URL S3 endpoint for offsite copy (optional) + --retention N days to keep local snapshots (default: 7) + -h, --help show this help + +Environment: + SSH_USER ssh user (default: current user) + S3_ACCESS_KEY required if --s3-endpoint is set + S3_SECRET_KEY required if --s3-endpoint is set +EOF +} + +main() { + local NODE="" S3_ENDPOINT="" RETENTION=7 + while [[ $# -gt 0 ]]; do + case "$1" in + --node) NODE="$2"; shift 2 ;; + --s3-endpoint) S3_ENDPOINT="$2"; shift 2 ;; + --retention) RETENTION="$2"; shift 2 ;; + -h|--help) usage; exit 0 ;; + *) usage; fatal "unknown arg: $1" ;; + esac + done + + [[ -n "${NODE}" ]] || { usage; fatal "--node required"; } + require_cmd ssh + + if [[ -n "${S3_ENDPOINT}" ]]; then + require_env S3_ACCESS_KEY + require_env S3_SECRET_KEY + fi + + local ts + ts="$(date -u +'%Y%m%dT%H%M%SZ')" + local snap="k3s-snapshot-${ts}.db" + + info "creating snapshot on node=${NODE}" + ssh "${SSH_USER:-$USER}@${NODE}" \ + "sudo k3s etcd-snapshot save --name ${snap}" + + info "pruning snapshots older than ${RETENTION} days on ${NODE}" + ssh "${SSH_USER:-$USER}@${NODE}" \ + "sudo find /var/lib/rancher/k3s/server/db/snapshots -name 'k3s-snapshot-*.db' -mtime +${RETENTION} -print -delete" + + if [[ -n "${S3_ENDPOINT}" ]]; then + info "uploading ${snap} to ${S3_ENDPOINT} (credentials masked)" + # secret 은 env 로 mc 에 전달, argv 노출 금지 + ssh "${SSH_USER:-$USER}@${NODE}" \ + "S3_ACCESS_KEY='${S3_ACCESS_KEY}' S3_SECRET_KEY='${S3_SECRET_KEY}' \ + mc alias set backup ${S3_ENDPOINT} \"\${S3_ACCESS_KEY}\" \"\${S3_SECRET_KEY}\" 2>&1 | mask-secrets || true && \ + mc cp /var/lib/rancher/k3s/server/db/snapshots/${snap} backup/k3s-snapshots/${snap}" + fi + + info "backup complete: ${snap}" +} + +main "$@" +``` + +**왜 좋은가:** + +- backup 은 destructive 가 아니므로 `--yes` 는 없지만, prune 은 retention 일수로 guard. +- secret 은 argv 로 전달 X, env 로 ssh 내부에서만. +- ISO 8601 UTC timestamp 로 이름 충돌 방지. +- require_env 로 credential 선검증. + +--- + +## 좋은 예시 4: destructive 스크립트 예시 (`scripts/bin/delete-namespace`) + +```bash +#!/usr/bin/env bash +set -euo pipefail +IFS=$'\n\t' + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=../lib/common.sh +source "${SCRIPT_DIR}/../lib/common.sh" + +usage() { + cat <<'EOF' >&2 +Usage: delete-namespace --context CTX --namespace NS [--yes] + + DANGER: this deletes the namespace and all its resources (including PVCs + if reclaimPolicy=Delete). Requires --yes or CONFIRM=yes. +EOF +} + +main() { + local CONTEXT="" NS="" YES=0 + while [[ $# -gt 0 ]]; do + case "$1" in + --context) CONTEXT="$2"; shift 2 ;; + --namespace) NS="$2"; shift 2 ;; + --yes) YES=1; shift ;; + -h|--help) usage; exit 0 ;; + *) usage; fatal "unknown arg: $1" ;; + esac + done + [[ -n "${CONTEXT}" ]] || { usage; fatal "--context required"; } + [[ -n "${NS}" ]] || { usage; fatal "--namespace required"; } + require_cmd kubectl + + if (( YES != 1 )) && [[ "${CONFIRM:-no}" != "yes" ]]; then + usage + fatal "destructive op requires --yes or CONFIRM=yes" + fi + + warn "will DELETE namespace=${NS} in context=${CONTEXT}" + local pvc_count + pvc_count="$(kubectl --context="${CONTEXT}" -n "${NS}" get pvc -o json | jq '.items | length')" + warn "PVC count in namespace: ${pvc_count}" + + kubectl --context="${CONTEXT}" delete namespace "${NS}" --wait=true + info "deleted namespace=${NS}" +} + +main "$@" +``` + +**왜 좋은가:** + +- destructive op 는 `--yes` / `CONFIRM=yes` 이중 gate. +- 삭제 전 PVC 수를 jq 로 보여줌 (사용자 자각). +- `--wait=true` 로 실제 삭제 완료 확인. +- JSON 파싱은 jq, regex 없음. + +--- + +## 좋은 예시 5: local 선언과 command substitution 분리 + +```bash +get_current_context() { + local ctx + ctx="$(kubectl config current-context)" # 분리 + printf '%s\n' "${ctx}" +} +``` + +**왜 좋은가:** + +- ShellCheck SC2155: `local ctx="$(...)"` 는 `local` 의 exit status 가 cmd substitution 을 가리므로 에러가 숨는다. +- 분리해야 `$?` 가 실제 kubectl 결과 반영. + +--- + +## 좋은 예시 6: JSON 파싱 + +```bash +# jsonpath +get_image() { + local ns="$1" deploy="$2" + kubectl -n "${ns}" get deploy "${deploy}" \ + -o jsonpath='{.spec.template.spec.containers[0].image}' +} + +# jq +get_all_images() { + local ns="$1" + kubectl -n "${ns}" get pods -o json \ + | jq -r '.items[].spec.containers[].image' \ + | sort -u +} +``` + +**왜 좋은가:** + +- jsonpath / jq 는 구조적 파싱 → field 순서나 formatting 변화에 내성. + +--- + +## 좋은 예시 7: secret masking 적용 예 + +```bash +deploy_with_debug() { + local overlay="$1" + + if [[ "${DEBUG:-0}" -eq 1 ]]; then + set -x + fi + + kubectl apply -k "${overlay}" 2>&1 | mask_secrets + + if [[ "${DEBUG:-0}" -eq 1 ]]; then + set +x + fi +} +``` + +**왜 좋은가:** + +- debug 시에도 stdout/stderr 에 secret 이 새지 않음. +- mask_secrets 가 common lib 에서 재사용. + +--- + +## 나쁜 예시 1: strict mode 없음 + +```bash +#!/bin/bash +# strict mode 없음 +TMP=/tmp/foo +rm -rf $TMP +mkdir $TMP +some_command +# 실패해도 계속 진행 +``` + +**문제:** + +- 실패가 조용히 통과 (`set -e` 없음). +- unset variable 에서 빈 경로로 rm → 재앙 가능. +- unquoted `$TMP` 공백 split. + +**Fix:** `set -euo pipefail` + `IFS=$'\n\t'` + trap + quote. + +--- + +## 나쁜 예시 2: heredoc YAML 생성기 + +```bash +deploy_auth() { + cat < /tmp/auth.yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: auth-server +spec: + replicas: ${REPLICAS} + template: + spec: + containers: + - name: auth + image: auth:${VERSION} +EOF + kubectl apply -f /tmp/auth.yaml +} +``` + +**문제:** + +- 선언형 원본이 스크립트 안에 숨음. +- Git diff 로 환경별 차이 추적 불가. +- 리뷰 / audit / kustomize 기능 모두 상실. + +**Fix:** Kustomize overlay → `kubectl apply -k`. + +--- + +## 나쁜 예시 3: regex 로 kubectl 출력 파싱 + +```bash +kubectl get pods | grep Running | awk '{print $1}' +``` + +**문제:** + +- column 순서나 추가 field 변화에 깨짐. +- `Running` 이 pod 이름에 포함되면 오인식. + +**Fix:** + +```bash +kubectl get pods --field-selector=status.phase=Running -o jsonpath='{.items[*].metadata.name}' +``` + +--- + +## 나쁜 예시 4: secret 을 argv 로 전달 + +```bash +mc alias set backup https://s3.example.com "${ACCESS}" "${SECRET}" +# ps aux 에 노출, history 에 기록 +``` + +**문제:** + +- `ps` 나 audit log 에서 credential 유출. +- bash history (`HISTFILE`) 에 기록 가능. + +**Fix:** + +```bash +mc alias set backup https://s3.example.com \ + "$(echo "${ACCESS}")" "$(cat /run/secrets/s3-secret)" +# 또는 환경변수로 mc 가 직접 읽도록 +MC_HOST_backup="https://${ACCESS}:${SECRET}@s3.example.com" mc cp ... +``` + +--- + +## 나쁜 예시 5: confirmation 없는 destructive + +```bash +#!/usr/bin/env bash +kubectl delete ns prod +``` + +**문제:** + +- 의도 / 권한 / audit 전혀 없음. +- 사고 직결. + +**Fix:** 좋은 예시 4 참조 (`--yes` / `CONFIRM=yes` gate + 사전 정보 표시). + +--- + +## 나쁜 예시 6: trap 없이 임시파일 + +```bash +TMP="$(mktemp)" +do_something > "${TMP}" +# 실패 시 /tmp 에 쓰레기 남음 +rm "${TMP}" +``` + +**문제:** + +- 스크립트 실패 / Ctrl-C 시 임시 파일 누적. +- secret 이 들어있으면 유출. + +**Fix:** + +```bash +TMP="$(mktemp)" +trap 'rm -f "${TMP}"' EXIT INT TERM +do_something > "${TMP}" +``` + +--- + +## 나쁜 예시 7: `local` 과 command substitution 한 줄 + +```bash +bad() { + local ctx="$(kubectl config current-context)" # $? 가려짐 +} +``` + +**문제:** + +- ShellCheck SC2155. `local` 의 exit status 가 cmd substitution 을 덮어 에러 감지 실패. + +**Fix:** + +```bash +good() { + local ctx + ctx="$(kubectl config current-context)" +} +``` diff --git a/docs/examples/infra/security-hardening.md b/docs/examples/infra/security-hardening.md new file mode 100644 index 0000000..21479d6 --- /dev/null +++ b/docs/examples/infra/security-hardening.md @@ -0,0 +1,528 @@ +# security hardening 예시 + +이 문서의 모든 YAML은 `kubectl apply --dry-run=server -f -` 기준 clean을 목표로 한다. 1000+ 서비스 운영 클러스터의 auth-server namespace를 기준 예시로 사용한다. + +--- + +## 좋은 예시 1: Namespace에 Pod Security Admission 라벨 enforce + +```yaml +apiVersion: v1 +kind: Namespace +metadata: + name: auth-prod + labels: + app.kubernetes.io/part-of: identity-platform + pod-security.kubernetes.io/enforce: restricted + pod-security.kubernetes.io/enforce-version: v1.29 + pod-security.kubernetes.io/audit: restricted + pod-security.kubernetes.io/audit-version: v1.29 + pod-security.kubernetes.io/warn: restricted + pod-security.kubernetes.io/warn-version: v1.29 + annotations: + platform.example.com/owner: identity-team + platform.example.com/adr: ADR-0017-psa-restricted-baseline +``` + +**왜 좋은가:** + +- 운영 namespace의 PSA 기본값을 `restricted`로 enforce. violation Pod는 API server 단에서 reject된다. +- version을 pin해 Kubernetes 업그레이드 시 silent behavior drift를 방지한다. +- audit/warn을 함께 붙여 위반을 audit log와 kubectl warning으로 수집한다. + +--- + +## 좋은 예시 2: Restricted 프로파일을 완전히 만족하는 Deployment + +```yaml +apiVersion: v1 +kind: ServiceAccount +metadata: + name: auth-server + namespace: auth-prod + labels: + app.kubernetes.io/name: auth-server + app.kubernetes.io/instance: auth-server + app.kubernetes.io/component: api + app.kubernetes.io/part-of: identity-platform + app.kubernetes.io/managed-by: argocd +automountServiceAccountToken: false +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: auth-server + namespace: auth-prod + labels: + app.kubernetes.io/name: auth-server + app.kubernetes.io/instance: auth-server + app.kubernetes.io/component: api + app.kubernetes.io/part-of: identity-platform + app.kubernetes.io/version: 1.42.0 + app.kubernetes.io/managed-by: argocd +spec: + replicas: 6 + revisionHistoryLimit: 5 + strategy: + type: RollingUpdate + rollingUpdate: + maxSurge: 25% + maxUnavailable: 0 + selector: + matchLabels: + app.kubernetes.io/name: auth-server + app.kubernetes.io/instance: auth-server + template: + metadata: + labels: + app.kubernetes.io/name: auth-server + app.kubernetes.io/instance: auth-server + app.kubernetes.io/component: api + app.kubernetes.io/part-of: identity-platform + app.kubernetes.io/version: 1.42.0 + app.kubernetes.io/managed-by: argocd + spec: + serviceAccountName: auth-server + automountServiceAccountToken: false + terminationGracePeriodSeconds: 30 + securityContext: + runAsNonRoot: true + runAsUser: 10001 + runAsGroup: 10001 + fsGroup: 10001 + fsGroupChangePolicy: OnRootMismatch + seccompProfile: + type: RuntimeDefault + topologySpreadConstraints: + - maxSkew: 1 + topologyKey: topology.kubernetes.io/zone + whenUnsatisfiable: ScheduleAnyway + labelSelector: + matchLabels: + app.kubernetes.io/name: auth-server + containers: + - name: auth-server + image: registry.example.com/identity/auth-server@sha256:8f3c0a8c6b3a2a7a0f1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f6071 + imagePullPolicy: IfNotPresent + ports: + - name: http + containerPort: 8080 + protocol: TCP + - name: metrics + containerPort: 9090 + protocol: TCP + env: + - name: JAVA_TOOL_OPTIONS + value: "-XX:MaxRAMPercentage=75 -XX:+ExitOnOutOfMemoryError" + envFrom: + - secretRef: + name: auth-server-db + resources: + requests: + cpu: 200m + memory: 512Mi + limits: + cpu: "2" + memory: 1Gi + readinessProbe: + httpGet: + path: /actuator/health/readiness + port: http + initialDelaySeconds: 5 + periodSeconds: 5 + failureThreshold: 3 + livenessProbe: + httpGet: + path: /actuator/health/liveness + port: http + initialDelaySeconds: 30 + periodSeconds: 10 + failureThreshold: 5 + startupProbe: + httpGet: + path: /actuator/health/liveness + port: http + failureThreshold: 30 + periodSeconds: 5 + securityContext: + runAsNonRoot: true + runAsUser: 10001 + runAsGroup: 10001 + allowPrivilegeEscalation: false + privileged: false + readOnlyRootFilesystem: true + capabilities: + drop: ["ALL"] + seccompProfile: + type: RuntimeDefault + volumeMounts: + - name: tmp + mountPath: /tmp + - name: workdir + mountPath: /workspace + volumes: + - name: tmp + emptyDir: + medium: Memory + sizeLimit: 64Mi + - name: workdir + emptyDir: + sizeLimit: 256Mi + imagePullSecrets: + - name: registry-example-com +``` + +**왜 좋은가:** + +- `restricted` 프로파일의 전 필드(runAsNonRoot, numeric UID/GID, fsGroup, seccompProfile, allowPrivilegeEscalation, readOnlyRootFilesystem, drop ALL capabilities)를 Pod+컨테이너 양쪽에 일관 명시한다. +- image는 digest pin. mutable tag에 의존하지 않는다. +- ServiceAccount는 전용 SA + `automountServiceAccountToken: false`. +- writable 경로는 `emptyDir`로 분리해 root FS는 read-only 유지. + +--- + +## 나쁜 예시 1: Restricted 프로파일 위반 Pod + +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: auth-server + namespace: auth-prod +spec: + replicas: 1 + selector: + matchLabels: + app: auth-server + template: + metadata: + labels: + app: auth-server + spec: + containers: + - name: auth-server + image: auth-server:latest + securityContext: + privileged: true +``` + +**문제:** + +- `privileged: true`는 baseline조차 위반. PSA enforce=restricted namespace에서는 API server가 reject한다. +- `runAsNonRoot`, `allowPrivilegeEscalation`, `capabilities.drop`, `seccompProfile`, `readOnlyRootFilesystem` 전부 누락. +- image tag `latest`는 digest 고정 없이 rolling silently breaks. +- SA 미지정 → `default` SA가 토큰 자동 마운트. + +--- + +## 좋은 예시 3: Default-deny + DNS + Ingress + DB + Prometheus allow NetworkPolicy 세트 + +```yaml +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: default-deny-all + namespace: auth-prod +spec: + podSelector: {} + policyTypes: + - Ingress + - Egress +--- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: allow-dns-egress + namespace: auth-prod +spec: + podSelector: {} + policyTypes: + - Egress + egress: + - to: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: kube-system + podSelector: + matchLabels: + k8s-app: kube-dns + ports: + - protocol: UDP + port: 53 + - protocol: TCP + port: 53 +--- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: allow-from-ingress-traefik + namespace: auth-prod +spec: + podSelector: + matchLabels: + app.kubernetes.io/name: auth-server + policyTypes: + - Ingress + ingress: + - from: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: ingress-traefik + podSelector: + matchLabels: + app.kubernetes.io/name: traefik + ports: + - protocol: TCP + port: 8080 +--- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: allow-to-postgres + namespace: auth-prod +spec: + podSelector: + matchLabels: + app.kubernetes.io/name: auth-server + policyTypes: + - Egress + egress: + - to: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: data-prod + podSelector: + matchLabels: + app.kubernetes.io/name: postgres + app.kubernetes.io/instance: identity-postgres + ports: + - protocol: TCP + port: 5432 +--- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: allow-metrics-scrape-from-prometheus + namespace: auth-prod +spec: + podSelector: + matchLabels: + app.kubernetes.io/name: auth-server + policyTypes: + - Ingress + ingress: + - from: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: monitoring + podSelector: + matchLabels: + app.kubernetes.io/name: prometheus + ports: + - protocol: TCP + port: 9090 +``` + +**왜 좋은가:** + +- `namespaceSelector`와 `podSelector`가 **동일 `from` 엔트리** 안에 있으므로 AND(교집합): monitoring namespace 안의 Prometheus Pod만 9090 scrape 허용된다. +- default-deny + minimum allow 세트로 ingress/egress 모두 통제. +- DNS는 `kube-system`의 `k8s-app=kube-dns` Pod로 한정, egress 전체를 열지 않음. + +--- + +## 나쁜 예시 2: NetworkPolicy AND/OR 혼동 + +```yaml +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: broken-scrape + namespace: auth-prod +spec: + podSelector: + matchLabels: + app.kubernetes.io/name: auth-server + policyTypes: + - Ingress + ingress: + - from: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: monitoring + - podSelector: + matchLabels: + app.kubernetes.io/name: prometheus + ports: + - protocol: TCP + port: 9090 +``` + +**문제:** + +- `namespaceSelector`와 `podSelector`가 **별도 엔트리**(두 개의 `-`) → OR로 해석된다. +- 결과: ① monitoring namespace의 **모든 Pod**가 허용되고, ② `auth-prod` namespace의 label `app.kubernetes.io/name=prometheus`를 가진 **아무 Pod**도 허용된다. +- 의도했던 "monitoring의 Prometheus만 허용"이 아니라 훨씬 넓은 경로가 열린다. 실제 클러스터에서 NetworkPolicy 버그의 1순위. + +--- + +## 좋은 예시 4: Namespace-scoped RBAC (Role + RoleBinding) + +```yaml +apiVersion: v1 +kind: ServiceAccount +metadata: + name: auth-server-secret-rotator + namespace: auth-prod +automountServiceAccountToken: true +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: auth-server-secret-reader + namespace: auth-prod +rules: + - apiGroups: [""] + resources: ["secrets"] + resourceNames: + - auth-server-db + - auth-server-oidc-client + verbs: ["get"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: auth-server-secret-reader + namespace: auth-prod +subjects: + - kind: ServiceAccount + name: auth-server-secret-rotator + namespace: auth-prod +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: auth-server-secret-reader +``` + +**왜 좋은가:** + +- namespace 경계 안에서 특정 Secret 이름 2개만 `get`. `list`/`watch` 미부여. +- SA/Role/RoleBinding 모두 같은 namespace에 명시. `---`로 분리된 다중 리소스 문서. +- `system:masters`나 `cluster-admin` 같은 전능 role과 무관. + +--- + +## 나쁜 예시 3: cluster-admin ClusterRoleBinding 남용 + +```yaml +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: auth-server-admin +subjects: + - kind: ServiceAccount + name: auth-server + namespace: auth-prod +roleRef: + kind: ClusterRole + name: cluster-admin + apiGroup: rbac.authorization.k8s.io +``` + +**문제:** + +- 단일 SA가 모든 namespace의 모든 리소스(Secret, Node, CRD)를 수정할 수 있다. +- 앱 노드 1개가 compromise되면 전체 클러스터가 compromise된다. +- least privilege 원칙의 정반대. + +--- + +## 좋은 예시 5: Private registry ImagePullSecret + +```yaml +apiVersion: v1 +kind: Secret +metadata: + name: registry-example-com + namespace: auth-prod +type: kubernetes.io/dockerconfigjson +data: + .dockerconfigjson: eyJhdXRocyI6eyJyZWdpc3RyeS5leGFtcGxlLmNvbSI6eyJ1c2VybmFtZSI6ImNpLWJvdCIsInBhc3N3b3JkIjoiPFJFREFDVEVEPiIsImF1dGgiOiI8UkVEQUNURUQ+In19fQ== +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: auth-server + namespace: auth-prod +automountServiceAccountToken: false +imagePullSecrets: + - name: registry-example-com +``` + +**왜 좋은가:** + +- type이 `kubernetes.io/dockerconfigjson`으로 정확. kubelet이 이 포맷만 pull credential로 인식한다. +- SA에 `imagePullSecrets`를 묶어 Deployment 마다 반복 선언 불필요. +- 실제 운영에서는 이 Secret 자체도 VSO로 Vault → K8s로 sync(config-and-secrets 문서 참고). + +--- + +## 나쁜 예시 4: 정책 없는 운영 namespace + +```yaml +apiVersion: v1 +kind: Namespace +metadata: + name: auth-prod +``` + +**문제:** + +- PSA 라벨 없음 → `privileged` Pod도 통과. +- NetworkPolicy 없음 → ingress/egress 모두 allow-all. 침해 시 lateral movement 자유. +- ResourceQuota/LimitRange 없음 → 한 Deployment가 namespace CPU/memory 전부 점유 가능. +- 1000-서비스 운영에서 이런 namespace는 허용되지 않는다. + +--- + +## 좋은 예시 6: ResourceQuota + LimitRange 묶음 + +```yaml +apiVersion: v1 +kind: ResourceQuota +metadata: + name: auth-prod-quota + namespace: auth-prod +spec: + hard: + requests.cpu: "50" + requests.memory: 100Gi + limits.cpu: "100" + limits.memory: 200Gi + pods: "200" + services.loadbalancers: "0" + services.nodeports: "0" +--- +apiVersion: v1 +kind: LimitRange +metadata: + name: auth-prod-defaults + namespace: auth-prod +spec: + limits: + - type: Container + default: + cpu: 500m + memory: 512Mi + defaultRequest: + cpu: 100m + memory: 128Mi + max: + cpu: "4" + memory: 4Gi +``` + +**왜 좋은가:** + +- `services.loadbalancers=0`, `services.nodeports=0`으로 namespace 내 외부 노출 Service 생성을 금지(ingress 경유 강제). +- LimitRange로 컨테이너별 default request/limit을 보장해 limit 누락 Pod를 예방. diff --git a/docs/examples/infra/storage-pvc.md b/docs/examples/infra/storage-pvc.md new file mode 100644 index 0000000..5c3eb90 --- /dev/null +++ b/docs/examples/infra/storage-pvc.md @@ -0,0 +1,641 @@ +# storage / PVC 예시 + +모든 예시는 `kubectl apply -f` 로 바로 적용 가능한 완성 매니페스트다. +생략(`...`)이 있는 곳은 의도적으로 다른 문서로 위임한 부분이다. + +--- + +## 좋은 예시 1: 운영 StorageClass 표준 세트 (WaitForFirstConsumer + Retain) + +```yaml +--- +apiVersion: storage.k8s.io/v1 +kind: StorageClass +metadata: + name: fast-ssd-retain + labels: + app.kubernetes.io/part-of: platform-storage + storage.platform.io/tier: gold + annotations: + storage.platform.io/description: "prod stateful (DB, vault, object store). retain on PVC delete." +provisioner: driver.longhorn.io +parameters: + numberOfReplicas: "3" + staleReplicaTimeout: "30" + fsType: ext4 +reclaimPolicy: Retain +volumeBindingMode: WaitForFirstConsumer +allowVolumeExpansion: true +--- +apiVersion: storage.k8s.io/v1 +kind: StorageClass +metadata: + name: standard-delete + labels: + app.kubernetes.io/part-of: platform-storage + storage.platform.io/tier: silver + annotations: + storage.platform.io/description: "dev/test, ephemeral, rebuild-safe data. deletes on PVC removal." +provisioner: driver.longhorn.io +parameters: + numberOfReplicas: "2" + fsType: ext4 +reclaimPolicy: Delete +volumeBindingMode: WaitForFirstConsumer +allowVolumeExpansion: true +--- +apiVersion: storage.k8s.io/v1 +kind: StorageClass +metadata: + name: rwx-shared + labels: + app.kubernetes.io/part-of: platform-storage + storage.platform.io/tier: shared +provisioner: nfs.csi.k8s.io +parameters: + server: nfs.storage.svc.cluster.local + share: /exports/shared +reclaimPolicy: Retain +volumeBindingMode: Immediate # 네트워크 스토리지이고 topology 제약 없음 → 예외적으로 Immediate 허용 +allowVolumeExpansion: true +mountOptions: + - nfsvers=4.1 + - hard + - noatime +``` + +왜 좋은가: +- `volumeBindingMode: WaitForFirstConsumer` 기본, `Immediate`는 이유를 주석으로 명시 +- `reclaimPolicy`가 데이터 등급에 따라 다르게 선언됨 (Retain / Delete) +- `allowVolumeExpansion: true` 기본 +- 라벨/annotation으로 용도 구분 + +❌ 나쁜 예시 1: 기본값 의존 + Immediate 바인딩 + +```yaml +apiVersion: storage.k8s.io/v1 +kind: StorageClass +metadata: + name: default +provisioner: driver.longhorn.io +# reclaimPolicy 미지정 → 기본 Delete (운영 데이터도 삭제됨) +# volumeBindingMode 미지정 → 기본 Immediate (topology 충돌 유발) +# allowVolumeExpansion 미지정 → 확장 불가 +``` + +문제: +- `reclaimPolicy` 기본 `Delete`: 실수로 PVC를 지우면 PV와 데이터까지 사라진다 +- `volumeBindingMode` 기본 `Immediate`: Pod가 스케줄되지 못하는 zone/node에 PV가 붙을 수 있다 +- 확장 불가 + +--- + +## 좋은 예시 2: VolumeSnapshotClass를 StorageClass와 매칭 + +```yaml +--- +apiVersion: snapshot.storage.k8s.io/v1 +kind: VolumeSnapshotClass +metadata: + name: fast-ssd-snap-retain + labels: + app.kubernetes.io/part-of: platform-storage + velero.io/csi-volumesnapshot-class: "true" +driver: driver.longhorn.io +deletionPolicy: Retain +parameters: + type: bak + csi.storage.k8s.io/snapshotter-secret-name: longhorn-backup-secret + csi.storage.k8s.io/snapshotter-secret-namespace: longhorn-system +--- +apiVersion: snapshot.storage.k8s.io/v1 +kind: VolumeSnapshotClass +metadata: + name: standard-snap-delete + labels: + app.kubernetes.io/part-of: platform-storage +driver: driver.longhorn.io +deletionPolicy: Delete +``` + +왜 좋은가: +- snapshot class가 StorageClass 등급과 1:1 매칭 +- 운영 데이터용은 `deletionPolicy: Retain` +- Velero가 인식하도록 `velero.io/csi-volumesnapshot-class: "true"` 라벨 부여 + +--- + +## 좋은 예시 3: PostgreSQL StatefulSet + PVC retention Retain + +```yaml +--- +apiVersion: v1 +kind: Namespace +metadata: + name: data-prod + labels: + pod-security.kubernetes.io/enforce: restricted +--- +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: postgres + namespace: data-prod + labels: + app.kubernetes.io/name: postgres + app.kubernetes.io/instance: postgres-prod + app.kubernetes.io/component: database + app.kubernetes.io/part-of: auth-platform + app.kubernetes.io/managed-by: argocd + app.kubernetes.io/version: "16.4" +spec: + serviceName: postgres + replicas: 1 + persistentVolumeClaimRetentionPolicy: + whenDeleted: Retain + whenScaled: Retain + selector: + matchLabels: + app.kubernetes.io/name: postgres + app.kubernetes.io/instance: postgres-prod + template: + metadata: + labels: + app.kubernetes.io/name: postgres + app.kubernetes.io/instance: postgres-prod + app.kubernetes.io/component: database + spec: + securityContext: + runAsNonRoot: true + runAsUser: 999 + runAsGroup: 999 + fsGroup: 999 + fsGroupChangePolicy: OnRootMismatch + seccompProfile: + type: RuntimeDefault + containers: + - name: postgres + image: postgres@sha256:8a6b7c6f0e0b5e2b0e2b0e2b0e2b0e2b0e2b0e2b0e2b0e2b0e2b0e2b0e2b0e2b + imagePullPolicy: IfNotPresent + ports: + - name: pg + containerPort: 5432 + env: + - name: POSTGRES_DB + value: auth + - name: PGDATA + value: /var/lib/postgresql/data/pgdata + envFrom: + - secretRef: + name: postgres-credentials + resources: + requests: + cpu: "500m" + memory: "1Gi" + limits: + cpu: "2" + memory: "4Gi" + readinessProbe: + exec: + command: ["pg_isready", "-U", "postgres"] + initialDelaySeconds: 10 + periodSeconds: 5 + livenessProbe: + exec: + command: ["pg_isready", "-U", "postgres"] + initialDelaySeconds: 30 + periodSeconds: 10 + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: ["ALL"] + volumeMounts: + - name: data + mountPath: /var/lib/postgresql/data + - name: tmp + mountPath: /tmp + - name: run + mountPath: /var/run/postgresql + volumes: + - name: tmp + emptyDir: {} + - name: run + emptyDir: {} + volumeClaimTemplates: + - metadata: + name: data + labels: + app.kubernetes.io/name: postgres + app.kubernetes.io/instance: postgres-prod + backup.platform.io/tier: gold + spec: + accessModes: ["ReadWriteOnce"] + storageClassName: fast-ssd-retain + resources: + requests: + storage: 50Gi +``` + +왜 좋은가: +- `persistentVolumeClaimRetentionPolicy`가 명시적으로 `Retain` +- StorageClass `fast-ssd-retain`에 맞물리는 `RWO` +- `fsGroup` + `fsGroupChangePolicy` 설정 +- restricted PSA 준수 (runAsNonRoot, readOnlyRootFilesystem, capabilities drop all) +- `emptyDir`로 tmp/run 분리 (PVC 남발 방지) +- 라벨 full set + backup tier 라벨 + +--- + +## 좋은 예시 4: 단일 PVC Deployment (RWO, replicas 1) + +```yaml +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: minio-data + namespace: object-prod + labels: + app.kubernetes.io/name: minio + app.kubernetes.io/component: object-store + backup.platform.io/tier: gold +spec: + accessModes: ["ReadWriteOnce"] + storageClassName: fast-ssd-retain + resources: + requests: + storage: 500Gi +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: minio + namespace: object-prod + labels: + app.kubernetes.io/name: minio + app.kubernetes.io/instance: minio-prod + app.kubernetes.io/component: object-store + app.kubernetes.io/part-of: platform-storage +spec: + replicas: 1 + strategy: + type: Recreate # RWO 단일 PVC이므로 RollingUpdate 금지 + selector: + matchLabels: + app.kubernetes.io/name: minio + app.kubernetes.io/instance: minio-prod + template: + metadata: + labels: + app.kubernetes.io/name: minio + app.kubernetes.io/instance: minio-prod + spec: + securityContext: + runAsNonRoot: true + runAsUser: 1000 + fsGroup: 1000 + seccompProfile: + type: RuntimeDefault + containers: + - name: minio + image: quay.io/minio/minio@sha256:abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789 + args: ["server", "/data", "--console-address", ":9001"] + ports: + - {name: s3, containerPort: 9000} + - {name: console, containerPort: 9001} + envFrom: + - secretRef: + name: minio-root-credentials + resources: + requests: {cpu: "250m", memory: "512Mi"} + limits: {cpu: "2", memory: "4Gi"} + readinessProbe: + httpGet: {path: /minio/health/ready, port: s3} + periodSeconds: 5 + livenessProbe: + httpGet: {path: /minio/health/live, port: s3} + periodSeconds: 20 + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: ["ALL"] + volumeMounts: + - {name: data, mountPath: /data} + volumes: + - name: data + persistentVolumeClaim: + claimName: minio-data +``` + +왜 좋은가: +- StatefulSet 없이도 stable한 단일 writer 구성 +- `strategy: Recreate`로 RWO 충돌 방지 +- PVC와 StorageClass가 명시적으로 매칭 +- backup tier 라벨 → Velero selector와 연동 + +❌ 나쁜 예시 2: RWO에 RollingUpdate + replicas 2 + +```yaml +spec: + replicas: 2 + strategy: + type: RollingUpdate + template: + spec: + containers: + - volumeMounts: + - {name: data, mountPath: /data} + volumes: + - name: data + persistentVolumeClaim: + claimName: minio-data # RWO인데 두 Pod가 동시에 마운트 시도 +``` + +문제: +- RWO PVC를 두 Pod가 동시에 잡을 수 없어 신규 Pod가 영원히 Pending +- RollingUpdate가 old→new 전환 시 마운트 충돌 +- 해결: replicas=1 + Recreate, 또는 RWX, 또는 StatefulSet + +--- + +## 좋은 예시 5: separate PVC (정당한 수명/복구 단위 차이) + +```yaml +--- +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: archive-ledger + namespace: finance-prod + labels: + app.kubernetes.io/name: archive-ledger + app.kubernetes.io/instance: archive-ledger-prod +spec: + serviceName: archive-ledger + replicas: 3 + selector: + matchLabels: + app.kubernetes.io/name: archive-ledger + app.kubernetes.io/instance: archive-ledger-prod + template: + metadata: + labels: + app.kubernetes.io/name: archive-ledger + app.kubernetes.io/instance: archive-ledger-prod + spec: + securityContext: + runAsNonRoot: true + runAsUser: 10001 + runAsGroup: 10001 + fsGroup: 10001 + seccompProfile: + type: RuntimeDefault + containers: + - name: archive-ledger + image: registry.example.com/finance/archive-ledger@sha256:3fbc632167424a6d997e74f52b878d7cc478225cffac6bc977eedfe51c7f4e79 + ports: + - { name: http, containerPort: 8080 } + resources: + requests: { cpu: 500m, memory: 1Gi } + limits: { memory: 2Gi } + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: ["ALL"] + volumeMounts: + - { name: data, mountPath: /var/lib/ledger } + - { name: audit-archive, mountPath: /var/lib/ledger/audit } + volumeClaimTemplates: + - metadata: + name: data + labels: + backup.platform.io/tier: gold # 5분 RPO, 매일 snapshot + spec: + accessModes: ["ReadWriteOnce"] + storageClassName: fast-ssd-retain + resources: + requests: {storage: 500Gi} + - metadata: + name: audit-archive + labels: + backup.platform.io/tier: bronze # 24h RPO, 주 1회 snapshot, 7년 보존 + spec: + accessModes: ["ReadWriteOnce"] + storageClassName: archive-retain + resources: + requests: {storage: 2Ti} +``` + +왜 좋은가: +- data와 audit-archive의 RPO/retention이 다름 +- StorageClass도 다름 (SSD vs 아카이브) +- backup tier 라벨이 Velero schedule selector에 의해 다르게 잡힘 + +❌ 나쁜 예시 3: separate PVC 남발 + +```yaml +volumeClaimTemplates: + - {metadata: {name: logs}} + - {metadata: {name: tmp}} + - {metadata: {name: config-copy}} + - {metadata: {name: cache}} +``` + +문제: +- 로그/tmp/cache는 `emptyDir` 또는 stdout 대상 +- PVC 4개는 수명 구분 없이 쪼갠 것 — 운영 복잡도만 증가 +- snapshot/backup 단위가 파편화됨 + +--- + +## 좋은 예시 6: 파이프라인 전체 — PVC → Snapshot → Restore → Verify + +아래 6개 블록은 순서대로 `kubectl apply` 한다. + +### (1) PVC + +```yaml +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: app-data + namespace: app-prod + labels: + app.kubernetes.io/name: app + backup.platform.io/tier: gold +spec: + accessModes: ["ReadWriteOnce"] + storageClassName: fast-ssd-retain + resources: + requests: {storage: 20Gi} +``` + +### (2) VolumeSnapshotClass (전역 1회) + +```yaml +--- +apiVersion: snapshot.storage.k8s.io/v1 +kind: VolumeSnapshotClass +metadata: + name: fast-ssd-snap-retain + labels: + velero.io/csi-volumesnapshot-class: "true" +driver: driver.longhorn.io +deletionPolicy: Retain +``` + +### (3) On-demand VolumeSnapshot + +```yaml +--- +apiVersion: snapshot.storage.k8s.io/v1 +kind: VolumeSnapshot +metadata: + name: app-data-2026-04-16-pre-migration + namespace: app-prod + labels: + app.kubernetes.io/name: app + snapshot.platform.io/reason: pre-migration +spec: + volumeSnapshotClassName: fast-ssd-snap-retain + source: + persistentVolumeClaimName: app-data +``` + +### (4) Restore: snapshot을 소스로 하는 새 PVC + +```yaml +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: app-data-restored + namespace: app-prod +spec: + accessModes: ["ReadWriteOnce"] + storageClassName: fast-ssd-retain + resources: + requests: {storage: 20Gi} + dataSource: + name: app-data-2026-04-16-pre-migration + kind: VolumeSnapshot + apiGroup: snapshot.storage.k8s.io +``` + +### (5) Verify Job + +```yaml +--- +apiVersion: batch/v1 +kind: Job +metadata: + name: app-data-restore-verify + namespace: app-prod +spec: + backoffLimit: 0 + ttlSecondsAfterFinished: 3600 + template: + spec: + restartPolicy: Never + securityContext: + runAsNonRoot: true + runAsUser: 1000 + fsGroup: 1000 + containers: + - name: verify + image: busybox@sha256:3fbc632167424a6d997e74f52b878d7cc478225cffac6bc977eedfe51c7f4e79 + command: + - sh + - -c + - | + set -eu + test -d /data + COUNT=$(find /data -type f | wc -l) + echo "file_count=${COUNT}" + test "${COUNT}" -gt 0 + resources: + requests: { cpu: 50m, memory: 64Mi } + limits: { memory: 128Mi } + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: {drop: ["ALL"]} + volumeMounts: + - {name: data, mountPath: /data, readOnly: true} + volumes: + - name: data + persistentVolumeClaim: + claimName: app-data-restored +``` + +### (6) 최종 확인 + +```bash +kubectl -n app-prod get volumesnapshot,pvc,job +kubectl -n app-prod logs job/app-data-restore-verify +``` + +왜 좋은가: +- PVC → SnapshotClass → Snapshot → dataSource 기반 PVC restore → Job 검증의 end-to-end 흐름 +- `deletionPolicy: Retain`으로 snapshot을 실수로 삭제해도 PV는 남음 +- Job이 restricted PSA 준수, 이미지 digest 고정, `backoffLimit: 0` + +--- + +## 나쁜 예시 4: hostPath를 운영 PV로 사용 + +```yaml +apiVersion: v1 +kind: PersistentVolume +metadata: + name: pg-host +spec: + capacity: {storage: 50Gi} + accessModes: ["ReadWriteOnce"] + hostPath: + path: /data/postgres +``` + +문제: +- 노드 장애 = 데이터 손실 +- snapshot / expansion / 다중 노드 스케줄링 전부 불가 +- 운영 표준 아님 + +--- + +## 나쁜 예시 5: K3s local-path로 production Postgres + +```yaml +volumeClaimTemplates: + - metadata: {name: data} + spec: + accessModes: ["ReadWriteOnce"] + storageClassName: local-path # K3s default + resources: {requests: {storage: 100Gi}} +``` + +문제: +- local-path는 snapshot 미지원 → Velero CSI snapshot 불가 +- expansion 미지원 → 용량 부족 시 마이그레이션 필요 +- 노드 pin → 노드 장애 시 Postgres 복구 불가 +- 해결: Longhorn / OpenEBS / cloud CSI driver로 교체 + +--- + +## 나쁜 예시 6: StorageClass 생략 + +```yaml +spec: + accessModes: ["ReadWriteOnce"] + resources: {requests: {storage: 20Gi}} + # storageClassName 미지정 → 클러스터 default annotation 사용 +``` + +문제: +- 어떤 tier를 기대했는지 선언에서 드러나지 않음 +- 클러스터 default가 바뀌면 침묵적으로 다른 StorageClass로 바인딩 +- 환경 간 재현 불가 diff --git a/docs/examples/infra/vault.md b/docs/examples/infra/vault.md new file mode 100644 index 0000000..703eb77 --- /dev/null +++ b/docs/examples/infra/vault.md @@ -0,0 +1,777 @@ +# Vault 예시 + +Vault 1.17+ + Helm chart `hashicorp/vault` + VSO 0.8+ 기준. 모든 manifest는 `kubectl apply` 적용 가능한 완전한 형태다. + +--- + +## 좋은 예시 1: Helm values.yaml — HA Raft + auto-unseal + audit + +```yaml +# values/vault-prod.yaml +global: + enabled: true + tlsDisable: false + +injector: + enabled: true + replicas: 2 + resources: + requests: + cpu: 100m + memory: 128Mi + limits: + cpu: 500m + memory: 256Mi + +server: + image: + repository: hashicorp/vault + tag: "1.17.6" + + resources: + requests: + cpu: 250m + memory: 256Mi + limits: + cpu: "1" + memory: 512Mi + + extraEnvironmentVars: + VAULT_CACERT: /vault/tls/ca.crt + VAULT_TLSCERT: /vault/tls/tls.crt + VAULT_TLSKEY: /vault/tls/tls.key + AWS_REGION: ap-northeast-2 + + volumes: + - name: vault-tls + secret: + secretName: vault-tls + volumeMounts: + - name: vault-tls + mountPath: /vault/tls + readOnly: true + + serviceAccount: + create: true + name: vault + annotations: + eks.amazonaws.com/role-arn: arn:aws:iam::123456789012:role/vault-autounseal + + readinessProbe: + enabled: true + path: "/v1/sys/health?standbyok=true&perfstandbyok=true&uninitcode=204" + port: 8200 + scheme: HTTPS + failureThreshold: 2 + initialDelaySeconds: 5 + periodSeconds: 5 + timeoutSeconds: 3 + + livenessProbe: + enabled: true + path: "/v1/sys/health?standbyok=true&sealedcode=204&uninitcode=204" + port: 8200 + scheme: HTTPS + failureThreshold: 3 + initialDelaySeconds: 60 + periodSeconds: 30 + timeoutSeconds: 3 + + dataStorage: + enabled: true + size: 20Gi + storageClass: ebs-gp3 + accessMode: ReadWriteOnce + mountPath: /vault/data + + auditStorage: + enabled: true + size: 10Gi + storageClass: ebs-gp3 + accessMode: ReadWriteOnce + mountPath: /vault/audit + + service: + enabled: true + type: ClusterIP + port: 8200 + targetPort: 8200 + + ha: + enabled: true + replicas: 3 + apiAddr: "https://$(POD_IP):8200" + clusterAddr: "https://$(HOSTNAME).vault-internal:8201" + raft: + enabled: true + setNodeId: true + config: | + ui = true + + listener "tcp" { + address = "[::]:8200" + cluster_address = "[::]:8201" + tls_cert_file = "/vault/tls/tls.crt" + tls_key_file = "/vault/tls/tls.key" + tls_min_version = "tls13" + } + + storage "raft" { + path = "/vault/data" + + retry_join { + leader_api_addr = "https://vault-0.vault-internal:8200" + leader_ca_cert_file = "/vault/tls/ca.crt" + leader_client_cert_file = "/vault/tls/tls.crt" + leader_client_key_file = "/vault/tls/tls.key" + } + retry_join { + leader_api_addr = "https://vault-1.vault-internal:8200" + leader_ca_cert_file = "/vault/tls/ca.crt" + leader_client_cert_file = "/vault/tls/tls.crt" + leader_client_key_file = "/vault/tls/tls.key" + } + retry_join { + leader_api_addr = "https://vault-2.vault-internal:8200" + leader_ca_cert_file = "/vault/tls/ca.crt" + leader_client_cert_file = "/vault/tls/tls.crt" + leader_client_key_file = "/vault/tls/tls.key" + } + } + + seal "awskms" { + region = "ap-northeast-2" + kms_key_id = "alias/vault-autounseal" + } + + service_registration "kubernetes" {} + + telemetry { + prometheus_retention_time = "24h" + disable_hostname = true + } + + affinity: | + podAntiAffinity: + requiredDuringSchedulingIgnoredDuringExecution: + - labelSelector: + matchLabels: + app.kubernetes.io/name: vault + component: server + topologyKey: kubernetes.io/hostname + + topologySpreadConstraints: | + - maxSkew: 1 + topologyKey: topology.kubernetes.io/zone + whenUnsatisfiable: ScheduleAnyway + labelSelector: + matchLabels: + app.kubernetes.io/name: vault + component: server +``` + +**왜 좋은가:** + +- `ha.enabled=true` + `raft.enabled=true` + `raft.setNodeId=true` 3종 필수 플래그 +- listener와 storage raft stanza가 8200/8201 모두 바인드, `cluster_address` 명시 → peer replication 성립 +- `seal "awskms"`로 auto-unseal, Pod 재시작 시 수동 개입 불필요 +- `auditStorage.enabled=true` → audit 전용 PVC 분리 (dataStorage 오염 방지) +- IRSA(`eks.amazonaws.com/role-arn`)로 KMS 접근 권한 위임 (static IAM key 없음) + +--- + +## 나쁜 예시 1: chart 기본값 standalone + +```bash +helm install vault hashicorp/vault --namespace vault --create-namespace +``` + +**문제:** + +- 기본은 `standalone` + `file` storage → single pod, PVC 1개, HA 없음, snapshot restore로만 복구 +- Shamir 수동 unseal → pod 재시작마다 운영자 개입 +- audit device 미활성 → 감사 로그 없음 +- chart 문서 자체가 "not suitable for production"이라 명시 + +--- + +## 좋은 예시 2: Service — 8200 + 8201 둘 다 expose + +Helm chart가 자동 생성하지만, 수제 Service 예시: + +```yaml +--- +apiVersion: v1 +kind: Service +metadata: + name: vault + namespace: vault + labels: + app.kubernetes.io/name: vault + app.kubernetes.io/instance: vault-prod +spec: + type: ClusterIP + selector: + app.kubernetes.io/name: vault + component: server + ports: + - name: https + port: 8200 + targetPort: 8200 + protocol: TCP +--- +apiVersion: v1 +kind: Service +metadata: + name: vault-internal + namespace: vault + labels: + app.kubernetes.io/name: vault +spec: + type: ClusterIP + clusterIP: None + publishNotReadyAddresses: true + selector: + app.kubernetes.io/name: vault + component: server + ports: + - name: https + port: 8200 + targetPort: 8200 + - name: https-internal + port: 8201 + targetPort: 8201 +``` + +**왜 좋은가:** + +- `vault-internal` headless + `publishNotReadyAddresses: true` → Raft peer가 unseal 전에도 서로 발견 가능 +- **8201 포트 expose** → peer-to-peer Raft replication 성립 (누락 시 leader election 영구 실패) +- 사용자용 `vault` Service는 8200만 노출 + +--- + +## 나쁜 예시 2: 8201 누락 + +```yaml +spec: + ports: + - port: 8200 + targetPort: 8200 +``` + +**문제:** + +- Raft peer가 8201로 서로 통신해야 하는데 Service가 expose하지 않음 +- `vault operator raft list-peers`에서 follower가 리더로 못 붙음 +- 증상: 단일 노드만 unsealed, 나머지는 "storage: IO error" 로그 루프 + +--- + +## 좋은 예시 3: Kubernetes auth bootstrap + role + +```bash +# 1. Kubernetes auth method 활성화 +vault auth enable kubernetes + +# 2. Vault가 Kubernetes TokenReview API를 호출하기 위한 설정 +# (Vault Pod 내부에서 실행하거나 reviewer SA의 JWT를 주입) +vault write auth/kubernetes/config \ + token_reviewer_jwt="$(cat /var/run/secrets/kubernetes.io/serviceaccount/token)" \ + kubernetes_host="https://kubernetes.default.svc.cluster.local" \ + kubernetes_ca_cert=@/var/run/secrets/kubernetes.io/serviceaccount/ca.crt \ + disable_iss_validation=false + +# 3. Policy 생성 (auth-server가 읽을 수 있는 경로만) +vault policy write auth-server-read - <<'EOF' +path "kv/data/auth-server/*" { + capabilities = ["read"] +} +path "database/creds/auth-server" { + capabilities = ["read"] +} +EOF + +# 4. Role 생성 — 특정 SA + namespace에만 바인딩 +vault write auth/kubernetes/role/auth-server \ + bound_service_account_names=auth-server \ + bound_service_account_namespaces=auth-prod \ + policies=auth-server-read \ + ttl=1h \ + max_ttl=24h \ + audience=vault +``` + +**왜 좋은가:** + +- TokenReview JWT를 명시적으로 구성 → Vault가 SA 토큰 유효성 검증 가능 +- Policy는 `kv/data/auth-server/*`, `database/creds/auth-server`만 허용 (최소 권한) +- Role은 `auth-prod` namespace의 `auth-server` SA에만 바인딩 +- `audience=vault`로 projected token의 audience 검증 (token confusion 방어) + +--- + +## 나쁜 예시 3: wildcard role + +```bash +vault write auth/kubernetes/role/all-apps \ + bound_service_account_names="*" \ + bound_service_account_namespaces="*" \ + policies=default \ + ttl=720h +``` + +**문제:** + +- 모든 namespace의 모든 SA가 로그인 가능 → 한 워크로드 침해가 전체 Vault 접근으로 확대 +- TTL 30일은 token revocation window가 너무 김 +- `default` policy가 넓으면 실질적인 접근 제어 상실 + +--- + +## 좋은 예시 4: VSO — 클러스터 수준 연결 + 앱 namespace auth + +```yaml +--- +apiVersion: v1 +kind: Namespace +metadata: + name: vault-secrets-operator-system +--- +# 1) 클러스터 전체 1개 VaultConnection +apiVersion: secrets.hashicorp.com/v1beta1 +kind: VaultConnection +metadata: + name: default + namespace: vault-secrets-operator-system +spec: + address: https://vault.vault.svc.cluster.local:8200 + tlsServerName: vault.vault.svc.cluster.local + caCertSecretRef: vault-ca + skipTLSVerify: false + timeout: 60s +--- +# 2) 앱 namespace의 ServiceAccount +apiVersion: v1 +kind: ServiceAccount +metadata: + name: auth-server + namespace: auth-prod +--- +# 3) 앱 namespace의 VaultAuth (Vault Kubernetes auth role로 로그인) +apiVersion: secrets.hashicorp.com/v1beta1 +kind: VaultAuth +metadata: + name: default + namespace: auth-prod +spec: + vaultConnectionRef: vault-secrets-operator-system/default + method: kubernetes + mount: kubernetes + kubernetes: + role: auth-server + serviceAccount: auth-server + audiences: + - vault + tokenExpirationSeconds: 600 +``` + +**왜 좋은가:** + +- `VaultConnection` 1개를 operator namespace에 두고, 앱 namespace에서 cross-reference +- `VaultAuth.method: kubernetes`가 Vault의 `auth/kubernetes/role/auth-server`를 호출 +- `audiences: [vault]`로 projected SA token의 audience 바인딩 +- `tokenExpirationSeconds: 600` → projected token 10분마다 rotate + +--- + +## 좋은 예시 5: VSO — Static / Dynamic / PKI secret + +```yaml +--- +# KV v2에서 정적 secret 동기화 +apiVersion: secrets.hashicorp.com/v1beta1 +kind: VaultStaticSecret +metadata: + name: auth-server-config + namespace: auth-prod +spec: + vaultAuthRef: default + mount: kv + path: auth-server/config + type: kv-v2 + refreshAfter: 30m + hmacSecretData: true + rolloutRestartTargets: + - kind: Deployment + name: auth-server + destination: + name: auth-server-config + create: true + overwrite: true +--- +# Postgres dynamic credential (TTL 1h) +apiVersion: secrets.hashicorp.com/v1beta1 +kind: VaultDynamicSecret +metadata: + name: auth-server-db + namespace: auth-prod +spec: + vaultAuthRef: default + mount: database + path: creds/auth-server + renewalPercent: 67 + rolloutRestartTargets: + - kind: Deployment + name: auth-server + destination: + name: auth-server-db + create: true + overwrite: true + transformation: + excludeRaw: true + templates: + DATABASE_URL: + text: 'postgresql://{{ .Secrets.username }}:{{ .Secrets.password }}@auth-db-rw.auth-prod.svc.cluster.local:5432/authdb?sslmode=require' +--- +# PKI 인증서 발급 +apiVersion: secrets.hashicorp.com/v1beta1 +kind: VaultPKISecret +metadata: + name: auth-server-cert + namespace: auth-prod +spec: + vaultAuthRef: default + mount: pki_int + role: auth-server + commonName: auth-server.auth-prod.svc.cluster.local + altNames: + - auth-server + - auth-server.auth-prod + ipSans: [] + ttl: 720h + revoke: true + clear: true + expiryOffset: 120h + destination: + name: auth-server-cert + create: true + type: kubernetes.io/tls +``` + +**왜 좋은가:** + +- 세 패턴(정적 KV, 동적 DB credential, PKI cert)을 한 namespace에서 일관되게 선언 +- `rolloutRestartTargets`로 secret 갱신 시 consumer Deployment 자동 롤링 재시작 +- `renewalPercent: 67` → TTL 67% 경과 시 갱신 (default는 보통 70%) +- `transformation.templates`로 연결 문자열 포맷 변환 (앱이 username/password 파싱 안 해도 됨) + +--- + +## 좋은 예시 6: Vault Agent Injector (init-only 모드) + +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: legacy-app + namespace: legacy +spec: + replicas: 2 + selector: + matchLabels: + app: legacy-app + template: + metadata: + labels: + app: legacy-app + annotations: + vault.hashicorp.com/agent-inject: "true" + vault.hashicorp.com/role: "legacy-app" + vault.hashicorp.com/agent-pre-populate-only: "true" + vault.hashicorp.com/agent-inject-secret-db.env: "database/creds/legacy-app" + vault.hashicorp.com/agent-inject-template-db.env: | + {{ with secret "database/creds/legacy-app" -}} + DATABASE_USERNAME={{ .Data.username }} + DATABASE_PASSWORD={{ .Data.password }} + {{- end }} + vault.hashicorp.com/agent-inject-file-db.env: "db.env" + vault.hashicorp.com/agent-limits-cpu: "200m" + vault.hashicorp.com/agent-limits-mem: "128Mi" + vault.hashicorp.com/agent-requests-cpu: "50m" + vault.hashicorp.com/agent-requests-mem: "64Mi" + spec: + serviceAccountName: legacy-app + securityContext: + runAsNonRoot: true + runAsUser: 10001 + runAsGroup: 10001 + fsGroup: 10001 + seccompProfile: + type: RuntimeDefault + containers: + - name: app + image: registry.example.com/legacy-app:1.4.2 + command: ["sh", "-c", "source /vault/secrets/db.env && exec /app/run"] + resources: + requests: { cpu: 100m, memory: 128Mi } + limits: { memory: 256Mi } + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: ["ALL"] + volumeMounts: + - { name: tmp, mountPath: /tmp } + volumes: + - name: tmp + emptyDir: {} +``` + +**왜 좋은가:** + +- `agent-pre-populate-only: "true"` → init container만 돌고 sidecar 없음 → 2 pod 당 컨테이너 1개 절감 +- etcd에 Kubernetes Secret 생성 없음 (annotation에 명시적 destination 없음; in-memory volume) +- template로 `.env` 포맷 렌더링 → legacy 앱이 그대로 소비 + +--- + +## 나쁜 예시 4: Injector + long-lived sidecar + 무한 renew + +```yaml +annotations: + vault.hashicorp.com/agent-inject: "true" + vault.hashicorp.com/role: "legacy-app" + vault.hashicorp.com/agent-inject-secret-creds: "database/creds/legacy-app" + # agent-pre-populate-only 없음 → sidecar 상시 실행 + # agent-limits-* 없음 → sidecar가 limit 없이 메모리 증가 +``` + +**문제:** + +- sidecar가 Pod 수명 내내 상주 → 1000 서비스 x 3 replica = 3000 추가 컨테이너 +- resource limit 미지정 → OOM cascading +- VSO로 대체 가능한데 Injector를 default로 쓰면 운영 복잡도 증가 + +--- + +## 좋은 예시 7: Raft snapshot CronJob + +```yaml +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: vault-snapshot + namespace: vault +--- +apiVersion: batch/v1 +kind: CronJob +metadata: + name: vault-raft-snapshot + namespace: vault +spec: + schedule: "0 2 * * *" + concurrencyPolicy: Forbid + successfulJobsHistoryLimit: 3 + failedJobsHistoryLimit: 3 + jobTemplate: + spec: + backoffLimit: 2 + template: + spec: + restartPolicy: OnFailure + serviceAccountName: vault-snapshot + securityContext: + runAsNonRoot: true + runAsUser: 100 + runAsGroup: 1000 + fsGroup: 1000 + seccompProfile: + type: RuntimeDefault + containers: + - name: snapshot + image: hashicorp/vault:1.17.6 + env: + - name: VAULT_ADDR + value: https://vault.vault.svc.cluster.local:8200 + - name: VAULT_CACERT + value: /vault/tls/ca.crt + - name: VAULT_TOKEN + valueFrom: + secretKeyRef: + name: vault-snapshot-token + key: token + - name: AWS_REGION + value: ap-northeast-2 + command: + - sh + - -c + - | + set -eu + TS=$(date -u +%Y%m%dT%H%M%SZ) + SNAP=/tmp/vault-${TS}.snap + vault operator raft snapshot save "${SNAP}" + aws s3 cp "${SNAP}" "s3://vault-backup.example.com/daily/vault-${TS}.snap" \ + --sse aws:kms --sse-kms-key-id alias/vault-backup + rm -f "${SNAP}" + volumeMounts: + - name: vault-tls + mountPath: /vault/tls + readOnly: true + - name: tmp + mountPath: /tmp + resources: + requests: + cpu: 100m + memory: 128Mi + limits: + cpu: 500m + memory: 512Mi + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: ["ALL"] + volumes: + - name: vault-tls + secret: + secretName: vault-tls + - name: tmp + emptyDir: {} +``` + +**왜 좋은가:** + +- 매일 02:00 UTC `raft snapshot save` 실행 +- 결과를 S3 SSE-KMS로 off-cluster 보관 (PVC와 독립적 failure domain) +- 짧은 TTL snapshot token을 별도 Secret로 주입 (root token 미사용) +- `concurrencyPolicy: Forbid`로 snapshot 중복 방지 + +--- + +## 좋은 예시 8: ServiceMonitor + Prometheus policy + +```yaml +--- +# Vault policy: Prometheus가 /v1/sys/metrics 읽기 전용 +# (이 정책은 Vault 내부에 생성) +# vault policy write prometheus-metrics - <` + history limit. + +--- + +## 나쁜 예시 6: StatefulSet에 `persistentVolumeClaimRetentionPolicy` 미지정 (prod) + +```yaml +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: vault +spec: + serviceName: vault + replicas: 3 + # persistentVolumeClaimRetentionPolicy missing + volumeClaimTemplates: + - metadata: { name: data } + spec: + accessModes: [ "ReadWriteOnce" ] + storageClassName: longhorn-replicated + resources: + requests: { storage: 20Gi } +``` + +**문제:** 명시가 없으면 기본값 (`{whenDeleted: Retain, whenScaled: Retain}`)이 적용되어 "동작은 맞지만" 의도가 코드에 드러나지 않는다. 팀원이 `{Delete, Delete}`인지 추측. 1000-서비스 스케일에서는 모든 StatefulSet이 이 필드를 **명시**해야 정책이 audit 가능. 해결: 항상 명시. diff --git a/docs/ingress-traefik.md b/docs/ingress-traefik.md new file mode 100644 index 0000000..997d2f5 --- /dev/null +++ b/docs/ingress-traefik.md @@ -0,0 +1,248 @@ +# Ingress / Traefik 운영 + +dev 환경은 K3s packaged Traefik 을 그대로 유지한다. 단 **`/var/lib/rancher/k3s/server/manifests/traefik.yaml` 는 수정하지 않는다.** 운영 설정은 `k8s/overlays/dev/platform/traefik/` 의 `HelmChartConfig` 로만 오버라이드한다. + +## 현재 구성 + +| 위치 | 역할 | +|---|---| +| `k8s/overlays/dev/platform/traefik/helmchartconfig.yaml` | Traefik replica, 기본 ingressClass, HTTP→HTTPS redirect, metrics, 기본 TLS option 연결 | +| `k8s/overlays/dev/platform/traefik/middleware.yaml` | 공용 `security-headers` Middleware + `modern-tls` TLSOption | +| `k8s/overlays/dev/auth/ingress.yaml` | `project.com` → `auth-server` | +| `k8s/overlays/dev/keycloak/ingress-public.yaml` | `keycloak.dev.example.com` → Keycloak 공개 path (`/realms/`, `/resources/`, `/.well-known/`, `/js/`) | +| `k8s/overlays/dev/platform/cert-manager/` | cert-manager `v1.20.2` CRD/controller 설치 overlay | +| `k8s/overlays/dev/platform/cert-manager-issuers/` | `letsencrypt-staging` / `letsencrypt-prod` ClusterIssuer | +| `k8s/overlays/dev/platform/keycloak-operator/` | Keycloak Operator `26.6.1`. dev 제약상 `mnt` 에 설치해 `mnt` 의 Keycloak CR 을 watch. K8s API egress NetworkPolicy 포함 | +| `k8s/overlays/dev/tls/*.yaml` | cert-manager 설치 후 발급할 `Certificate` 리소스 | +| `k8s/components/forward-auth/` | oauth2-proxy + Traefik ForwardAuth 재사용 component | +| `k8s/overlays/dev/` | 기본 dev overlay. 현재 forward-auth component 를 직접 포함 | +| `k8s/overlays/dev/keycloak-realm/` | `KeycloakRealmImport` 로 realm/client 를 Git 관리 | + +## 설계 원칙 + +- 앱은 `Ingress` 만 선언하고, 공통 보안 정책은 Traefik Middleware / TLSOption 으로 재사용 +- Keycloak 은 외부 전체 공개가 아니라 **최소 공개 path** 만 연다. `/admin`, `/metrics`, `/health` 는 비공개 +- TLS 리소스는 cert-manager + ClusterIssuer 적용 후 `tls/` overlay 에서 발급 +- north-south ingress 는 `kube-system` 의 Traefik Pod 에서만 시작 → app NetworkPolicy 도 그에 맞춰 작성 + +## ForwardAuth variant + +`k8s/components/forward-auth/` 는 oauth2-proxy + ForwardAuth Middleware 를 담은 Kustomize component 다. 현재 `k8s/overlays/dev/` 가 이 component 를 직접 포함한다. + +구성: + +- `oauth2-proxy` Deployment / Service / ConfigMap / VaultStaticSecret +- `project.com/oauth2/*` 경로용 Ingress +- `oauth2-proxy-auth` Traefik Middleware +- `auth-server` Ingress patch — `project.com/` 요청은 oauth2-proxy 를 거친 인증된 사용자만 통과 + +흐름: `Traefik ForwardAuth → oauth2-proxy → Keycloak`. + +### 적용 전제 + +- `k8s/overlays/dev/keycloak-realm/` 또는 동등한 방법으로 `platform` realm + `auth-server-ingress` client 가 준비됨 +- redirect URI: `https://project.com/oauth2/callback` +- Vault path `secret/oauth2-proxy/forward-auth` 에 `client_secret`, `cookie_secret` 저장 +- `project.com`, `keycloak.dev.example.com` 이 실제 Traefik 진입점으로 해석됨 + +### 브라우저 접속 전제 + +curl 검증은 `--resolve project.com:443:` 와 `-k` 로 DNS/TLS 문제를 우회할 수 있다. 브라우저는 이 옵션이 없으므로 dev 환경에서 직접 접속하려면 운영자가 아래를 별도로 맞춰야 한다. + +```text + project.com + keycloak.dev.example.com +``` + +예: Traefik `LoadBalancer` IP 중 하나가 `10.208.141.123` 이면 로컬 `/etc/hosts` 에 두 host 를 추가한다. dev overlay 는 현재 외부 ACME 발급 대신 `dev-selfsigned` ClusterIssuer 를 사용하므로 브라우저에서는 인증서 경고를 허용하거나 해당 인증서를 로컬 trust store 에 등록해야 한다. 공인 DNS 가 Traefik 진입점으로 향하고 ACME 인증서가 Ready 가 되면 이 임시 조치는 제거한다. + +Chrome 에서 계속 실패하면 먼저 boundary 를 나눈다. + +| Boundary | 확인 | +|---|---| +| 로컬 DNS | `getent hosts project.com keycloak.dev.example.com` 이 Traefik IP 를 반환해야 한다. | +| 브라우저 DNS cache | `/etc/hosts` 수정 후 Chrome 재시작 또는 `chrome://net-internals/#dns` 에서 cache clear. | +| TLS trust | `ERR_CERT_*` 가 나오면 dev self-signed 인증서를 허용하거나 trust store 에 등록한다. | +| 인증 redirect | `curl -k -D - --resolve project.com:443: https://project.com/swagger-ui.html` 가 `302 Location: https://keycloak...` 를 반환해야 한다. | +| 로그인 후 app route | 인증 후 `404 PRES-005` 는 ForwardAuth 실패가 아니라 auth-server 에 해당 route 가 없다는 뜻이다. | + +### 브라우저 검증 순서 + +dev ForwardAuth 를 브라우저에서 직접 확인할 때는 아래 순서로 진행한다. 중간 단계를 건너뛰면 "Chrome 이 안 된다" 만 보이고 어느 boundary 가 깨졌는지 알기 어렵다. + +#### 1. Traefik 진입 IP 확인 + +```bash +kubectl -n kube-system get svc traefik \ + -o jsonpath='{.status.loadBalancer.ingress[*].ip}{"\n"}' +``` + +예상 예시: + +```text +10.208.141.123 10.208.141.14 +``` + +이 문서의 예시는 `10.208.141.123` 을 사용한다. 실제 클러스터에서 나온 IP 중 하나를 선택한다. + +#### 2. curl 로 클러스터 경로 먼저 확인 + +브라우저를 열기 전에 curl 로 Traefik / oauth2-proxy / Keycloak boundary 가 살아있는지 확인한다. + +```bash +curl -k -sS -L \ + -D /tmp/project-infra-login.headers \ + -o /tmp/project-infra-login.body \ + --resolve project.com:443:10.208.141.123 \ + --resolve keycloak.dev.example.com:443:10.208.141.123 \ + https://project.com/swagger-ui.html +``` + +정상 신호: + +```bash +sed -n '1,80p' /tmp/project-infra-login.headers +grep -o '[^<]*' /tmp/project-infra-login.body +``` + +정상이라면 헤더에는 첫 응답 `HTTP/2 302` 와 `location: https://keycloak.dev.example.com/.../auth` 가 보이고, body title 은 아래처럼 나온다. + +```text +<title>Sign in to platform +``` + +이 단계가 실패하면 브라우저를 볼 필요가 없다. 먼저 `docs/troubleshooting.md` 의 `ForwardAuth 로그인 E2E 검증 실패` 사건에서 해당 boundary 를 찾는다. + +#### 3. 빠른 Chrome 임시 프로필로 확인 + +로컬 `/etc/hosts` 와 인증서 trust 를 건드리기 전에, Chrome 실행 옵션으로 DNS/TLS 를 임시 우회해 본다. + +```bash +google-chrome \ + --user-data-dir=/tmp/project-infra-chrome \ + --ignore-certificate-errors \ + --host-resolver-rules="MAP project.com 10.208.141.123, MAP keycloak.dev.example.com 10.208.141.123" \ + https://project.com/swagger-ui.html +``` + +정상 흐름: + +1. `https://project.com/swagger-ui.html` 접속 +2. Traefik ForwardAuth 가 미인증 요청을 감지 +3. `302` 로 Keycloak 로그인 화면 이동 +4. `Sign in to platform` 화면 표시 +5. 로그인 성공 후 `project.com` 으로 callback + +이 방식으로 성공하면 Kubernetes / Traefik / oauth2-proxy / Keycloak 경로는 정상이다. 평소 Chrome 에서 안 되는 원인은 로컬 DNS cache, `/etc/hosts`, 인증서 trust, 기존 쿠키 중 하나다. + +#### 4. 일반 Chrome 으로 볼 수 있게 hosts 등록 + +임시 Chrome 이 성공하면 로컬 OS resolver 를 맞춘다. + +```bash +sudo tee -a /etc/hosts >/dev/null <<'EOF' + +# Project-Infra dev ingress +10.208.141.123 project.com +10.208.141.123 keycloak.dev.example.com +EOF +``` + +확인: + +```bash +getent hosts project.com keycloak.dev.example.com +``` + +두 host 가 선택한 Traefik IP 를 반환해야 한다. + +#### 5. Chrome DNS cache / 기존 세션 정리 + +hosts 를 바꾼 뒤에도 Chrome 이 이전 DNS / 쿠키를 들고 있을 수 있다. + +권장 순서: + +1. `chrome://net-internals/#dns` 에서 DNS cache clear +2. `chrome://net-internals/#sockets` 에서 socket pools flush +3. `project.com`, `keycloak.dev.example.com` 사이트 데이터 삭제 +4. Chrome 완전 종료 후 재시작 + +그래도 헷갈리면 아래처럼 새 임시 프로필을 쓰는 게 가장 빠르다. + +```bash +google-chrome --user-data-dir=/tmp/project-infra-normal https://project.com/swagger-ui.html +``` + +#### 6. 인증서 경고 처리 + +dev overlay 는 현재 `dev-selfsigned` ClusterIssuer 로 TLS Secret 을 만든다. 따라서 일반 Chrome 에서는 인증서 경고가 뜰 수 있다. + +검증 목적이면 고급 옵션에서 예외를 허용한다. 장기적으로 반복 검증할 예정이면 `project-com-tls`, `keycloak-dev-example-com-tls` 인증서를 로컬 trust store 에 등록한다. + +이 경고는 dev self-signed 인증서 때문에 생기는 것으로, ForwardAuth 실패와는 다른 boundary 다. + +#### 7. 로그인 후 결과 해석 + +로그인 후 `swagger-ui.html` 이 열리면 브라우저 검증은 성공이다. + +로그인 후 `/api/me` 를 열어 `404 PRES-005` 가 나오면 이것도 ForwardAuth 실패가 아니다. 인증은 통과했고 auth-server 애플리케이션에 `/api/me` route 가 없다는 뜻이다. + +판단 기준: + +| 결과 | 의미 | +|---|---| +| Keycloak 로그인 화면이 뜸 | 미인증 redirect 정상 | +| 로그인 후 `project.com` 으로 돌아옴 | callback / token exchange / session cookie 정상 | +| `/oauth2/auth` 가 `202` | oauth2-proxy 세션 인증 정상 | +| auth-server 가 `404 PRES-005` 반환 | 인증 통과 후 application route 없음 | +| auth-server 가 `401` 반환 | Authorization header 또는 JWT validation boundary 문제 | + +### 인증 실패 처리 + +Traefik ForwardAuth 는 `/oauth2/auth` 를 호출한다. oauth2-proxy 가 `401` 또는 `403` 을 반환하면 `oauth2-proxy-errors` Middleware 가 `/oauth2/start?rd={url}` 로 넘겨 로그인 흐름을 시작한다. + +중요: Traefik errors middleware 는 기본적으로 원래 status code 를 유지할 수 있다. 그러면 oauth2-proxy 가 `Location` 을 내려도 브라우저는 `401` 응답을 자동 redirect 로 처리하지 않는다. dev 구성은 `statusRewrites` 로 `401`/`403` 을 `302` 로 바꿔 브라우저가 바로 Keycloak 로그인 화면으로 이동하게 한다. + +## cert-manager / ClusterIssuer + +repo 에 `k8s/overlays/dev/platform/cert-manager/` 와 `k8s/overlays/dev/platform/cert-manager-issuers/` 가 추가되어 있다. + +- 설치 overlay: 공식 static install `v1.20.2` +- issuer overlay: ACME HTTP-01 용 `letsencrypt-staging` / `letsencrypt-prod` + +source-of-truth 관점에서 cert-manager 도 이 repo 의 선언형 관리 대상. 단, **실제 인증서 발급은 DNS 가 Traefik 외부 진입점을 가리키고 80/443 도달이 가능해야** 완료된다. + +```bash +kubectl apply -k k8s/overlays/dev/platform/cert-manager +kubectl apply -k k8s/overlays/dev/platform/cert-manager-issuers +kubectl apply -k k8s/overlays/dev/tls +``` + +운영 보정 필요: `admin@project.com` 은 실제 운영 수신 가능한 메일로 교체. + +## Keycloak realm / client Git 관리 + +`k8s/overlays/dev/keycloak-realm/` 는 `KeycloakRealmImport` 로 `platform` realm 과 `auth-server-ingress` client 를 선언한다. `k8s/overlays/dev/keycloak/` 도 수제 `Deployment` 가 아니라 `Keycloak` CR 기반으로 전환되어 있다. + +`KeycloakRealmImport` 는 같은 `mnt` namespace 의 `Keycloak/keycloak` 을 대상으로 동작한다. + +### 적용 순서 + +```bash +kubectl apply -k k8s/overlays/dev/platform/keycloak-operator +# 기존 수제 Deployment/Service/ConfigMap/ServiceAccount keycloak* 정리 +kubectl apply -k k8s/overlays/dev +kubectl apply -k k8s/overlays/dev/keycloak-realm +``` + +## 적용 범위 + +repo 가 커버하는 것: + +- Traefik 운영 정책의 Git 관리 +- app ingress host / path / policy 정의 +- Traefik → app 방향 ingress allow NetworkPolicy +- TLS `Certificate` 선언 준비 +- ForwardAuth variant 와 KeycloakRealmImport 선언 + +> 미완 항목(DNS / ACME 발급 / end-to-end 테스트)은 README 의 [Limitations](../README.md#limitations-honest-scope) 섹션을 참고. diff --git a/docs/networking.md b/docs/networking.md new file mode 100644 index 0000000..82c1e41 --- /dev/null +++ b/docs/networking.md @@ -0,0 +1,31 @@ +# NetworkPolicy 매트릭스 + +단일 namespace(`mnt`) 내부에서도 서비스 간 트래픽을 **최소권한** 으로 제한한다. baseline 은 모든 Pod 의 ingress/egress 를 차단하고, 컴포넌트별로 필요한 경로만 명시적으로 연다. + +## 정책 목록 + +| 정책 파일 | 역할 | +|---|---| +| `overlays/dev/networkpolicy-baseline.yaml` | `default-deny-all` (전 Pod ingress/egress 기본 차단) + `allow-dns-egress` (kube-system/kube-dns 53) | +| `overlays/dev/database/networkpolicy.yaml` | identity-postgres ingress ← keycloak / auth-server / migration-flyway (5432) | +| `overlays/dev/auth/networkpolicy.yaml` | auth-server ingress ← `kube-system/traefik`(8080); egress → postgres(5432) + keycloak(8080); flyway egress → postgres(5432) | +| `overlays/dev/keycloak/networkpolicy.yaml` | keycloak ingress ← `kube-system/traefik`(8080) + auth-server(8080); egress → postgres(5432); Keycloak Pod 간 peer 통신 (Infinispan/JGroups) | +| `overlays/dev/storage/networkpolicy.yaml` | minio ingress ← `part-of=auth-platform`(9000); 자체 peer(9000/9001) | +| `overlays/dev/test/networkpolicy.yaml` | test-server 3 대 내부 상호 통신만 허용 | +| `overlays/dev/vault/networkpolicy.yaml` | vault ingress ← VSO Operator Pod(8200) | +| `overlays/dev/registry/networkpolicy.yaml` | docker-registry ingress ← namespace 내 전 Pod(5000) + `kube-system/traefik`(5000); egress → minio(9000) | + +## 작성 규칙 + +- cross-namespace 참조가 필요한 항목(예: `kube-system/traefik`)은 `namespaceSelector` + `podSelector` 를 한 블록에 조합해 **AND 시맨틱** 으로 작성한다. 두 selector 를 별도 블록에 두면 OR 가 되어 정책이 헐거워진다. +- north-south ingress 는 `kube-system` 의 Traefik Pod 에서만 시작되므로, app 측 NetworkPolicy 도 실제 클러스터 기준으로 `kube-system` 을 허용해야 한다 (`ingressClassName=traefik` 만으로는 부족). +- baseline default-deny 가 켜져 있는 한, 새 워크로드를 올릴 때마다 ingress / egress 를 **명시적으로** 추가해야 한다. 이게 의도된 마찰이다 (실수로 wide-open 으로 시작하지 않도록). + +## Keycloak Operator 추가 고려사항 + +Keycloak Operator 가 Keycloak Pod 를 만들고 watch 하기 때문에 default-deny 환경에서는 다음 두 가지를 NetworkPolicy 로 명시한다: + +- Keycloak Operator 의 Kubernetes API egress (CR reconcile) +- Keycloak Pod 간 Infinispan/JGroups peer 통신 (cluster mode) + +해당 정책은 `overlays/dev/keycloak/networkpolicy.yaml` 에 함께 들어 있다. diff --git a/docs/operations.md b/docs/operations.md new file mode 100644 index 0000000..6f6bd03 --- /dev/null +++ b/docs/operations.md @@ -0,0 +1,83 @@ +# 운영 / 검증 + +bootstrap, teardown, validate.sh, 환경별 차등 계획의 **설계 의도** 를 정리한 문서. 단계별 실제 실행 절차는 [guide.md](../guide.md) 에 있다. + +## bootstrap 단계 + +`VaultConnection` / `VaultAuth` / `VaultStaticSecret` 은 VSO Helm 설치로 CRD 가 등록된 뒤에만 apply 할 수 있다. 그래서 `overlays/dev/vso/` 는 dev kustomization 집계에 포함되지 않으며, `bin/bootstrap.sh` 마지막 단계에서 별도로 `kubectl apply -k overlays/dev/vso/` 한다. + +| Phase | 작업 | 의존하는 직전 상태 | 멱등 안전? | +|:---:|---|---|---| +| 0 | MinIO Operator Helm install (`tasks/minio-operator-install.sh`) | helm 가능한 클러스터 | ✅ `helm upgrade --install` | +| 1 | `kubectl apply -k base/managing/namespace/` (PSS restricted 라벨 선행) | — | ✅ `kubectl apply` | +| 2 | VSO-managed Secret 점검 | namespace 존재 | ⚠️ `RESET_STALE_SECRETS=yes` 옵션 시 파괴적 | +| 3 | `kubectl apply -k overlays/dev/` (vault + registry + 앱) | namespace + PSS 라벨 | ✅ `kubectl apply` | +| 4 | `vault-0` Pod Running 대기 | Phase 3 의 Vault StatefulSet | ✅ wait 만 | +| 5 | `tasks/vault-init.sh` (init / unseal / auth / policy×2 / role×2) | `vault-0` Running | ✅ 상태 체크 후 차이만 적용 | +| 6 | `tasks/vso-install.sh` (helm upgrade --install) | Vault auth/role 준비 | ✅ `helm upgrade --install` | +| 7 | `kubectl apply -k overlays/dev/vso/` (VaultConnection / VaultAuth / VaultStaticSecret) | Phase 6 의 VSO CRD 등록 | ✅ `kubectl apply` | + +Phase 5 의 1 회성 셋업 흐름은 [secret-pipeline-bootstrap 시퀀스](diagrams/sequence/secret-pipeline-bootstrap.md), Phase 7 이후의 정상 reconcile 은 [secret-pipeline-runtime 시퀀스](diagrams/sequence/secret-pipeline-runtime.md) 참고. + +```bash +# dev — 비밀번호를 프롬프트에서 무음 입력 (bash history 에 안 남음) +bash k8s/scripts/bin/bootstrap.sh dev + +# teardown — 대화형 y/N +bash k8s/scripts/bin/teardown.sh dev +``` + +## 스크립트 구조 + +`k8s/scripts/` 는 `bin / ci / lib / tasks` 4 축: + +| 디렉토리 | 역할 | +|---|---| +| `bin/` | 사용자 진입점. `bootstrap.sh` / `teardown.sh` | +| `ci/` | CI / 로컬 검증. `validate.sh` (kustomize + kubeconform + kube-linter) | +| `lib/` | 공통 Bash 라이브러리. `common.sh` (strict mode / trap / log / confirm / retry / mask_secret) + `vault.sh` | +| `tasks/` | 재사용 작업. `vault-init.sh` / `vault-seed-apps.sh` / `vso-install.sh` | + +모든 쉘 스크립트는 `set -Eeuo pipefail` + `IFS=$'\n\t'` + `trap_cleanup` 으로 공통 에러 처리. root token / registry BasicAuth 같은 민감 값은 **stdin 파이프** 로만 전달하고 stdout 에 찍지 않는다. + +## 검증 (validate.sh) + +```bash +bash k8s/scripts/ci/validate.sh +``` + +3 단계: + +1. 각 overlay 에 대해 `kustomize build` (환경 중립성 / patch 유효성) +2. 렌더 결과에 `kubeconform -strict -ignore-missing-schemas` (Kubernetes OpenAPI + Datree CRD catalog) +3. 렌더 결과에 `kube-linter lint --config .kube-linter.yaml` (securityContext / resources / PSS / image tag 등) + +`.kube-linter.yaml` 은 **블록 단위 분석으로 생기는 컨텍스트 오탐 4 종**(`dangling-service`, `non-existent-service-account`, `mismatching-selector`, `no-anti-affinity`) 만 제외한다. 나머지는 모두 활성. + +목표 상태: + +``` +k8s/overlays/dev build=ok schema=ok lint=ok +k8s/overlays/dev/vso build=ok schema=ok lint=ok +``` + +## 환경별 배포 + +현재 `dev` overlay 만 완성. `staging` / `prod` 는 의도적으로 비어 있고 추후 확장 예정. validate.sh 는 `kustomization.yaml` 이 없는 환경을 자동 스킵한다 — 빈 overlay 가 CI 를 빨갛게 만들지 않기 위함. + +### 계획된 환경별 차등 + +| 리소스 | dev | staging | prod | +|---|---|---|---| +| Vault replicas / storage | 1 / 1Gi | 1 / 5Gi | 3 (HA Raft) / 20Gi | +| Registry replicas / storage | 1 / 5Gi | 1 / 10Gi | 2 / 50Gi | +| PostgreSQL retention policy | Delete | Retain | Retain | +| 이미지 tag 정책 | semver tag | semver tag | `@sha256:` digest pin | +| TLS | 비활성화 | cert-manager | cert-manager + HSTS | + +prod 승격 시 필수 작업: + +- Vault storage `file` → `raft` + KMS auto-unseal +- Postgres backup CronJob (Velero / pgBackRest) +- cert-manager ClusterIssuer 로 TLS 전환 +- 이미지 tag → digest pin diff --git a/docs/security-hardening.md b/docs/security-hardening.md new file mode 100644 index 0000000..2bcd0ba --- /dev/null +++ b/docs/security-hardening.md @@ -0,0 +1,256 @@ +# Security Hardening + +운영 절차가 아닌 **정책 / 거버넌스** 영역. guide.md 가 *"오늘 oncall 이 따라할 절차"* 라면 이 문서는 *"이 클러스터를 책임진다면 알아야 할 보안 결정"* 이다. + +## 목차 + +1. [etcd encryption at rest](#1-etcd-encryption-at-rest) +2. [Vault 운영자 토큰 관리](#2-vault-운영자-토큰-관리) +3. [bash history 에 비밀번호 남기지 않기](#3-bash-history-에-비밀번호-남기지-않기) + +--- + +## 1. etcd encryption at rest + +VSO 가 만드는 K8s Secret 은 기본적으로 **etcd 에 base64 로만 저장된다 (평문과 동일)**. 운영 전 반드시 암호화를 켠다. + +### K3s 방식 (권장 — 학습/소규모) + +최초 설치 시: + +```bash +# /etc/rancher/k3s/config.yaml +write-kubeconfig-mode: "0644" +secrets-encryption: true + +# 또는 설치 커맨드 +curl -sfL https://get.k3s.io | sh -s - server --secrets-encryption +``` + +K3s 가 AES-CBC 키를 자동 생성해서 `/var/lib/rancher/k3s/server/cred/encryption-config.json` 에 저장한다. + +이미 돌고 있는 클러스터에서 켜는 경우: + +```bash +sudo vim /etc/rancher/k3s/config.yaml # secrets-encryption: true 추가 +sudo systemctl restart k3s + +# 기존 Secret 을 즉시 재암호화 (없으면 새 Secret 부터 적용) +sudo k3s secrets-encrypt prepare +sudo systemctl restart k3s +sudo k3s secrets-encrypt rotate +sudo systemctl restart k3s +sudo k3s secrets-encrypt reencrypt +``` + +확인: + +```bash +sudo k3s secrets-encrypt status +# Encryption Status: Enabled +# Current Rotation Stage: start +# Server Encryption Hashes: All hashes match +``` + +### 표준 Kubernetes 방식 (kubeadm 등) + +1. `/etc/kubernetes/encryption.yaml` 생성: + +```yaml +apiVersion: apiserver.config.k8s.io/v1 +kind: EncryptionConfiguration +resources: + - resources: ["secrets"] + providers: + - aescbc: + keys: + - name: key-2026-04-21 + secret: <head -c 32 /dev/urandom | base64> + - identity: {} +``` + +2. kube-apiserver manifest 에 플래그 추가: + +```yaml +spec: + containers: + - command: + - kube-apiserver + - --encryption-provider-config=/etc/kubernetes/encryption.yaml + volumeMounts: + - name: encryption-config + mountPath: /etc/kubernetes/encryption.yaml + readOnly: true +``` + +3. 기존 Secret 재암호화: + +```bash +kubectl get secrets --all-namespaces -o json \ + | kubectl replace -f - +``` + +### KMS provider (프로덕션 권장) + +`aescbc` 대신 KMS plugin (Vault transit / AWS KMS / GCP KMS) 사용. circular dependency (Vault 도 K8s Secret 에 의존) 회피를 위해 Vault transit 은 **별도 provider Vault** 를 띄워야 한다. 현 프로젝트는 단일 Vault 이므로 KMS 는 추후 작업. + +### 검증 + +Secret 이 암호화됐는지 확인: + +```bash +# K3s +sudo k3s kubectl -n mnt get secret auth-server-db -o yaml \ + | grep -A1 "data:" + +# etcd 에 직접 접근해서 암호화 확인 (K3s) +sudo ETCDCTL_API=3 etcdctl \ + --endpoints=https://127.0.0.1:2379 \ + --cacert=/var/lib/rancher/k3s/server/tls/etcd/server-ca.crt \ + --cert=/var/lib/rancher/k3s/server/tls/etcd/client.crt \ + --key=/var/lib/rancher/k3s/server/tls/etcd/client.key \ + get /registry/secrets/mnt/auth-server-db +# 출력이 'k8s:enc:aescbc:v1:...' 로 시작하면 암호화됨 +``` + +--- + +## 2. Vault 운영자 토큰 관리 + +root token 은 **비상시 (rekey / generate-root / 전체 복구) 전용**으로만 사용한다. 평시 작업은 개인별 계정 + `vault-admin` policy 로 수행한다. + +### 초기 설정 + +부트스트랩 완료 후 한 번만: + +```bash +REPO_ROOT="$(pwd)" \ +VAULT_ADMIN_USERNAME='alice' \ +VAULT_ADMIN_PASSWORD='<초기 비밀번호>' \ +bash k8s/scripts/tasks/vault-setup-admin.sh +``` + +이 스크립트가 수행: +- `userpass` auth method 활성화 (idempotent) +- `vault-admin` policy 작성 (`secret/*`, `auth/*`, `sys/mounts/*`, `sys/audit/*` 등 관리 권한) +- `${VAULT_ADMIN_USERNAME}` 계정 생성 (token TTL 기본 8h / max 24h) + +### 운영자 로그인 — root token 사용 중단 + +```bash +# port-forward 로 vault CLI 접근 +kubectl -n mnt port-forward svc/vault 8200:8200 & +export VAULT_ADDR=http://127.0.0.1:8200 + +# 로그인 (로그인 시 발급되는 토큰은 8h 후 자동 만료) +vault login -method=userpass username=alice +# Password (will be hidden): <입력> +# Token is displayed and automatically cached in ~/.vault-token +``` + +첫 로그인 후 비밀번호 변경: + +```bash +vault write auth/userpass/users/alice/password password='<새 비밀번호>' +``` + +### root token 처리 + +`vault-init-keys.json` 에 있는 root token 은: + +1. **즉시 오프라인 금고 (1Password Team / 하드웨어 보안 금고 / 봉인 봉투) 로 이동** +2. 원본 파일에서 `root_token` 필드 삭제 (unseal keys 는 재부팅 시 필요하므로 유지) +3. root token 이 필요하면: + +```bash +# 기존 root token 유효하면 재사용 +vault login <root-token> + +# 분실/만료됐으면 재발급 (unseal key 쿼럼 필요) +vault operator generate-root -init +# 응답의 nonce 저장, unseal key 보유자들이 otp 로 제출 +vault operator generate-root -nonce=<nonce> -otp=<your-otp> <unseal-key-1> +vault operator generate-root -nonce=<nonce> -otp=<your-otp> <unseal-key-2> +vault operator generate-root -nonce=<nonce> -otp=<your-otp> <unseal-key-3> +# 마지막 응답에 Encoded Token 이 나옴 → otp 로 decode +vault operator generate-root -decode=<encoded> -otp=<your-otp> +# 새 root token 확보 후 기존 것 revoke: +vault token revoke <old-root-token> +``` + +### 운영자 계정 추가 / 제거 + +```bash +# 추가 +REPO_ROOT="$(pwd)" \ +VAULT_ADMIN_USERNAME='bob' \ +VAULT_ADMIN_PASSWORD='<임시 pw>' \ +bash k8s/scripts/tasks/vault-setup-admin.sh + +# 제거 +vault delete auth/userpass/users/bob +``` + +### 권한 분리 (추후 확장) + +`vault-admin` 은 전권 정책이다. 실무에서는 역할별 분리 권장: + +| 역할 | policy 이름 | 권한 범위 | +|---|---|---| +| 인프라 admin | `vault-admin` | 현재 정의된 전권 (rekey 제외) | +| 앱 팀 (read) | `secret-readonly` | `secret/data/*` read 전용 | +| 앱 팀 (write) | `secret-writer` | 팀별 경로 제한 (`secret/data/auth-server/*` 등) | +| 감사자 | `audit-reader` | `sys/audit/*` read + Vault audit log 접근 | + +각 policy 를 만들고 userpass user 생성 시 `token_policies=<policy-name>` 로 바인딩한다. + +### 감사 로그 활성화 (추후) + +Vault 자체 감사 로그는 기본 비활성. 운영에서는 반드시 활성화: + +```bash +# file 방식 +vault audit enable file file_path=/vault/logs/audit.log + +# socket 방식 (중앙집중 수집) +vault audit enable socket address=loki-syslog.monitoring.svc:514 socket_type=tcp +``` + +`statefulset.yaml` 의 volumeMounts 에 `/vault/logs` 를 추가해야 파일 방식 사용 가능. 별도 작업. + +--- + +## 3. bash history 에 비밀번호 남기지 않기 + +`VAR=value command` 형태로 env var 를 명령줄에 직접 적으면 **그대로 `~/.bash_history` 에 저장** 된다. 대응: + +### 권장 — 대화형 입력 + +```bash +bash k8s/scripts/bin/bootstrap.sh dev +# 프롬프트에서 무음 입력 (echo 안 됨) +``` + +본 프로젝트의 모든 시크릿 seed 스크립트 (`bootstrap.sh`, `tasks/vault-seed-apps.sh`, `tasks/vault-setup-admin.sh`) 는 env var 가 비어 있으면 TTY 에서 자동으로 `read -r -s` 프롬프트로 전환한다. + +### 비대화 (CI) 실행 시 + +어쩔 수 없이 env var 를 넣어야 할 때: + +```bash +# 이번 명령만 history 에 안 남기기 +HISTFILE=/dev/null \ +POSTGRES_SUPERUSER_PASSWORD='...' \ +KEYCLOAK_DB_PASSWORD='...' \ +AUTH_SERVER_DB_PASSWORD='...' \ +KEYCLOAK_ADMIN_PASSWORD='...' \ +MINIO_ROOT_PASSWORD='...' \ +bash k8s/scripts/bin/bootstrap.sh dev + +# 또는 세션 전체 history 비활성화 +set +o history +POSTGRES_SUPERUSER_PASSWORD='...' bash ... +set -o history +``` + +> **Tip**: `HISTCONTROL=ignorespace` 가 설정된 쉘이면 **명령 앞에 공백 1 칸** 넣어도 저장되지 않는다. 다만 쉘마다 설정이 다르니 `HISTFILE=/dev/null` 이 가장 확실. diff --git a/docs/standards/infra/STYLE.md b/docs/standards/infra/STYLE.md new file mode 100644 index 0000000..970bb8d --- /dev/null +++ b/docs/standards/infra/STYLE.md @@ -0,0 +1,275 @@ +# STYLE.md — 인프라 문서 공용 규약 (Single Source of Truth) + +이 문서는 `docs/standards/infra/**` 와 `docs/examples/infra/**` 에 등장하는 모든 라벨, 네이밍, 포트, 이미지, 리소스 관례의 **정규(normative)** 정의다. 다른 모든 문서의 YAML 조각은 예시이며, 여기 규약과 충돌할 경우 **이 문서가 우선한다.** AI 에이전트가 매니페스트를 생성할 때 관례가 문서 간 표류하는 것을 방지하려는 목적이다. + +--- + +## 1. 라벨 (Labels) + +### 1.1 Kubernetes well-known labels (`app.kubernetes.io/*`) + +공식 well-known set. 이 namespace 아래에는 아래 6개 외에 임의 키를 **추가하지 않는다.** + +| 키 | 의미 | 예시 | +| --- | --- | --- | +| `app.kubernetes.io/name` | 애플리케이션 이름 | `auth-server` | +| `app.kubernetes.io/instance` | 인스턴스 (환경/리전 포함 가능) | `auth-server-prod`, `auth-server` | +| `app.kubernetes.io/version` | semver 또는 release tag | `1.24.0` | +| `app.kubernetes.io/component` | 역할 | `api`, `worker`, `migration`, `database` | +| `app.kubernetes.io/part-of` | 상위 시스템 | `auth-platform` | +| `app.kubernetes.io/managed-by` | 배포 도구 | `kustomize`, `argocd`, `helm` | + +### 1.2 조직 커스텀 라벨 (`example.com/*`) + +`example.com/` namespace 는 문서 전용 플레이스홀더다. 실제 조직은 자사 도메인(e.g., `acme.corp/`)으로 치환한다. + +| 키 | 허용 값 | +| --- | --- | +| `example.com/environment` | `dev` \| `staging` \| `prod` | +| `example.com/owner-team` | 팀 slug (e.g., `auth-platform`, `sre`) | +| `example.com/cost-center` | 회계 코스트 센터 ID | +| `example.com/data-classification` | `public` \| `internal` \| `confidential` \| `restricted` | +| `example.com/tier` | `0` (critical) \| `1` \| `2` \| `3` (best-effort) | + +### 1.3 규칙 + +1. 모든 워크로드(Deployment / StatefulSet / DaemonSet / Job / CronJob)에는 위 6개 `app.kubernetes.io/*` 라벨 + `example.com/environment` + `example.com/owner-team` 이 **필수**다. +2. `app.kubernetes.io/environment` 라벨은 **사용 금지**. 공식 well-known set 에 없으며, 환경 라벨은 조직 namespace 아래에 둔다. +3. Selector (`spec.selector.matchLabels`)에는 **`app.kubernetes.io/name` 과 `app.kubernetes.io/instance` 만** 사용한다. 이유: selector 는 immutable 이고, `version` / `component` 이외 라벨은 릴리즈마다 바뀌기 때문에 selector 에 포함하면 rollout 이 막힌다. +4. 라벨 값은 DNS-1123 subdomain 또는 label 규칙을 따른다: 소문자 알파벳, 숫자, `-`, `.`, 최대 63자. 공백/대문자/언더스코어 금지. +5. 라벨은 metadata 의 최상위 `labels:` 와 Pod template 의 `spec.template.metadata.labels:` 에 **동일하게** 복제한다(선택자 일치 보장). + +```yaml +metadata: + name: auth-server + labels: + app.kubernetes.io/name: auth-server + app.kubernetes.io/instance: auth-server-prod + app.kubernetes.io/version: "1.24.0" + app.kubernetes.io/component: api + app.kubernetes.io/part-of: auth-platform + app.kubernetes.io/managed-by: kustomize + example.com/environment: prod + example.com/owner-team: auth-platform +spec: + selector: + matchLabels: + app.kubernetes.io/name: auth-server + app.kubernetes.io/instance: auth-server-prod +``` + +--- + +## 2. 네이밍 (Naming) + +### 2.1 Namespace + +1. 기본 스키마: `<env>-<domain>-<service>` — 예: `prod-auth-keycloak`, `staging-billing-api`. +2. 단일 서비스 네임스페이스에 여러 컴포넌트가 있으면 서비스 이름까지만 사용한다: `prod-auth` 네임스페이스 안에 Keycloak, PostgreSQL, Flyway Job 이 공존. +3. `default` 네임스페이스는 **금지**. `kube-*` 는 Kubernetes 예약. +4. 클러스터 공통 플랫폼 컴포넌트는 별도 접두어: `platform-vault`, `platform-cert-manager`, `platform-monitoring`. + +### 2.2 리소스 이름 + +케밥-케이스, 소문자. Service / ServiceAccount / Secret / ConfigMap 이름은 관련 워크로드 이름을 접두어로 공유한다. + +| 리소스 | 규약 | 예시 | +| --- | --- | --- | +| Deployment / StatefulSet | `<app>` | `auth-server` | +| Service (ClusterIP) | `<app>` (Deployment와 동일) | `auth-server` | +| Headless Service (StatefulSet peer 통신용) | `<app>-headless` (옆에 일반 ClusterIP `<app>` 병행) | `keycloak-headless`, `keycloak` | +| ServiceAccount | `<app>-sa` | `auth-server-sa` | +| Secret (앱 소유) | `<app>-<purpose>` | `auth-server-db`, `auth-server-oidc` | +| ConfigMap | `<app>-<purpose>` | `auth-server-config`, `auth-server-runtime` | +| PDB | `<app>-pdb` | `auth-server-pdb` | +| HPA | `<app>-hpa` | `auth-server-hpa` | +| NetworkPolicy | `<app>-<direction>-<peer>` | `auth-server-egress-db`, `auth-server-ingress-traefik` | +| Job (일회성) | `<app>-<action>-<timestamp-or-version>` | `auth-server-migrate-1-24-0` | +| CronJob | `<app>-<action>` | `auth-server-session-cleanup` | + +--- + +## 3. 포트 (Ports) + +### 3.1 이름 규약 + +모든 containerPort / servicePort 에는 `name` 필드가 **필수**다. 아래 이름은 예약어로 취급한다. + +| name | 용도 | 관행 포트 | +| --- | --- | --- | +| `http` | HTTP 앱 트래픽 | 8080 | +| `https` | HTTPS 직접 종료 | 8443 | +| `grpc` | gRPC | 9090 또는 앱별 지정 | +| `metrics` | Prometheus scrape | 9090 (kube-prometheus 관행). 컴포넌트가 이미 9090 을 쓰면 9100 | +| `health` | 별도 헬스/관리 포트 | Keycloak Quarkus 관리 포트 9000 등 | +| `admin` | 관리 UI | 컴포넌트별 | +| `cluster` | 내부 peer / 레플리케이션 | Vault 8201, Postgres 5432, etcd 2380 | + +### 3.2 규칙 + +1. `targetPort` 는 number 대신 **이름 참조**를 권장: `targetPort: http`. 이유: 컨테이너가 바인드 포트를 바꿔도 Service 쪽 조정이 필요 없다. +2. `metrics` 포트는 **외부 노출 금지**. ClusterIP 만 쓰며 NetworkPolicy 로 Prometheus 네임스페이스에서만 ingress 허용. +3. `health`, `admin` 포트는 Ingress 에 붙이지 않는다. NetworkPolicy 로 접근 대역을 제한한다. + +Container ports 스탠자 (Deployment/Pod spec 내부): + +``` +ports: +- name: http + containerPort: 8080 + protocol: TCP +- name: metrics + containerPort: 9090 + protocol: TCP +- name: health + containerPort: 9000 + protocol: TCP +``` + +Service 정의 (targetPort 는 이름 참조): + +```yaml +apiVersion: v1 +kind: Service +metadata: + name: auth-server +spec: + selector: + app.kubernetes.io/name: auth-server + app.kubernetes.io/instance: auth-server-prod + ports: + - name: http + port: 80 + targetPort: http +``` + +--- + +## 4. 이미지 (Images) + +1. **prod 환경**: `<registry>/<path>@sha256:<digest>` 형태 digest pin **필수**. 뮤터블 태그(`:1`, `:latest`, `:main`) 금지. +2. **staging**: digest 권장, 최소 semver tag(`:1.24.0`) 허용. 절대 `:latest` 금지. +3. **dev**: semver tag 허용, `:latest` 지양 (로컬 / 노드 cache invalidation 이슈). +4. `imagePullPolicy`: + - digest 사용 시 `IfNotPresent` (이미지 콘텐츠는 immutable) + - 뮤터블 태그 사용 시 `Always` +5. 레지스트리: 조직 내부 미러가 우선한다. 예: `registry.example.com/<ns>/<app>`. Docker Hub 직접 pull 금지 (rate limit + 공급망 리스크). +6. SHA256 digest 로 pin 한 이미지는 CI 파이프라인에서 cosign 서명 검증(선택)과 `imagePullSecrets` digest 검증에 연결한다. + +```yaml +containers: +- name: app + image: registry.example.com/auth-platform/auth-server@sha256:9f0b2c4d8e7a1b3c5d7e9f1a3b5c7d9e1f3a5b7c9d1e3f5a7b9c1d3e5f7a9b1c + imagePullPolicy: IfNotPresent +``` + +--- + +## 5. 리소스 (Resources) + +### 5.1 필수 필드 + +1. 모든 컨테이너는 `resources.requests.cpu`, `resources.requests.memory`, `resources.limits.memory` 를 **반드시** 설정한다. +2. `resources.limits.cpu` 는 **선택**이다. 레이턴시 민감 워크로드에만 설정한다. 이유: CFS throttling 으로 인한 p99 tail-latency 악화를 회피하려는 Tim Hockin / Google SRE 가이던스. + +### 5.2 QoS 클래스 + +1. `Guaranteed` — latency-critical (Keycloak, Vault, Postgres 등): `requests == limits`, CPU limit 도 설정. +2. `Burstable` — 일반 stateless 앱: `requests < limits` 또는 CPU limit 생략. +3. `BestEffort` — **금지**. requests/limits 를 생략한 워크로드는 PR 에서 블록. + +### 5.3 기본 가이드라인 (1000-서비스 스케일 기준 출발점) + +| 워크로드 | CPU req | Memory req / limit | +| --- | --- | --- | +| 일반 stateless API | 100m | 128–256Mi | +| 무거운 JVM (Keycloak, Elasticsearch) | 500m–1 | 1–2Gi (req == limit) | +| 배경 worker | 250m | 512Mi–1Gi | +| 전환성(transient) Job (Flyway) | 100m | 128Mi | + +실제 값은 부하 테스트 / VPA 권고 결과로 조정한다. + +--- + +## 6. 보안 기준선 (Security baselines — 모든 Pod) + +아래 블록은 **모든** Pod 의 최소 baseline 이다. 이걸 내린 설정은 security-hardening.md 의 예외 절차를 거쳐야 한다. + +```yaml +spec: + securityContext: + runAsNonRoot: true + runAsUser: 10001 # 앱별 고정 UID, 루트(0) 금지 + runAsGroup: 10001 + fsGroup: 10001 + seccompProfile: + type: RuntimeDefault + containers: + - name: app + image: registry.example.com/auth-platform/auth-server@sha256:... + imagePullPolicy: IfNotPresent + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: [ALL] + resources: + requests: + cpu: 100m + memory: 128Mi + limits: + memory: 256Mi +``` + +--- + +## 7. PDB, Job, Deployment 기타 + +1. **PDB**: 1.27+ 에서 `spec.unhealthyPodEvictionPolicy: AlwaysAllow` **필수**. 기본값 `IfHealthyBudget` 은 노드 drain 중 복구 불가능한 Pod 가 evict 되지 못해 업그레이드가 멈추는 원인이 된다. +2. **Job / CronJob**: + - `spec.ttlSecondsAfterFinished: 86400` (24h) 기본. 민감 로그가 남는 경우 `3600` (1h). + - `spec.backoffLimit` 명시 (기본 6). 크리티컬 마이그레이션(Flyway)은 `0` 또는 `1` 로 줄여 재시도 폭주 방지. + - CronJob 은 `spec.concurrencyPolicy: Forbid` 를 기본값으로 둔다(중복 실행 금지). +3. **Deployment**: + - `spec.revisionHistoryLimit: 5` (기본 10 은 너무 많음 — etcd 부하). + - `spec.progressDeadlineSeconds: 600` 명시. + - `spec.strategy.rollingUpdate.maxUnavailable: 0` + `maxSurge: 25%` 가 안전한 기본. + +```yaml +apiVersion: policy/v1 +kind: PodDisruptionBudget +metadata: + name: auth-server-pdb +spec: + minAvailable: 2 + unhealthyPodEvictionPolicy: AlwaysAllow + selector: + matchLabels: + app.kubernetes.io/name: auth-server + app.kubernetes.io/instance: auth-server-prod +``` + +--- + +## 8. 문서 내 예시 규약 + +1. 모든 YAML 예시는 ```` ```yaml ```` 펜스로 감싼다. 다른 언어 펜스 금지. +2. 한 파일에 여러 리소스가 등장하면 `---` separator 를 **명시적으로** 추가한다. +3. 예시는 원칙적으로 `kubectl apply -f` 로 바로 적용 가능한 완전체여야 한다. 지면상 생략할 때는 주석으로 표기: `# ... (full spec omitted for brevity)`. +4. 나쁜 예시(안티패턴)는 반드시 `## 나쁜 예시`, `## ❌`, 또는 `## bad example` 헤더 아래에 둔다. CI 검증 스크립트가 이 헤더 규약으로 나쁜 예시 블록을 제외한다. 헤더 없이 안티패턴을 노출하면 검증기가 정당한 예시로 오인해 lint 규칙 위반을 일으킨다. +5. 네임스페이스, 이미지 레지스트리, 도메인 이름은 `example.com`, `registry.example.com` 플레이스홀더를 사용한다. 실제 조직 도메인은 overlays 에서만 등장한다. + +--- + +## 검증 (Validation) + +이 문서의 규약은 CI 에서 기계 검증된다. + +- 실행: `k8s/scripts/ci/validate-docs.sh` +- Lint 설정 위치: `.kube-linter.yaml` (repo root) +- 목표 스코어: + - syntax 에러: **0** + - schema 에러: **0** + - lint warning: **≤ 5** + +syntax 또는 schema 에러가 있으면 PR 은 머지 불가. lint warning 이 임계치를 넘으면 리뷰어가 수정 또는 예외 주석을 요구한다. diff --git a/docs/standards/infra/architecture-environments.md b/docs/standards/infra/architecture-environments.md new file mode 100644 index 0000000..2b191a3 --- /dev/null +++ b/docs/standards/infra/architecture-environments.md @@ -0,0 +1,297 @@ +# infra architecture / environments 기준 + +## 목적 + +이 문서는 1000+ 서비스 규모의 K3s 기반 production 클러스터에서 +- 환경을 어떻게 나눌지 +- namespace / label / selector를 어떻게 고정할지 +- K3s 기본 컴포넌트와 GitOps source of truth를 어떻게 구분할지 +- cross-cluster / multi-region / DR(RPO·RTO)을 어떻게 문서화할지 + +를 먼저 고정한다. + +이 문서의 목표: + +- dev / staging / prod 환경 분리를 **label·namespace·selector 레벨에서** 일관되게 만든다 +- 서비스별 리소스 소유권(팀·도메인·컴포넌트)을 label로 쿼리 가능하게 한다 +- K3s packaged component와 사용자 AddOn을 혼동하지 않는다 +- 멀티 서버에서 `manifests/` 디렉터리를 source-of-truth로 쓰는 사고를 원천 차단한다 +- 이후 storage / secrets / ingress / workload / observability 표준의 전제 조건을 고정한다 + +## 공식 의미 (근거) + +- Kubernetes well-known label set (공식, SIG-Apps 공인): `app.kubernetes.io/{name,instance,version,component,part-of,managed-by}` — 총 6개. `environment`는 포함되지 **않는다** (`https://kubernetes.io/docs/concepts/overview/working-with-objects/common-labels/`). +- `app.kubernetes.io/*` 외의 운영 차원(environment, team, tier, region 등)은 **자체 도메인 네임스페이스**(`example.com/*`)를 붙여 선언해야 한다. +- K3s는 `coredns`, `traefik`, `local-storage`, `metrics-server`를 packaged component로 제공한다. +- `/var/lib/rancher/k3s/server/manifests` 아래 파일은 서버 시작 시와 파일 변경 시 자동 적용된다(AddOn auto-deploy). +- packaged component manifest는 K3s가 재기록하므로 직접 수정 금지. +- 멀티 서버 K3s는 AddOn 파일을 자동 동기화하지 않는다. +- Kustomize v5+부터는 `labels:` 필드(기본 `includeSelectors: false`)가 `commonLabels`보다 안전한 기본이다. `commonLabels`는 항상 `selector.matchLabels`에 주입되며, Deployment/StatefulSet의 selector는 **immutable**이므로 운영 중 label 추가만으로 apply가 실패한다. +- GitOps 기본 apply 방식은 **Server-Side Apply** (`kubectl apply --server-side --field-manager=...`)다. CI/ArgoCD/Flux 모두 SSA 기본. + +## 기본 규칙 + +### 1. 환경은 명시적으로 분리하고 **label + namespace 양쪽에** 박는다 + +기본 환경: + +- `dev` +- `staging` +- `prod` + +필요 시 `sandbox` / `canary` / `dr`을 추가할 수 있으나 dev/staging/prod 의미를 흐리지 않는다. + +각 리소스는 두 곳에 동시에 환경이 드러나야 한다. + +- `metadata.namespace` — 물리적 격리 +- `metadata.labels["example.com/environment"]` — 쿼리·정책용 (well-known label에는 환경이 없으므로 **자체 도메인 label** 사용) + +### 2. 환경 간 혼합 배포 전면 금지 + +하나의 namespace / hostname / PVC / Secret / TLS cert scope 안에서 서로 다른 환경 리소스가 섞이지 않는다. + +금지: + +- `auth-dev`, `auth-prod`가 같은 namespace 공유 +- dev와 prod가 같은 ingress host (`auth.example.com`) 공유 +- staging과 prod가 같은 PostgreSQL schema / S3 bucket / Vault mount 공유 +- NetworkPolicy / ResourceQuota / LimitRange가 환경 경계를 걸치지 않음 + +### 3. namespace 전략은 “환경 prefix + 서비스 이름” 고정 + +1000+ 서비스 스케일에서 초기에 하나의 포맷을 박는다. 본 표준 권장은: + +``` +<env>-<domain>-<service> +``` + +예: + +- `prod-identity-auth` +- `prod-identity-keycloak` +- `staging-identity-auth` +- `dev-identity-auth` +- `prod-platform-ingress-nginx` +- `prod-data-postgres-identity` + +이유: + +- `kubectl -n prod-*` 와일드카드 RBAC / 모니터링 쿼리가 쉬움 +- `prod-` prefix로 PodSecurity admission (`pod-security.kubernetes.io/enforce=restricted`)을 one-shot으로 강제 가능 +- `default` namespace는 production workload 배포 전면 금지 + +### 4. `app.kubernetes.io/*` 6종은 전 리소스 필수 + +모든 워크로드·서비스·ingress·PVC·ConfigMap·Secret에 아래 6개가 반드시 붙는다. + +- `app.kubernetes.io/name` — 애플리케이션 이름 (예: `auth`) +- `app.kubernetes.io/instance` — 인스턴스 (예: `auth-prod`) +- `app.kubernetes.io/version` — semver 또는 image tag +- `app.kubernetes.io/component` — 역할 (예: `api`, `worker`, `database`) +- `app.kubernetes.io/part-of` — 상위 도메인 (예: `identity-platform`) +- `app.kubernetes.io/managed-by` — 관리 도구 (예: `kustomize`, `argocd`, `flux`) + +### 5. 운영 차원 label은 **자체 도메인**으로 선언 + +well-known label 6종으로 표현되지 않는 축은 다음 키로 고정한다. + +- `example.com/environment` — `dev|staging|prod|canary|dr` +- `example.com/team` — 소유 팀 (예: `identity-sre`) +- `example.com/tier` — `frontend|backend|data|platform` +- `example.com/data-classification` — `public|internal|confidential|restricted` +- `example.com/cost-center` — FinOps tag +- `example.com/slo-tier` — `tier-1|tier-2|tier-3` + +금지: + +- `app.kubernetes.io/environment` 사용 (well-known set에 없음) +- 도메인 없는 커스텀 키 (`environment: prod` 같은 top-level key) + +### 6. selector에 들어가는 label은 **불변 3종만** + +Deployment / StatefulSet의 `selector.matchLabels`는 일단 apply 후 수정 불가다. 여기에는 운영 중 **절대 바뀌지 않는** 값만 넣는다. + +허용: + +- `app.kubernetes.io/name` +- `app.kubernetes.io/instance` +- `app.kubernetes.io/component` + +금지 (selector에 넣지 말 것): + +- `app.kubernetes.io/version` (배포 때마다 바뀜) +- `app.kubernetes.io/managed-by` (툴 교체 시 drift) +- `example.com/environment` (overlay에서 주입되면 selector immutable 위반) + +### 7. K3s packaged component는 “기본 제공”일 뿐 “무조건 사용”이 아니다 + +다음 컴포넌트는 클러스터 bootstrap 초기에 유지/비활성 결정을 박는다. + +- `traefik` +- `servicelb` +- `local-storage` +- `metrics-server` +- `coredns` (교체는 특수 케이스) + +기본: + +- 무엇을 끄는지 Git에 기록 +- packaged manifest 직접 수정 금지 — `--disable` 플래그 또는 `HelmChartConfig` +- prod 1000-서비스 스케일에서는 traefik / servicelb 모두 disable 후 **ingress-nginx DaemonSet + MetalLB/외부 LB** 조합이 일반적 + +### 8. `/var/lib/rancher/k3s/server/manifests`는 source of truth 아님 + +이 디렉터리는 AddOn auto-deploy 경로다. +멀티 서버 환경에서 자동 동기화가 **안 되므로**, Git이 source of truth고 이 디렉터리는 apply sink에 지나지 않는다. + +기본: + +- Git repo의 `k8s/` 디렉터리가 SoT +- CI/ArgoCD/Flux가 `kubectl apply --server-side`로 push +- 서버별 scp / vim 절대 금지 +- 멀티 서버 bootstrap AddOn도 Git 관리(예: `k8s/bootstrap/*`를 첫 서버에만 배치) + +### 9. GitOps apply는 Server-Side Apply가 기본 + +``` +kubectl apply --server-side --field-manager=<ci-id> -k <overlay> +kubectl diff --server-side -k <overlay> +``` + +이유: + +- multi-controller 환경(ArgoCD + HPA + VPA + operator)에서 ownership 충돌을 `managedFields`로 명시적 해결 +- `last-applied-configuration` annotation 2MB 한계 회피 +- 3-way merge 실패로 인한 silent drift 제거 + +### 10. base는 환경 중립, overlay는 환경 차이만 + +이후 `kustomize.md`에서 상세히 다룬다. 이 문서에서는 원칙만 박는다. + +- `k8s/base/` — 공통 shape, 환경-agnostic +- `k8s/overlays/{dev,staging,prod}/` — patches / images / replicas / resources / labels + +overlay는 base를 재작성하지 않는다. overlay diff가 100줄을 넘으면 base 설계 실패 신호다. + +### 11. `app/managing/plugins` 책임 분리 + +`k8s/base/` 하위는 다음 3축으로 고정한다. + +- `app/units/<domain>/<service>/` — 애플리케이션 유닛 (auth, keycloak, test-server) +- `managing/` — Job/CronJob 운영 작업 (flyway-migrate, backup, restore, bootstrap admin) +- `plugins/` — 플랫폼 (ingress-controller, cert-manager, external-secrets, observability, policy) + +이 축은 **소유 팀이 다르다**는 가정 위에 있다. 각 축은 독립된 Git owner (CODEOWNERS)를 가진다. + +### 12. 상태 저장 / 외부 공개 범위를 architecture 단계에서 분류 + +모든 서비스는 아래 2축으로 초기 분류한다. + +| 축 | 값 | +|-----------------|-----------------------------------------------------------------| +| workload 성격 | `stateless` / `stateful` / `job` / `cronjob` / `daemonset` | +| 공개 범위 | `public` / `internal-only` / `operator-only` / `cluster-only` | + +예: + +- `auth-server` — stateless / public +- `keycloak` — stateless(앱) + stateful(외부 DB) / public (관리 포트는 internal-only) +- `vault` — stateful / operator-only (+ cluster-only service endpoint) +- `minio-tenant` — stateful / internal-only +- `postgres-identity` — stateful / cluster-only +- `fluent-bit` — daemonset / cluster-only +- `flyway-migrate` — job / cluster-only + +### 13. SLO·RPO·RTO를 환경 문서에서 먼저 박는다 + +환경 분리가 의미 있으려면 각 환경의 목표를 숫자로 고정해야 한다. 서비스 tier별로 아래 항목을 환경 문서에서 표로 둔다. + +| tier | availability SLO | RPO | RTO | backup 주기 | multi-AZ | PDB minAvailable | +|--------|------------------|------|------|-------------|----------|------------------| +| tier-1 | 99.95% | 5m | 15m | 15m | required | 50% | +| tier-2 | 99.9% | 1h | 1h | 1h | required | 1 | +| tier-3 | 99.5% | 24h | 4h | 24h | optional | 0 | + +tier는 `example.com/slo-tier` label로 리소스마다 붙는다. + +### 14. 멀티 서버 K3s는 critical config를 Git에서 통일 + +K3s multi-server에서는 아래가 모든 서버에서 동일해야 한다(불일치 시 `critical configuration value mismatch`로 join 실패). + +- `cluster-cidr`, `service-cidr`, `cluster-dns`, `cluster-domain` +- `disable` 플래그 세트 +- `flannel-backend` / CNI 관련 +- `embedded-registry` 활성화 여부 + +기본: + +- `/etc/rancher/k3s/config.yaml` Git 관리 +- 서버별 ad-hoc 수정 금지 +- 신규 서버 조인 전 `config.yaml` diff 확인 + +### 15. 공개 범위별 ingress host 패턴 고정 + +- public: `<service>.example.com` +- internal: `<service>.internal.example.com` +- operator: `<service>.ops.example.com` (mTLS + SSO 필수) +- cluster-only: ingress 없음, ClusterIP + NetworkPolicy로만 접근 + +### 16. 네이밍 규칙 정리 (요약) + +- namespace: `<env>-<domain>-<service>` +- Deployment/StatefulSet 이름: `<service>` (namespace로 환경 구분, 이름에 env 중복 금지) +- Service 이름: Deployment 이름과 동일 (headless면 `-headless` suffix) +- PVC 이름: `<service>-<purpose>-<ordinal>` (StatefulSet volumeClaimTemplate은 자동) +- Kustomize overlay 디렉터리: `overlays/<env>/<region>/` (멀티 region 시) + +## 추천 디렉터리 구조 + +```text +k8s/ + base/ + app/ + shared/ + units/ + identity/ + auth/ + kustomization.yaml + keycloak/ + kustomization.yaml + data/ + postgres-identity/ + kustomization.yaml + managing/ + flyway-migrate-identity/ + backup-postgres/ + plugins/ + ingress-nginx/ + cert-manager/ + external-secrets/ + kube-prometheus-stack/ + overlays/ + dev/ + kustomization.yaml + staging/ + kustomization.yaml + prod/ + kustomization.yaml + region-kr-main/ + region-kr-dr/ + bootstrap/ + k3s-addons-disabled/ + scripts/ + render.sh + diff.sh + apply.sh +``` + +## 프로젝트 기준 요약 + +- 환경 3종(`dev`/`staging`/`prod`) + namespace prefix 고정 +- well-known `app.kubernetes.io/*` 6개 + 자체 도메인 운영 label 필수 +- `app.kubernetes.io/environment` 사용 금지, `example.com/environment`로 대체 +- selector에는 불변 3종만 +- K3s packaged component는 초기에 disable 여부 결정, 직접 수정 금지 +- `manifests/`는 apply sink, Git이 SoT +- `kubectl apply --server-side` GitOps 기본 +- SLO / RPO / RTO 표가 환경 문서의 일부 diff --git a/docs/standards/infra/backup-restore.md b/docs/standards/infra/backup-restore.md new file mode 100644 index 0000000..5f9f3a6 --- /dev/null +++ b/docs/standards/infra/backup-restore.md @@ -0,0 +1,241 @@ +# backup / restore 기준 + +## 목적 + +이 문서는 K3s 및 일반 Kubernetes 인프라에서 +- 무엇을 백업해야 하는지 +- 어떤 도구/방식으로 백업할지 (Velero / CSI snapshot / pgBackRest / WAL-G / CNPG Barman) +- RPO / RTO를 어떻게 선언할지 +- 복구 단위와 절차를 어떻게 표준화할지 +- restore drill을 어떻게 운영할지 +를 먼저 고정한다. + +이 문서의 목표는 다음과 같다. + +- "PVC가 있으니 백업도 된 것"이라는 착각을 제거한다 +- "K3s etcd snapshot이 있으니 DB/PVC도 복구된다"는 오해를 제거한다 +- 선언형 원본, 제어 평면, 상태 저장소를 서로 다른 백업 대상으로 구분한다 +- 컴포넌트별 복구 전략과 도구를 먼저 정하고 YAML을 쓰게 한다 +- 실제 장애 시 복구 절차를 재현 가능하게 만든다 + +## 공식 의미 + +- K3s etcd snapshot은 **클러스터 API 상태**(namespaces, secrets encryption key, RBAC, CRD instance 등)만 백업한다. **PVC의 데이터 내용은 백업하지 않는다.** +- K3s snapshot에는 cluster CA 인증서/개인키와 secrets encryption 관련 데이터가 포함될 수 있다. +- 새 호스트로 K3s snapshot을 복구할 때는 snapshot 당시 사용한 server token이 필요하다. +- Velero는 CNCF 표준 K8s 백업 도구로 `Backup` / `Schedule` / `Restore` CRD와 object storage 백엔드(S3 / MinIO / GCS / Azure Blob)를 사용한다. +- Velero file-level backup: File System Backup (FSB, kopia/restic) — 모든 CSI/비-CSI 볼륨의 파일 내용을 복제. +- Velero volume-level backup: CSI snapshot — CSI driver가 지원하는 경우 블록 수준 snapshot. +- VolumeSnapshotClass의 `deletionPolicy: Retain`이면 VolumeSnapshot 삭제 후에도 VolumeSnapshotContent(클라우드 snapshot)는 남는다. +- PostgreSQL의 기본 physical backup 도구로는 `pg_basebackup`(standalone) 외에 **pgBackRest** 또는 **WAL-G**가 사실상 표준이다. CloudNativePG operator는 내장으로 **Barman Cloud**를 사용한다. +- `pg_dump`는 logical export이며 정기 production 전체 백업의 기본 도구로는 보통 적합하지 않다. +- MinIO `mc mirror`는 현재 객체만 동기화하며 버전 이력/전체 메타데이터 보존에는 적합하지 않다. +- MinIO bucket replication은 versioning을 전제로 하고, DR 상황에서 `resync`를 지원한다. + +## RPO / RTO 선언 + +모든 백업 대상은 아래 세 줄을 runbook에 먼저 적는다. + +- **RPO (Recovery Point Objective)**: 허용 가능한 데이터 손실 시간 +- **RTO (Recovery Time Objective)**: 허용 가능한 복구 시간 +- **Retention**: 백업 보존 기간 + +이 세 값이 비어 있으면 도구/스케줄을 선택할 수 없다. + +기본 tier 예시: + +| Tier | RPO | RTO | Retention | 대표 도구 | +|---|---|---|---|---| +| gold | 5분 | 30분 | 30일 | CNPG continuous WAL + CSI snap 매일 | +| silver | 1시간 | 2시간 | 14일 | Velero hourly + CSI snap | +| bronze | 24시간 | 24시간 | 90일 | Velero daily FSB | +| archive | 24시간 | 72시간 | 7년 | Velero weekly → Glacier / cold bucket | + +## 기본 규칙 + +### 1. 백업 대상은 세 층으로 분리 +1. **선언형 원본 (Git)**: Kustomize base/overlay, Helm values, Argo CD Application, 운영 문서/runbook, 스크립트 +2. **제어 평면 (K8s API state)**: K3s etcd snapshot / Velero API object backup +3. **상태 저장 데이터 (data plane)**: PVC / VolumeSnapshot, PostgreSQL 물리 백업, Vault raft snapshot, MinIO object data + +이 셋을 하나의 방식으로 뭉뚱그리지 않는다. 특히 **K3s etcd snapshot은 (3)을 커버하지 않는다.** + +### 2. Git은 배포 원본 백업이지 런타임 상태 백업이 아니다 +Git/Kustomize는 source of truth지만 runtime DB state, Vault secret state, MinIO object data, K3s cluster membership state를 복원해 주지 않는다. Git 백업만으로 운영 복구가 된다고 판단하지 않는다. + +### 3. K3s etcd snapshot은 제어 평면 전용 백업 +K3s etcd snapshot은 API server의 선언 상태만 백업한다. PVC 안의 파일 내용은 포함하지 않는다. + +기본: +- scheduled etcd snapshot 사용 (예: 6시간마다) +- local retention + S3/off-node retention 병행 +- snapshot에는 secrets encryption key와 CA private key가 포함될 수 있으므로 민감 정보로 취급 +- 저장 위치 암호화, 접근 통제, 보존 기간 통제, chain of custody 확인 +- 새 호스트 복구용 server token을 별도 위치에 보관 (같은 곳에 두면 동시 유출 위험) + +### 4. 상태 저장 데이터 백업은 Velero가 표준 +Kubernetes 수준에서 PVC / 네임스페이스 / CRD를 함께 백업/복구하려면 **Velero**를 기본 도구로 둔다. + +기본 구성: +- `BackupStorageLocation`: off-cluster S3 또는 cluster 외부 MinIO (같은 cluster 안 MinIO에 백업하지 않는다) +- `VolumeSnapshotLocation`: CSI driver 대응 +- `Schedule` CRD로 cron 기반 정기 백업 +- selector(`labelSelector`, `includedNamespaces`)로 tier별 스케줄 분리 +- TTL로 retention 관리 + +### 5. Velero FSB(kopia/restic) vs CSI snapshot 선택 +- **CSI snapshot**: 볼륨 수준 crash-consistent, 빠름, CSI driver 지원 필요. DB처럼 큰 볼륨에 적합. 클라우드 snapshot cost 고려. +- **File System Backup (FSB, kopia/restic)**: 파일 수준, 모든 볼륨에서 동작, 암호화/중복제거, 느림. local-path / hostPath / 비-CSI 볼륨에 적합. + +운영 기본: +- CSI snapshot이 가능한 볼륨은 CSI snapshot 우선 +- 크지 않은 설정/아카이브 볼륨은 FSB 허용 +- DB 볼륨은 CSI snapshot이어도 app-consistent hook(pre/post backup) 필요 + +### 6. 오프-클러스터 백업 저장소 필수 +백업을 **같은 K8s 클러스터 안**의 MinIO/S3에 두지 않는다. cluster 장애 = 백업 동시 소실이다. + +기본: +- 별도 리전/별도 account의 S3-호환 object storage +- bucket versioning 활성화 +- object-lock / WORM (규제 필요 시) +- 접근은 IRSA / Workload Identity / 최소권한 IAM + +### 7. VolumeSnapshotClass `deletionPolicy`는 정책에 맞춘다 +운영 gold tier 데이터의 VolumeSnapshotClass는 `deletionPolicy: Retain`을 기본으로 둔다. +이렇게 하면 K8s에서 VolumeSnapshot object가 지워져도 CSI driver의 실제 snapshot(VolumeSnapshotContent)은 남아서 사고 복구 여지를 준다. + +### 8. PostgreSQL은 logical / physical / continuous를 구분 +- `pg_dump` — logical export. 선택적 export, schema 비교, 마이그레이션 준비용. 프로덕션 전체 복구 기본값으로 두지 않는다. +- `pg_basebackup` — standalone base backup. 소규모/단순 케이스에 적합하지만 WAL archiving을 직접 구성해야 한다. +- **pgBackRest / WAL-G** — 프로덕션 표준. incremental / differential backup, parallel restore, retention, PITR, S3 업로드를 내장. +- **CloudNativePG (CNPG)** — K8s-native Postgres operator. 내장 **Barman Cloud**로 object storage에 WAL + base backup을 지속 업로드. `Backup` / `ScheduledBackup` CRD 제공. + +### 9. K8s 위 Postgres 운영 기본은 CloudNativePG +2026 기준 K8s 상에서 Postgres를 운영한다면 **CloudNativePG (CNPG)**를 기본 후보로 둔다 (CNCF sandbox). + +이유: +- `Cluster` CRD로 primary + standby 자동 관리, failover, rolling upgrade +- `backup` 섹션에서 Barman Cloud 기반 continuous archiving을 선언만 하면 동작 +- `Backup` (on-demand), `ScheduledBackup` (cron), PITR restore가 `Cluster.spec.bootstrap.recovery`로 표준화 +- Prometheus `PodMonitor` 내장 + +대안: +- **Zalando postgres-operator** — 오래된 생태계, Spilo 기반 +- **Crunchy PGO** — 상용 지원 강점, pgBackRest 내장 + +manual StatefulSet + sidecar는 1000개 서비스 규모에서는 권장하지 않는다. + +### 10. Keycloak DB는 애플리케이션과 분리된 DB 전략을 따른다 +Keycloak이 외부 PostgreSQL을 사용하면 Keycloak 복구는 애플리케이션 Pod 복구보다 DB 백업 전략에 크게 의존한다. Keycloak server manifest만 백업해서는 충분하지 않다. + +### 11. Vault는 storage mode에 따라 백업 방식을 다르게 본다 +- integrated storage (raft) → `vault operator raft snapshot save` 기본 +- external storage (Consul 등) → 해당 백엔드 백업 전략 +- dev mode → 운영 대상 아님 + +Vault snapshot 복구 테스트는 격리된 네트워크/환경에서 수행한다 (live credential revoke, 원치 않는 cluster 간 통신, 데이터 일관성 훼손 방지). + +### 12. MinIO는 PVC snapshot만으로 충분하다고 보지 않는다 +object store는 단순 PV 파일 복사 관점보다 object versioning / replication / resync 포함 전략으로 본다. + +기본: +- bucket versioning enabled +- 소스/대상 cluster replication configured +- DR 시 `mc replicate resync` 절차 문서화 +- `mc mirror`는 현재 객체 동기화 용도로만 제한 (버전 이력 보존 안 됨) +- 스토리지 layer snapshot은 보조 수단 + +### 13. stateless workload는 데이터보다 재현성을 백업 +다음은 기본적으로 런타임 파일 백업 대상이 아니다. + +- auth-server, ingress-controller, stateless test-server +- 외부 DB 사용 Keycloak 서버 자체 + +복구 핵심: +- Git / Kustomize / Helm values +- Config / Secret source (Vault Secrets Operator 기준) +- 이미지 digest +- 운영 문서 + +### 14. migration-flyway는 산출물이 아닌 migration source를 백업 +Flyway Job 자체나 container 파일시스템/PVC는 backup 대상이 아니다. 중요한 것은: + +- migration script (Git) +- migration ordering + schema history table 상태 (DB 백업으로 포함) +- Flyway 실행 이력 (CI/CD 로그, Argo Rollout 기록) + +### 15. 모든 백업은 "주기 + 보존기간 + 저장 위치 + 암호화 + 무결성 검증 + 복구 테스트"를 갖춘다 +파일만 남기고 정책이 없는 것을 백업 전략으로 보지 않는다. 최소 메타데이터: + +- Schedule cron 또는 RPO +- Retention TTL +- 저장 위치 (버킷, prefix, 리전) +- 암호화 방식 (SSE-S3 / SSE-KMS / client-side) +- 무결성 검증 (checksum, Velero `backup describe`의 errors) +- 복구 테스트 cadence + 마지막 성공 일자 + +### 16. restore drill은 표준 운영 절차 +복구 가능한지 확인하지 않은 백업은 신뢰하지 않는다. + +기본 cadence: +- 제어 평면 (K3s etcd / Velero): 분기별 1회 +- DB physical restore + PITR: 월 1회 +- Vault raft restore: 분기별 1회 +- MinIO replication resync: 반기별 1회 + +기록 항목: +- 실행 일자 +- 실행자 +- 대상 snapshot/backup ID +- 실제 RTO / 확인된 RPO +- 발견된 issue +- 다음 drill 예정일 + +최근 90일 내 성공 기록이 없는 백업은 "신뢰할 수 있는 백업"으로 보지 않는다. + +### 17. 복구 단위는 컴포넌트별로 다르게 정의 +| 컴포넌트 | 복구 단위 | 기본 도구 | +|---|---|---| +| K3s control plane | cluster snapshot | k3s etcd-snapshot | +| K8s API object (namespace 단위) | Velero Backup | Velero | +| PostgreSQL cluster | DB cluster 전체 + PITR | CNPG Backup / pgBackRest | +| PostgreSQL single database | logical dump | `pg_dump` (보조) | +| Vault | raft snapshot | `vault operator raft snapshot` | +| MinIO | bucket / object / site | mc replication + resync | +| stateless apps | namespace/service redeploy | Argo CD + Git | +| PVC 일반 | VolumeSnapshot / Velero FSB | Velero + CSI | + +모든 것을 "서비스 단위" 또는 "PVC 단위" 하나로만 보지 않는다. + +### 18. 백업 구성은 Git으로 관리되고 GitOps sync된다 +Velero `Schedule`, `BackupStorageLocation`, `VolumeSnapshotClass`, CNPG `ScheduledBackup`은 Argo CD / Flux로 동기화한다. kubectl 수동 편집 금지. + +### 19. 백업과 복구는 다른 문서와 연결 +다음 문서와 항상 연결한다. + +- `storage-pvc.md` (PVC tier ↔ snapshot class) +- `db-and-migration.md` (DB 복구 전략) +- `operations-runbook-upgrade-rollback.md` (장애 시 절차) +- `config-and-secrets.md` (Vault 백업) + +## 현재 스택 기본 권장안 + +- **K3s control plane**: etcd scheduled snapshot (6h) + S3 off-node 보관, server token 별도 안전 보관 +- **PostgreSQL**: CloudNativePG + Barman Cloud (continuous WAL + daily base backup), RPO 5분 +- **Vault**: integrated storage → raft snapshot 매일, 격리 환경에서 분기 1회 drill +- **MinIO**: bucket versioning + 별도 region으로 replication, mc resync runbook +- **PVC 일반**: Velero Schedule (tier별 분리) + CSI VolumeSnapshot +- **auth-server / ingress-controller / stateless**: Git + Argo CD 재현 +- **migration-flyway**: migration source는 Git, DB 상태는 CNPG 백업에 포함 + +## 프로젝트 기준 요약 + +- 백업 대상은 선언형 원본 / 제어 평면 / 상태 저장 데이터로 분리 +- K3s etcd snapshot은 PVC 데이터를 포함하지 않음 — 별도 Velero 필수 +- Velero를 Kubernetes 백업 표준으로, off-cluster 저장소에 보관 +- PostgreSQL은 CNPG + Barman Cloud (또는 pgBackRest / WAL-G) — `pg_dump`는 보조 +- VolumeSnapshotClass `deletionPolicy`를 tier에 맞게 (운영은 Retain) +- RPO/RTO/Retention을 tier로 선언 +- restore drill은 분기/월 단위 표준 cadence + 최근 성공 일자 기록 +- 백업 구성은 GitOps로 관리 diff --git a/docs/standards/infra/config-and-secrets.md b/docs/standards/infra/config-and-secrets.md new file mode 100644 index 0000000..927f2c1 --- /dev/null +++ b/docs/standards/infra/config-and-secrets.md @@ -0,0 +1,223 @@ +# config / secrets 기준 + +## 목적 + +이 문서는 1000+ 서비스 운영 클러스터에서 +- 무엇을 ConfigMap에 두고 무엇을 Secret/Vault에 두는지 +- 민감정보를 어떤 경로로 Pod에 주입하는지 (VSO / ESO / CSI / SealedSecrets / SOPS) +- Kubernetes Secret at-rest encryption을 어떻게 구성하는지 +- Image registry credential은 어떻게 다루는지 +를 단일 ground truth로 고정한다. + +이 문서의 목표는 다음과 같다. + +- 민감정보가 manifest/Git/image/log 어디에도 새지 않는다 +- Vault를 single source of truth로 두고 K8s Secret은 **파생 산출물**로만 존재 +- 주입 방식(envFrom/volume/CSI)과 source(VSO/ESO/Vault Injector)를 표준화 +- GitOps와 비밀 관리를 구조적으로 분리 + +## 공식 의미 (근거) + +- ConfigMap은 **비기밀** 데이터 저장용 API object. 최대 1MiB. +- Secret은 민감정보용 object. **data는 base64 encoded(암호화 아님)**. stringData는 생성 시 자동 base64. +- Secret은 기본적으로 etcd에 평문 저장(base64 decode가 암호화가 아님). Kubernetes는 **at-rest encryption을 운영에서 필수**로 권장. +- Secret 타입: `Opaque`, `kubernetes.io/tls`, `kubernetes.io/dockerconfigjson`, `kubernetes.io/service-account-token`, `bootstrap.kubernetes.io/token`, `kubernetes.io/basic-auth`, `kubernetes.io/ssh-auth`. +- **Vault Secrets Operator(VSO)**: Vault의 secret(KV v2, dynamic DB, PKI, AWS 등)을 Kubernetes Secret으로 sync하는 controller. CRD: `VaultConnection`, `VaultAuth`, `VaultStaticSecret`, `VaultDynamicSecret`, `VaultPKISecret`, `HCPVaultSecretsApp`. 앱은 그냥 K8s Secret을 `envFrom`/`volumeMounts`로 소비. +- **Vault Agent Injector**: Mutating webhook이 Pod에 sidecar/init container를 주입해 tmpfs에 비밀을 렌더링. K8s Secret을 **만들지 않는다**(Vault → file). +- **External Secrets Operator(ESO)**: AWS Secrets Manager, GCP Secret Manager, Azure Key Vault, Vault 등 **외부 provider → K8s Secret sync**. VSO와 유사하지만 멀티 provider. +- **CSI Secret Store Driver**: volume으로만 마운트(K8s Secret 미생성, optionally mirror). Azure Key Vault, AWS Secrets Manager, GCP Secret Manager, Vault provider 존재. +- **Sealed Secrets (Bitnami)**: public key로 암호화된 `SealedSecret` CRD를 Git에 커밋 → controller가 cluster private key로 복호화해 K8s Secret 생성. GitOps 친화적. +- **SOPS**: 파일 수준 암호화(age/GPG/KMS) + kustomize/Helm/Flux plugin. Git 커밋 가능. +- `EncryptionConfiguration`은 API Server `--encryption-provider-config` 플래그로 지정. providers: `identity`(평문), `aescbc`, `aesgcm`, `secretbox`, `kms` v1/v2. +- K3s는 `--secrets-encryption` 플래그로 aescbc provider 활성화. + +## 기본 규칙 + +### 1. 분류: ConfigMap vs Secret vs Vault +#### ConfigMap +- host/port/base path +- feature flag +- timeout/retry/batch size +- 공개 가능한 application config (`application.yaml` 비기밀 부분) +- log level +- probe 관련 non-secret 설정 + +#### Kubernetes Secret (하지만 **Vault 파생**이 기본) +- DB password, OAuth client secret, signing key, API token +- TLS 인증서 (cert-manager가 자동 생성) +- imagePullSecret(`kubernetes.io/dockerconfigjson`) +- VSO/ESO가 sync한 Secret + +#### Vault (source of truth) +- 모든 운영 credential의 1차 저장소 +- DB dynamic credentials, PKI, transit encryption keys +- OIDC client secret, SMTP credential +- KV v2 path로 서비스별 격리 + +**원칙:** "조금이라도 민감하면 Vault/Secret 쪽". ConfigMap에는 절대 비밀 넣지 않는다. base64는 암호화가 아니다. + +### 2. Kubernetes Secret at-rest encryption 필수 +운영 클러스터는 API Server `--encryption-provider-config`로 Secret 자원을 암호화한다. 권장 순서: **KMS v2 > KMS v1 > aescbc > identity(금지)**. + +```yaml +apiVersion: apiserver.config.k8s.io/v1 +kind: EncryptionConfiguration +resources: + - resources: ["secrets"] + providers: + - kms: + apiVersion: v2 + name: platform-kms + endpoint: unix:///var/run/kmsplugin/socket.sock + cachesize: 1000 + timeout: 3s + - aescbc: + keys: + - name: fallback-2026-q1 + secret: <32-byte base64 key> + - identity: {} +``` + +- KMS 소켓/플러그인은 노드 hardening 대상. +- K3s는 `curl -sfL https://get.k3s.io | INSTALL_K3S_EXEC="server --secrets-encryption" sh -` 또는 config.yaml `secrets-encryption: true`. +- 기존 Secret은 `kubectl get secrets --all-namespaces -o json | kubectl replace -f -`로 강제 재암호화. +- 키 회전은 `kube-apiserver` restart + `replace` 절차를 ADR로 고정. + +### 3. Secret delivery 경로 우선순위 +1. **VSO** — 운영 기본. Vault KV v2/dynamic credential → K8s Secret → envFrom/volume. 앱 코드 변경 0. +2. **ESO** — 멀티 클라우드 provider 필요 시. API는 VSO와 유사하지만 `SecretStore`/`ClusterSecretStore` + `ExternalSecret`. +3. **CSI Secret Store Driver** — K8s Secret object를 아예 만들고 싶지 않을 때(volume only). SA별 scope가 필요한 sensitive mount. +4. **Vault Agent Injector** — 앱이 template engine을 필요로 할 때(JSON/XML 포맷 렌더링). K8s Secret 없음. +5. **SealedSecrets / SOPS** — GitOps 전용 + 소규모 클러스터 + VSO 미도입 환경. Git에 encrypted blob 커밋. +6. **Plain Secret manifest** — 운영 금지. 로컬/부트스트랩 한정. + +선택 기준: +- "Vault가 SoT이고 K8s Secret을 앱이 envFrom으로 소비" → VSO +- "멀티 클라우드/비-Vault provider" → ESO +- "K8s Secret 자체를 만들고 싶지 않음(audit/scope)" → CSI +- "앱이 Vault template로 renderng 필요" → Vault Agent Injector +- "Vault 없음 + Git에 커밋해야 함" → SealedSecrets/SOPS + +### 4. VSO CRD 사용 표준 +VSO는 Helm으로 `vault-secrets-operator` namespace에 설치되어 있다고 가정한다. + +- `VaultConnection` (namespace or cluster) — Vault address, CA bundle, TLS skipVerify=false +- `VaultAuth` — auth method(kubernetes, jwt, approle). kubernetes auth 기본. +- `VaultStaticSecret` — KV v2 secret → K8s Secret +- `VaultDynamicSecret` — Postgres/MySQL/AWS dynamic credentials +- `VaultPKISecret` — PKI engine → `kubernetes.io/tls` Secret +- `HCPVaultSecretsApp` — HCP Vault Secrets 소비 + +모든 CRD는 같은 namespace 안에서 선언하고, 결과 Secret의 이름은 서비스명 규칙을 따른다. + +### 5. Vault path 규칙 + auth policy +- KV v2 path: `kv/data/<team>/<service>/<env>/<component>` (예: `kv/data/identity/auth-server/prod/db`) +- Vault role은 namespace + service account로 제한: + ``` + bound_service_account_names=auth-server + bound_service_account_namespaces=auth-prod + ``` +- Vault policy는 `path "kv/data/identity/auth-server/prod/*" { capabilities = ["read"] }` 수준으로 scope. +- dynamic credential TTL은 pod 수명과 맞춘다(예: Postgres role 24h, auto-renew). + +### 6. 주입 방식: envFrom vs volume +- **envFrom** — 전체 Secret의 key를 env로 투사. 간단, 12-factor 친화. 하지만 프로세스 env는 sub-process 상속, `/proc/<pid>/environ` 노출 위험. +- **volume** — 파일로 마운트(`/var/run/secrets/<name>`). 권장 in-memory(`readOnly: true`). 민감 key는 volume 우선. +- **envFrom + volume 혼합** 허용(db env는 env, signing key는 volume). +- **subPath**는 사용 금지(Secret 업데이트가 자동 반영 안 됨). + +### 7. 한 Pod 내에서도 필요한 컨테이너에만 주입 +- sidecar(metrics, proxy)에는 secret 전달 금지. +- Pod `volumes`로 선언하더라도 각 컨테이너 `volumeMounts`는 필요한 컨테이너에만. + +### 8. Secret/ConfigMap naming +- 패턴: `<service>-<purpose>` (`auth-server-db`, `auth-server-oidc-client`, `keycloak-db`). +- 금지: `common-*`, `shared-*`, `global-*` (스코프가 불분명하고 권한 팽창 원인). + +### 9. immutable Secret/ConfigMap +- 변경 빈도 낮은 `kubernetes.io/tls`, 앱 release-tied config는 `immutable: true` 검토. +- immutable이면 수정 불가 → 삭제 후 재생성 + rollout 필요. VSO가 갱신하는 Secret은 immutable 금지. + +### 10. Kustomize generator 사용 기준 +- `configMapGenerator` — 비기밀 설정에 허용. hash suffix로 rollout 트리거. +- `secretGenerator` — **운영 금지**. 로컬/테스트/부트스트랩 한정. +- 운영은 VSO/ESO/SealedSecrets 경로. + +### 11. Image pull secret +- 타입: `kubernetes.io/dockerconfigjson`. +- 구조: + ```json + { + "auths": { + "registry.example.com": { + "username": "ci-bot", + "password": "<token>", + "auth": "<base64(username:password)>" + } + } + } + ``` +- SA의 `imagePullSecrets`에 연결 → Deployment마다 반복 선언 불필요. +- Registry credential 자체도 VSO로 Vault → `kubernetes.io/dockerconfigjson` Secret sync(VSO `VaultStaticSecret.destination.type: kubernetes.io/dockerconfigjson`). + +### 12. image 지정: digest pin 기본 +- mutable tag(`latest`, `main`, `dev`)는 `imagePullPolicy: Always` + staging 환경에만. +- 운영은 `image: registry.example.com/auth-server@sha256:<digest>` 고정. `imagePullPolicy: IfNotPresent` 충분. +- digest는 CI가 release 시 생성하고 GitOps manifest(ArgoCD)에 커밋. +- Kyverno/Gatekeeper로 namespace `auth-prod`의 Pod image가 `@sha256:`를 포함하도록 enforce. + +### 13. Secret 접근 RBAC +- `secrets` 리소스의 `list`/`watch`는 controller(VSO, cert-manager, argo-cd)에만 허용. +- 일반 workload는 `get` + `resourceNames` 배열로 제한. +- 같은 namespace에서 Pod 생성 권한은 Secret 간접 접근이 될 수 있음을 전제로 RBAC 설계(namespace 분리). + +### 14. 민감정보 로깅/에러 보호 +- 앱은 비밀을 평문 로그, 예외 메시지, telemetry attribute, debug endpoint에 포함 금지. +- Exception handler는 `password`, `token`, `secret`, `authorization` 포함 필드 자동 redact. +- APM/Logging pipeline에도 scrub rule 추가. + +### 15. Secret 회전 +- dynamic credential: VSO `VaultDynamicSecret`이 TTL 전에 자동 renew/rotate + Pod rollout trigger(`rolloutRestartTargets`). +- static credential: VSO `refreshAfter` + Vault rotate cron + `rolloutRestartTargets`로 Deployment 자동 rolling. +- TLS cert: cert-manager가 `renewBefore`에 맞춰 회전. Pod는 `reloader` annotation 또는 webhook으로 rollout. + +### 16. 설정 타입과 도메인 타입 분리 +- `@ConfigurationProperties` / `application.yaml`은 설정 계약. +- 도메인 Value Object는 config에서 복사하되 config 타입을 도메인에 노출하지 않는다. +- 테스트에서는 config를 직접 주입할 수 있어야 한다(포트 바인딩, spring profile). + +### 17. 환경별 overlay +- `base/` — 공통 ConfigMap/Service/Deployment/RBAC +- `overlays/{dev,staging,prod}/` — 환경별 patch(`replicas`, `resources`, `image digest`, `ingress host`) +- Secret은 **overlay에 plain 저장 금지**. VSO CRD도 prod overlay에서 Vault mount path만 override. + +### 18. 현재 스택 기본 권장안 + +#### auth-server / test-server / keycloak +- ConfigMap: `application.yaml` 비기밀 +- Secret 경로: VSO `VaultStaticSecret`(OIDC client) + `VaultDynamicSecret`(Postgres role) +- 주입: envFrom(DB creds) + volume(signing key 파일) + +#### migration-flyway +- short-lived Job +- SA token automount false +- VSO `VaultDynamicSecret`이 migration 전용 Postgres role을 짧은 TTL로 발급 + +#### vault +- Vault server 자체의 unseal key는 cluster 밖(HSM/KMS/cloud KMS auto-unseal) +- bootstrap token은 `vault-bootstrap` namespace에 at-rest encrypted Secret으로 저장, 사용 후 삭제 + +#### registry +- `kubernetes.io/dockerconfigjson` Secret은 VSO로 Vault KV에서 sync +- namespace SA `imagePullSecrets`에 연결 + +## 프로젝트 기준 요약 + +- ConfigMap = 비기밀, Secret = 민감정보, Vault = source of truth +- Kubernetes Secret at-rest encryption(KMS 우선, aescbc 최소) 필수 +- Secret delivery 우선순위: VSO > ESO > CSI > Vault Agent Injector > SealedSecrets/SOPS +- 운영 Secret generator/plain Secret manifest 금지 +- image는 digest pin + private registry, `kubernetes.io/dockerconfigjson` Secret은 SA imagePullSecrets 연결 +- Secret 주입은 필요한 컨테이너/필요한 key만, env보다 volume 우선 +- RBAC은 namespace Role + `resourceNames` + list/watch controller 전용 +- 회전은 VSO/cert-manager + rolloutRestart 자동화 diff --git a/docs/standards/infra/db-and-migration.md b/docs/standards/infra/db-and-migration.md new file mode 100644 index 0000000..7828939 --- /dev/null +++ b/docs/standards/infra/db-and-migration.md @@ -0,0 +1,305 @@ +# db / migration 기준 + +## 목적 + +이 문서는 Kubernetes 상의 PostgreSQL과 Flyway를 기준으로 +- 데이터베이스를 어떻게 나눌지 +- 어떤 operator / 도구로 운영할지 (CNPG, Zalando, Crunchy, self-managed StatefulSet) +- migration을 어디서 어떻게 실행할지 +- migration과 배포(Helm / Argo CD)의 순서를 어떻게 보장할지 +- validate / migrate / rollback / backup / PITR을 어떤 순서로 볼지 +- zero-downtime을 위한 expand-migrate-contract를 어떻게 적용할지 +를 먼저 고정한다. + +이 문서의 목표는 다음과 같다. + +- app rollout과 schema 변경을 분리한다 +- Keycloak DB와 auth-server DB 경계를 먼저 고정한다 +- Flyway를 앱 시작 로직에 숨기지 않는다 +- PostgreSQL backup/restore 전략과 migration 전략을 함께 본다 +- 1000+ 서비스 규모에서 일관된 migration Job 표준을 만든다 + +## 공식 의미 + +- `pg_dump`는 logical export다. 정기 production 전체 백업 기본값으로는 보통 적합하지 않다. +- `pg_basebackup`은 실행 중인 PostgreSQL cluster의 base backup을 만들며 PITR/standby 시작점으로 쓴다. +- PostgreSQL PITR은 base backup + WAL archiving 결합이다. +- 운영 표준 물리 백업 도구: **pgBackRest**, **WAL-G**. 또는 operator-native (CNPG Barman Cloud, Crunchy PGO). +- PostgreSQL의 **대부분 DDL은 트랜잭션 내에서 실행 가능**하지만, `CREATE INDEX CONCURRENTLY`, `REINDEX CONCURRENTLY`, `ALTER TYPE ... ADD VALUE`, `VACUUM`은 트랜잭션 밖에서만 실행된다. +- CloudNativePG operator는 CNCF Sandbox 프로젝트로 K8s-native Postgres 운영 표준 후보다. +- Flyway `validate`는 적용된 migration과 로컬 migration 사이의 이름/타입/checksum 차이, 로컬에 없는 적용 버전, 아직 적용되지 않은 로컬 버전을 검증한다. +- Flyway `validateOnMigrate` 기본값은 `true`, `cleanDisabled` 기본값은 `true` (Flyway 9+). +- Flyway `migrate`는 schema history table을 자동 생성하고 최신 migration까지 적용한다. +- Flyway Community(OSS)는 **undo(U__) migration을 지원하지 않는다.** Undo는 Teams/Enterprise 전용이다. +- Flyway는 migration 실행 시 schema history table에 advisory lock을 걸어 동시 실행을 방지한다. + +## RPO / RTO + +모든 DB는 다음을 runbook에 먼저 적는다. + +- RPO / RTO / Retention +- 복구 목표 (cluster restore / PITR / standby seed) +- 운영 tier (gold / silver / bronze) + +이 값들이 없으면 backup 도구 선택이 되지 않는다. `backup-restore.md` 참조. + +## 기본 규칙 + +### 1. DB 경계는 애플리케이션 경계보다 먼저 고정 +다음을 명시적으로 정한다. + +- Keycloak DB와 auth-server DB를 **물리적 cluster**로 분리할지, 같은 cluster 내 **logical DB / schema**로 분리할지 +- test-server가 DB를 가지는지 +- migration 소유권이 누구에게 있는지 (보통 서비스 팀) + +기본: +- 인증 critical data (Keycloak)와 앱 data (auth-server)는 **cluster 분리 권장** +- 같은 cluster를 쓰더라도 database / role / schema ownership을 섞지 않음 +- 한 migration tool/job이 여러 서비스 schema를 동시에 소유하지 않음 + +### 2. K8s 위 Postgres 운영 기본은 operator +1000+ 서비스 규모에서 self-managed StatefulSet은 운영 부담이 너무 크다. Operator를 기본 후보로 둔다. + +우선순위 (2026 기준): +1. **CloudNativePG (CNPG)** — CNCF Sandbox, K8s-native, Barman Cloud 내장, `Cluster` / `Backup` / `ScheduledBackup` CRD +2. **Crunchy PGO** — 상용 지원, pgBackRest 내장 +3. **Zalando postgres-operator** — Spilo 기반, 레거시 환경 + +operator를 쓰면 자동으로 얻는 것: +- primary/standby 구성 + failover +- rolling minor upgrade +- WAL archiving + continuous backup +- pg_basebackup, PITR, replica re-clone +- PodMonitor 연동 + +### 3. migration은 앱 startup에 숨기지 않는다 +Flyway migration은 **독립 실행 단계**다. + +기본: +- `validate` → (필요시 `info`) → `migrate` → app rollout + +기본 금지: +- app container 시작 시 자동 migration (Spring Boot `spring.flyway.enabled=true` + `@SpringBootApplication` 부팅 시 migrate) +- readiness/liveness와 migration 실패를 섞는 구조 +- "서버가 뜨면 알아서 schema를 맞춘다" 방식 + +### 4. Flyway 실행 기본값은 Kubernetes Job +운영 환경에서 Flyway는 다음 중 하나로만 실행한다. + +- Kubernetes Job (권장) +- CI/CD 명시 단계 +- 운영자 명시 실행 절차 + +장기 실행 Deployment에 넣지 않는다. Flyway 예시는 `examples/infra/flyway.md` 참조. + +### 5. migration Job은 배포 흐름 안에서 app보다 먼저 실행 +migration을 app보다 **선행**시키는 것은 manifest 메타데이터로 선언한다. + +패턴 A — Helm hook: +```yaml +metadata: + annotations: + "helm.sh/hook": "pre-upgrade,pre-install" + "helm.sh/hook-weight": "-10" + "helm.sh/hook-delete-policy": "before-hook-creation,hook-succeeded" +``` + +패턴 B — Argo CD sync wave + hook: +```yaml +metadata: + annotations: + argocd.argoproj.io/sync-wave: "-1" + argocd.argoproj.io/hook: Sync + argocd.argoproj.io/hook-delete-policy: BeforeHookCreation +``` + +기본: +- migration은 sync wave가 app보다 **작은** 값 (먼저 실행) +- app Deployment는 wave `0` 또는 그 이상 +- Helm과 Argo CD를 혼용하는 경우 **한 쪽으로 통일** (둘 다 hook을 걸면 순서가 꼬인다) + +### 6. migration Job 안전 설정 +모든 migration Job은 다음을 명시한다. + +- `parallelism: 1` — 병렬 실행 금지 (Flyway advisory lock이 막아주지만, Job 수준에서도 명시) +- `completions: 1` +- `backoffLimit: 0` 또는 작은 값 (1~2) — 실패 시 무한 재시도 금지 +- `activeDeadlineSeconds` — 타임아웃 (예: 1800) +- `ttlSecondsAfterFinished` — 완료 후 자동 정리 (예: 86400) +- `restartPolicy: Never` +- 이미지는 **digest pinning** (`flyway/flyway@sha256:...`) +- `resources.requests/limits` 명시 +- `securityContext` restricted PSA 준수 + +### 7. validate를 먼저, migrate를 나중에 +운영 절차 기본 순서: +1. `flyway info` (pending migration 확인) +2. `flyway validate` +3. `flyway migrate` +4. `flyway info` (결과 확인) +5. app rollout + +`validateOnMigrate=true` 기본값이 있더라도, 운영 runbook에서는 validate 단계를 **분리 Job** 또는 **initContainer**로 분리한다. `examples/infra/flyway.md` 참조. + +### 8. migration source는 Git이 source of truth +중요한 것은 아래다. + +- versioned migration script (`V__`) +- repeatable migration script (`R__`) +- migration ordering +- schema history table 상태 + +기본 금지: +- 운영 서버에서 migration 파일 수동 수정 +- 적용된 migration 파일을 사후 편집 (checksum mismatch) +- Flyway schema history table을 사람이 직접 UPDATE/DELETE + +### 9. Flyway undo(U__)는 쓰지 않는다 +Flyway Community(OSS)는 **U__ 파일을 지원하지 않는다**. Teams/Enterprise에서만 `undo` 명령이 동작한다. + +기본: +- undo migration 파일을 만들지 않음 +- rollback은 forward-only migration + PITR로 수행 +- 운영 기본은 "다음 migration으로 앞으로 수정" + +### 10. DB backup 전략과 migration 전략을 같이 본다 +schema 변경이 production에 들어간다면, 같은 변경 계획 안에 아래가 같이 있어야 한다. + +- rollback 가능 여부 +- 변경 직전 backup 시점 (예: on-demand CNPG `Backup` 실행) +- restore 단위 (전체 cluster / logical DB) +- PITR 필요 여부 + targetTime 후보 +- migration 실패 시 중단 지점 (어느 V__에서 멈췄는지) + +### 11. PostgreSQL 운영 기본 백업은 continuous physical backup +운영 기본 복구 목표가 cluster-level restore / PITR / standby seed 중 하나면 continuous WAL archiving + base backup이 기본이다. + +도구 선택: +- K8s + CNPG → Barman Cloud (내장) +- K8s + Crunchy → pgBackRest (내장) +- 자체 운영 → pgBackRest 또는 WAL-G + +`pg_dump`는 다음 용도로 제한: +- 선택적 logical export +- 로컬/테스트 seed +- 일부 schema/table 보존 +- migration 검증용 비교 데이터 + +### 12. PITR 필요 여부를 초기에 결정 +다음 질문에 "예"면 PITR을 우선 검토한다. + +- 잘못된 migration/DDL을 특정 시점 직전으로 되돌려야 하는가 +- 운영 데이터 손실 허용 시간이 짧은가 (RPO < 1h) +- 인증 관련 데이터 정합성이 중요한가 + +### 13. schema ownership은 서비스별로 분리 +기본: +- auth-server schema는 auth-server 팀이 소유 +- keycloak schema는 keycloak이 소유 +- 공용 schema 남발 금지 +- "편해서" 하나의 migration 프로젝트로 통합 관리 금지 + +### 14. Flyway history table 전략을 먼저 고정 +초기에 결정: + +- `flyway.table` (기본 `flyway_schema_history`) +- `flyway.defaultSchema` +- `flyway.schemas` +- `flyway.createSchemas` +- 필요 시 `flyway.initSql` + +기본: +- history table을 service별 schema에 배치 (예: `auth_server.flyway_schema_history`) +- 여러 서비스의 history table을 하나의 schema에 몰지 않음 + +### 15. baseline / repair는 예외 절차 +baseline과 repair는 정상 운영 흐름이 아니다. + +허용 예: +- legacy DB를 처음 Flyway 관리로 편입 (baseline) +- 의도적 migration 수정 후 공식 절차로 checksum 회복 (repair) +- history corruption 복구 (repair) + +기본 금지: +- CI/CD에서 습관적 baseline/repair +- validate 오류를 없애기 위해 무분별하게 repair + +### 16. migration은 forward-only를 기본값으로 +운영 기본값: +- 새 migration으로 앞으로 수정 +- rollback용 SQL을 미리 기대하지 않음 +- 실패 시 restore/PITR 또는 다음 migration으로 교정 + +### 17. Keycloak DB와 auth-server DB는 따로 본다 +둘 다 PostgreSQL을 써도 운영 기준은 별도로 둔다. + +- migration 파이프라인 분리 +- backup/restore 영향도 분리 +- schema/table ownership 분리 +- 버전 업그레이드 절차 분리 +- Keycloak은 자체 migration을 내장하므로 **Flyway로 관리하지 않는다** + +### 18. test-server는 DB를 기본 전제로 두지 않는다 +test-server가 DB 연결이 없으면 migration 대상 아님, DB secret 불필요, rollout 절차도 DB 의존 없이 단순화된다. + +### 19. destructive migration은 expand → migrate → contract +Zero-downtime을 위한 3단계 릴리즈: + +1. **Expand** — 새 컬럼/테이블 추가 (NULL 허용 또는 default 값 있음). 기존 앱 호환. +2. **Migrate** — 앱을 새 스키마 기준으로 배포 + 데이터 backfill. +3. **Contract** — 기존 컬럼/테이블/제약 제거. 한 릴리즈 이상 뒤. + +각 단계는 **별도 릴리즈**로 나간다. 같은 릴리즈에서 expand와 contract를 같이 하지 않는다. + +인증/권한/토큰 관련 테이블은 특히 보수적으로. + +### 20. DB 변경은 애플리케이션 호환성 윈도우를 고려 +migration 문서는 다음을 포함한다. + +- 이전 앱 버전과 호환 여부 +- 새 앱 버전과 호환 여부 +- 중간 배포 구간에서 허용되는 상태 (N-1 ↔ N 동시 운영 가능 여부) +- 롤백 시 DB가 이미 바뀐 상태일 때의 대응 + +### 21. 대용량 / long-running DDL은 트랜잭션 밖에서 +Postgres에서 다음은 트랜잭션 밖에서 실행해야 한다. + +- `CREATE INDEX CONCURRENTLY` +- `REINDEX CONCURRENTLY` +- `ALTER TYPE ... ADD VALUE` (Postgres 12+에서는 트랜잭션 내에서도 제한적으로 가능) +- `VACUUM` + +Flyway에서는 해당 migration 파일 상단에 다음을 적는다: +```sql +-- flyway:executeInTransaction=false +CREATE INDEX CONCURRENTLY idx_users_email ON users(email); +``` + +### 22. restore 테스트 없는 backup/migration 전략 금지 +다음은 반드시 drill이 있어야 한다. + +- PostgreSQL base backup 복구 +- WAL/PITR 절차 +- Flyway 적용 후 실패 시 중단 및 복구 절차 +- Keycloak/auth-server 개별 DB restore 절차 + +## 현재 스택 기본 권장안 + +- **auth-server DB**: CNPG `Cluster` 3 instances, Barman Cloud, RPO 5분, Flyway Job으로 migration +- **keycloak DB**: CNPG `Cluster` 별도, Keycloak 자체 migration (Flyway 밖) +- **test-server**: DB 없음 +- **migration-flyway**: Kubernetes Job (Helm/Argo hook), 앱 Deployment보다 먼저 실행 +- **backup**: CNPG Barman continuous WAL + daily base backup, `pg_dump`는 보조 + +## 프로젝트 기준 요약 + +- app rollout과 migration을 분리 (app startup migration 금지) +- Postgres on K8s는 CNPG operator를 기본 후보로 +- migration Job은 Helm hook 또는 Argo CD sync wave로 app보다 먼저 실행 +- migration Job은 `parallelism: 1`, `backoffLimit: 0`, digest pinning, restricted PSA +- Flyway undo(U__) 파일 만들지 않음 (OSS 미지원) +- validate → migrate → app rollout 순서 +- CNPG Barman Cloud (또는 pgBackRest / WAL-G) 물리 백업 + PITR, `pg_dump`는 보조 +- schema ownership은 서비스별 분리 +- destructive migration은 expand → migrate → contract, 여러 릴리즈에 걸쳐 +- non-transactional DDL은 `-- flyway:executeInTransaction=false` diff --git a/docs/standards/infra/flyway.md b/docs/standards/infra/flyway.md new file mode 100644 index 0000000..410395b --- /dev/null +++ b/docs/standards/infra/flyway.md @@ -0,0 +1,304 @@ +# Flyway 기준 + +## 목적 + +이 문서는 PostgreSQL 기반 서비스에서 Flyway를 +- 어디서 실행할지 (Kubernetes Job) +- 어떤 순서로 실행할지 (validate / info / migrate / app rollout) +- 어떤 배포 흐름과 맞물릴지 (Helm hook / Argo CD sync-wave) +- config를 어떻게 공급할지 (env var + Secret via Vault Secrets Operator) +- schema history table을 어떻게 둘지 +- baseline / repair / out-of-order / undo를 어떻게 다룰지 +- non-transactional DDL을 어떻게 처리할지 +를 먼저 고정한다. + +이 문서의 목표는 다음과 같다. + +- Flyway를 앱 startup 내부 로직처럼 숨기지 않는다 +- validate / migrate / repair / baseline의 역할을 분리한다 +- schema history table을 운영 감사 추적의 일부로 본다 +- migration Job을 1000+ 서비스 규모에서 재현 가능하게 표준화한다 +- DB 변경을 애플리케이션 rollout과 분리해 운영한다 + +## 공식 의미 + +- Flyway `validate`는 적용된 migration과 로컬 migration 사이의 이름/타입/checksum 차이, 로컬에 없는 적용 버전, 아직 적용되지 않은 로컬 버전을 검증한다. +- `migrate`는 schema history table이 없으면 자동 생성하고 최신 migration까지 적용한다. +- schema history table은 migration 실행 내역, checksum, 성공/실패 상태를 기록하는 audit trail이다. +- `repair`는 schema history table을 수정하는 명령이며, 실패한 migration 엔트리 제거, checksum/description/type 재정렬, missing migration 삭제 표시를 수행한다. user object는 정리하지 않는다. +- schema history table 기본 이름은 `flyway_schema_history`다. +- schema history table 위치는 `table`, `defaultSchema`, `schemas`로 제어할 수 있다. +- `createSchemas=false`일 때 history table이 들어갈 schema가 미리 준비되지 않으면 migrate가 실패할 수 있다. +- 기존 non-empty schema에 Flyway를 도입할 때 history table이 없으면 `baseline` 또는 `baselineOnMigrate`가 필요할 수 있다. +- schema history에는 `Pending`, `Success`, `Missing`, `Out of Order`, `Outdated`, `Superseded`, `Deleted` 등 상태가 기록될 수 있다. +- Flyway는 migration 실행 중 schema history table에 **advisory lock**을 걸어 동시 실행을 직렬화한다. 다중 replica Job 수준의 race를 방지한다. +- `cleanDisabled`는 Flyway 9 이후 기본 `true`. production에서는 반드시 `true`를 명시한다. +- Flyway 8.2+ 에서 `-- flyway:executeInTransaction=false` directive로 migration 파일 단위 트랜잭션 비활성화가 가능하다. +- Flyway Community(OSS)는 **undo(U__) migration을 지원하지 않는다.** Undo는 Teams/Enterprise 상용 기능이다. +- 환경변수 config 지원: `FLYWAY_URL`, `FLYWAY_USER`, `FLYWAY_PASSWORD`, `FLYWAY_LOCATIONS`, `FLYWAY_SCHEMAS`, `FLYWAY_DEFAULT_SCHEMA`, `FLYWAY_TABLE`, `FLYWAY_BASELINE_ON_MIGRATE`, `FLYWAY_VALIDATE_ON_MIGRATE`, `FLYWAY_CLEAN_DISABLED`, `FLYWAY_OUT_OF_ORDER`, 그 외 `FLYWAY_*`. + +## 기본 규칙 + +### 1. Flyway는 앱 startup이 아니라 독립 실행 단계 +운영 환경에서 Flyway는 다음 중 하나로만 실행한다. + +- Kubernetes Job (권장) +- CI/CD 명시 단계 +- 운영자 명시 실행 절차 + +기본 금지: +- 애플리케이션 startup 시 자동 migration +- Spring Boot `spring.flyway.enabled=true`로 앱 부팅 경로에 포함 +- readiness/liveness와 migration 실패를 섞는 구조 + +### 2. 기본 순서는 info → validate → migrate → info → app rollout +운영 기본 순서: + +1. `flyway info` (pending 확인) +2. `flyway validate` +3. `flyway migrate` +4. `flyway info` (결과 확인) +5. 애플리케이션 rollout + +`validateOnMigrate=true`가 기본값이지만, 운영 절차상 validate를 **분리 initContainer** 또는 **사전 단계**로 둔다. + +### 3. 배포 흐름 안에서 app보다 먼저 실행 — 두 가지 패턴 + +**패턴 A: Helm hook** +```yaml +annotations: + "helm.sh/hook": "pre-upgrade,pre-install" + "helm.sh/hook-weight": "-10" + "helm.sh/hook-delete-policy": "before-hook-creation,hook-succeeded" +``` + +**패턴 B: Argo CD sync-wave** +```yaml +annotations: + argocd.argoproj.io/sync-wave: "-1" + argocd.argoproj.io/hook: Sync + argocd.argoproj.io/hook-delete-policy: BeforeHookCreation +``` + +기본: +- 두 패턴을 혼용하지 않는다 (Argo CD가 Helm chart를 렌더링할 때 Helm hook을 일반 리소스로 취급해 순서가 꼬임) +- 배포 도구에 맞춰 한쪽만 사용 + +### 4. migration Job 안전 설정 체크리스트 +Job manifest에 반드시 다음이 있어야 한다. + +- `parallelism: 1`, `completions: 1` +- `backoffLimit: 0` 또는 작은 값 (1~2) +- `activeDeadlineSeconds` (권장 1800 = 30분, 대형 migration은 더 길게) +- `ttlSecondsAfterFinished` (권장 86400 = 1일) +- `restartPolicy: Never` +- 이미지 digest pinning (`flyway/flyway@sha256:...`) +- `imagePullPolicy: IfNotPresent` +- `resources.requests/limits` +- `securityContext`: `runAsNonRoot: true`, `readOnlyRootFilesystem: true`, `capabilities: drop: [ALL]` +- Pod-level `seccompProfile: RuntimeDefault` +- `fsGroup` 명시 (필요 시) + +### 5. config는 환경변수 + Secret +Flyway CLI는 `FLYWAY_*` 환경변수를 읽는다. Secret은 Vault Secrets Operator(VSO) 또는 External Secrets Operator를 통해 클러스터에 동기화된 `Secret`에서 주입한다. + +필수 env var: +- `FLYWAY_URL` — `jdbc:postgresql://host:5432/db` +- `FLYWAY_USER` +- `FLYWAY_PASSWORD` — Secret에서 주입 +- `FLYWAY_LOCATIONS` — `filesystem:/flyway/sql` + +운영 권장 env var: +- `FLYWAY_SCHEMAS` — 대상 schema +- `FLYWAY_DEFAULT_SCHEMA` — history table 위치 +- `FLYWAY_TABLE` — 기본 `flyway_schema_history` +- `FLYWAY_VALIDATE_ON_MIGRATE=true` +- `FLYWAY_BASELINE_ON_MIGRATE=false` (운영 기본값) +- `FLYWAY_CLEAN_DISABLED=true` (production 필수) +- `FLYWAY_OUT_OF_ORDER=false` +- `FLYWAY_MIXED=false` + +### 6. `cleanDisabled=true`는 production 필수 +`flyway clean`은 모든 object를 drop 하는 파괴적 명령이다. + +- production: `FLYWAY_CLEAN_DISABLED=true` 반드시 명시 (Flyway 9+ 기본값이지만 명시적으로 선언) +- dev/test: 필요 시 `false` 허용, 단 접근 권한 분리 + +### 7. migration SQL은 ConfigMap 또는 이미지 레이어로 +옵션: +- **ConfigMap**: 서비스 manifest와 함께 Argo CD로 관리. small/medium migration set에 적합. ConfigMap 1MiB 제한 주의. +- **이미지 레이어**: 서비스 repo에서 migration SQL을 Docker image로 빌드하고 Flyway image와 합쳐 사용. 대규모 migration set에 적합. + +기본: +- 두 방식 모두 Git이 source of truth +- 운영 서버에서 `kubectl edit configmap`으로 migration 편집 금지 + +### 8. schema history table은 운영 감사 추적의 일부 +수동 UPDATE / DELETE 금지. 위치는 명시적으로 결정. + +기본: +- service별 schema를 `FLYWAY_DEFAULT_SCHEMA`로 지정 (예: `auth_server`) +- history table 이름은 기본값 `flyway_schema_history` 유지 +- 여러 서비스의 history table을 하나의 schema에 몰지 않음 + +### 9. `createSchemas=false`면 history schema를 사전 준비 +`createSchemas=false`를 쓰면 history table이 들어갈 schema를 별도 준비해야 한다. + +기본: +- `FLYWAY_INIT_SQL`로 `CREATE SCHEMA IF NOT EXISTS` 지시 가능 +- 또는 CNPG `Cluster.bootstrap.initdb.postInitSQL`에서 schema 사전 생성 +- 생성 책임이 누구인지 문서화 + +### 10. baseline은 예외 절차 +허용 예: +- legacy DB를 처음 Flyway 관리로 편입 +- 기존 non-empty schema를 Flyway에 편입할 때 + +기본 금지: +- 새 프로젝트인데 baseline부터 쓰기 +- 운영 배포 파이프라인에서 습관적으로 baseline 사용 + +### 11. `baselineOnMigrate`는 기본값 아님 +`baselineOnMigrate=true`는 도입/전환 시 편의를 줄 수 있지만, 운영 기본값으로 두지 않는다. + +이유: +- 예상치 못한 기존 schema를 "정상 상태"처럼 받아들일 수 있다 +- 실수 탐지력이 떨어진다 + +`FLYWAY_BASELINE_ON_MIGRATE=false`로 명시. + +### 12. `repair`는 예외 절차 +허용 예: +- 의도적으로 migration 파일을 수정했고 checksum 정렬이 필요 +- missing migration을 문서화된 절차로 정리 +- failed repeatable migration 이후 history 정리 + +기본 금지: +- validate 오류가 나면 원인 분석 없이 바로 repair +- CI/CD에서 습관적으로 repair 실행 + +### 13. `repair`는 user object를 고쳐주지 않는다 +repair는 schema history table만 정리한다. 실패한 migration이 남긴 DB object 정리, 불완전한 DDL/DML 정리는 별도 절차로 수행해야 한다. + +### 14. 적용된 migration 파일은 수정 금지 +이유: +- checksum mismatch +- 재현 불가 +- 환경 간 drift + +대응: +- 새 migration으로 교정 +- 정말 예외적인 수정만 공식 repair 절차와 함께 수행 + +### 15. out-of-order는 기본 금지 +Out-of-order migration은 전체 migration history를 다시 실행할 때 다른 결과를 만들 수 있다. + +기본: +- `FLYWAY_OUT_OF_ORDER=false` +- 뒤늦게 빠진 migration을 넣는 방식을 기본값으로 두지 않음 +- 예외 허용 시 영향 범위 검토 문서 필수 + +### 16. repeatable migration(R__)은 목적 제한 +Repeatable migration은 다음 용도에 제한한다. + +- view 정의 +- function / procedure +- trigger 재생성 +- reference / static data refresh + +기본 금지: +- 순서가 중요한 핵심 schema change를 repeatable로 남발 +- versioned migration 대신 repeatable로 대체 + +### 17. Undo(U__) migration은 만들지 않는다 +Flyway Community(OSS)는 undo를 지원하지 않는다. + +- U__ 파일을 repo에 두지 않음 (오해 유발) +- rollback은 forward-only 새 migration + PITR로 대응 + +### 18. locations는 environment별로 흔들지 않는다 +`migrate`와 `repair`는 같은 `locations` 전제를 가져야 한다. + +기본: +- env마다 location 구조가 달라지지 않게 유지 +- 운영과 개발에서 전혀 다른 migration set을 쓰지 않음 +- env별 변수는 `placeholders`(`FLYWAY_PLACEHOLDERS_*`)로 분리 + +### 19. migration은 서비스 소유권 단위로 분리 +기본: +- auth-server는 auth-server migration set +- keycloak은 keycloak 고유 migration (사실 Keycloak은 내부 migration을 사용하므로 Flyway 대상이 아님) +- 공용 migration 프로젝트 금지 + +### 20. migration naming / versioning +기본: +- versioned: `V<N>__<snake_case>.sql`, N은 증가하는 정수 또는 점표기(예: `V12__`, `V1.2.3__`) +- repeatable: `R__<snake_case>.sql` +- 이름은 변경 의도를 드러나게 작성 + +예: +- `V42__add_refresh_token_audit_columns.sql` +- `R__refresh_user_views.sql` + +### 21. destructive change는 expand → migrate → contract +`db-and-migration.md` #19 참조. Flyway 입장에서 각 단계는 **별도 릴리즈**의 versioned migration으로 나간다. + +### 22. non-transactional DDL은 `executeInTransaction=false` +Postgres에서 트랜잭션 밖 실행이 필요한 DDL: + +- `CREATE INDEX CONCURRENTLY` +- `REINDEX CONCURRENTLY` +- `ALTER TYPE ... ADD VALUE` +- `VACUUM` + +migration 파일 상단: +```sql +-- flyway:executeInTransaction=false +CREATE INDEX CONCURRENTLY idx_users_email ON users(email); +``` + +기본: +- 이런 DDL은 **전용 migration 파일**로 분리 (다른 statement와 섞지 않음) +- runtime 추정치 주석 +- low-traffic window로 배포 일정 조정 + +### 23. rollback은 Flyway 명령에 기대지 않는다 +운영 기본 rollback: + +- 새 migration으로 수정 +- PostgreSQL PITR (CNPG bootstrap.recovery) +- 애플리케이션 버전 rollback + DB 호환 윈도우 유지 (expand-contract의 효과) + +rollout undo가 DB schema rollback을 대신하지 않는다. + +### 24. 현재 스택 기준 기본 권장안 + +- **auth-server** + - Flyway Job (Helm hook 또는 Argo sync-wave) + - `FLYWAY_DEFAULT_SCHEMA=auth_server` + - validate → migrate → app rollout + - digest pinning +- **keycloak** + - Keycloak 자체 migration 사용, Flyway 대상 아님 +- **test-server** + - DB가 없으면 Flyway 대상 아님 +- **운영 절차** + - repair/baseline은 예외 승인 절차 + - applied migration 수정 금지 + - CLEAN_DISABLED=true 필수 + +## 프로젝트 기준 요약 + +- Flyway는 독립 실행 단계 (Kubernetes Job) +- info → validate → migrate → info → app rollout +- 배포 흐름 내 순서는 Helm hook 또는 Argo CD sync-wave 중 하나로 통일 +- Job: `parallelism: 1`, `backoffLimit: 0`, `ttlSecondsAfterFinished`, digest pinning, restricted PSA +- config는 `FLYWAY_*` env var + Secret (VSO / ESO) +- `FLYWAY_CLEAN_DISABLED=true` 필수, `FLYWAY_BASELINE_ON_MIGRATE=false`, `FLYWAY_OUT_OF_ORDER=false` +- schema history table 위치를 `FLYWAY_DEFAULT_SCHEMA`로 명시 +- baseline / repair / out-of-order는 예외 절차 +- applied migration 수정 금지 +- Undo(U__) 파일 만들지 않음 (OSS 미지원) +- non-transactional DDL은 `-- flyway:executeInTransaction=false`로 파일 단위 분리 +- service별 migration ownership 분리 +- destructive migration은 expand → migrate → contract diff --git a/docs/standards/infra/k3s-specific.md b/docs/standards/infra/k3s-specific.md new file mode 100644 index 0000000..041f912 --- /dev/null +++ b/docs/standards/infra/k3s-specific.md @@ -0,0 +1,224 @@ +# K3s-specific 기준 + +## 목적 + +이 문서는 일반 Kubernetes 표준과 **분리**해서, K3s 운영에서만 발생하는 특수성을 고정한다. + +목표: + +- K3s packaged component (`coredns`, `traefik`, `local-storage`, `metrics-server`, `servicelb`)를 일반 manifest처럼 관리하는 실수를 막는다 +- `/var/lib/rancher/k3s/server/manifests`를 source-of-truth로 쓰는 실수를 막는다 +- 멀티 server HA 환경에서 `critical configuration value mismatch` join 실패를 예방한다 +- embedded registry mirror(Spegel)의 네트워크·버전 게이트를 정확히 이해한다 +- 1000+ 서비스 prod 스케일에서 K3s의 어떤 기능을 켜고 어떤 기능을 외부로 뺄지 기준을 박는다 + +## 공식 의미 (근거 URL 포함) + +- K3s packaged component: `coredns`, `traefik`, `local-storage`, `metrics-server` (매니페스트 파일 기반) + `servicelb`(매니페스트 없이 `--disable`만 가능). +- AddOn auto-deploy: `/var/lib/rancher/k3s/server/manifests` 하위 파일은 server 시작 시 + 파일 변경 시 자동 apply. packaged component는 K3s가 재기록하므로 직접 수정 금지. +- multi-server 유저 AddOn은 서버 간 자동 동기화되지 **않는다**. +- K3s 설정: `/etc/rancher/k3s/config.yaml` + `/etc/rancher/k3s/config.yaml.d/*.yaml` drop-in. +- critical 값 (cluster-cidr / service-cidr / cluster-dns / cluster-domain / disable 세트 / CNI / embedded-registry 활성화)이 서버 간 불일치면 `critical configuration value mismatch` join 실패. +- packaged Helm component(`traefik` 등) 커스터마이징은 `HelmChartConfig` (apiVersion `helm.cattle.io/v1`). +- K3s 기본 local storage는 Rancher Local Path Provisioner (`local-path` StorageClass, node-local, not replicated). +- **embedded registry mirror (Spegel)**: 기본 비활성. 활성화 시 노드 간 TCP 5001 (p2p gossip) + TCP 6443 (registry + supervisor)이 reachable해야 한다. 출처: `https://docs.k3s.io/installation/registry-mirror` — "all nodes must be able to reach each other via their internal IP addresses, on TCP ports 5001 and 6443". +- K3s 이미지 import: `/var/lib/rancher/k3s/agent/images/*.tar{,.zst,.gz}`. +- K3s는 기본적으로 network policy enforcer (kube-router 기반)를 포함한다. 외부 CNI(Cilium 등) 사용 시 `--disable-network-policy` + `--flannel-backend=none` 조합 필요. + +## 기본 규칙 + +### 1. K3s 전용 규칙은 별도 문서로 유지 + +일반 Kubernetes 표준 문서에 K3s 특수성을 흩뿌리지 않는다. 분리 범주: + +- packaged component +- AddOn auto-deploy +- config.yaml / config.yaml.d +- local-path provisioner +- embedded registry mirror +- critical server flags +- Helm component customization + +### 2. packaged component는 “편의 기능”, 직접 수정 절대 금지 + +관리 대상: + +- `coredns` +- `traefik` +- `local-storage` +- `metrics-server` +- `servicelb` (manifest 없음, flag로만 제어) + +금지: + +- `/var/lib/rancher/k3s/server/manifests/traefik.yaml` 직접 edit +- packaged manifest를 Git SoT로 관리 +- 재시작 후 overwrite되는 파일에 운영 커스터마이징 저장 + +### 3. packaged component 유지/비활성은 cluster bootstrap 때 박는다 + +1000-서비스 prod 스케일에서 현재 기준: + +| component | prod 기본 | 이유 | +|----------------|-----------|-------------------------------------------------------------| +| `traefik` | disable | ingress-nginx / Envoy Gateway로 교체. Traefik은 dev만. | +| `servicelb` | disable | MetalLB L2/BGP 또는 외부 LB. klipper는 노드 80/443 점유. | +| `local-storage`| disable | Longhorn / Ceph RBD / CSI. node-local은 DR 불가. | +| `metrics-server`| keep | HPA + `kubectl top` 전제. 대체 pipeline 준비되면 교체 가능. | +| `coredns` | keep | 교체는 특수 케이스. node-local dns cache는 별도로 추가. | +| network policy | 상황별 | Cilium 도입 시 disable. 기본 kube-router 유지도 가능. | + +### 4. server critical config는 Git에서 단일 파일로 관리 + +`/etc/rancher/k3s/config.yaml`이 Git의 inventory repo (Ansible / Fleet / CI)에서 push된다. +서버별 ad-hoc 수정 금지. critical 값 mismatch는 **join 실패**로 직결된다. + +일치해야 하는 값: + +- `cluster-cidr`, `service-cidr`, `cluster-dns`, `cluster-domain` +- `disable` 세트 +- `flannel-backend` / `disable-network-policy` +- `embedded-registry` 활성화 여부 +- `datastore-endpoint` (etcd / external DB) + +### 5. CLI argument보다 config file 우선 + +재현성 / diff / multi-node 동기화를 위해 server/agent 플래그는 모두 `config.yaml`로. +`/etc/rancher/k3s/config.yaml.d/*.yaml` drop-in은 역할별 파일 분리(예: `10-networking.yaml`, `20-audit.yaml`)에 사용. + +### 6. `/var/lib/rancher/k3s/server/manifests`는 apply sink, SoT 아님 + +- 운영 SoT = Git (+ Kustomize / ArgoCD / Flux) +- 이 디렉터리는 bootstrap addon에만 한정 (예: `k3s-addons-disabled.yaml` placeholder) +- 서버별로 다른 파일을 두고 "알아서 맞겠지"는 금지 +- `.skip` 파일은 **임시** 비활성화 용. 장기 disable은 `--disable` 플래그로. + +### 7. multi-server user AddOn은 Git push, 로컬 scp 금지 + +K3s는 user AddOn을 서버 간 동기화하지 않는다. 멀티 server 환경에서 AddOn을 쓰려면: + +- GitOps 컨트롤러(ArgoCD/Flux)가 apply +- 또는 Ansible/Fleet이 단일 server 노드에만 drop +- 또는 완전히 포기하고 `kubectl apply`로만 관리 (권장) + +### 8. packaged Helm component 커스터마이징은 `HelmChartConfig` + +traefik 유지가 불가피할 때: + +```yaml +apiVersion: helm.cattle.io/v1 +kind: HelmChartConfig +metadata: + name: traefik + namespace: kube-system +spec: + valuesContent: |- + <override values> +``` + +- `metadata.name` / `namespace`는 대응 `HelmChart`와 반드시 일치 +- 민감 값은 `valuesSecrets`로 Secret 참조 (valuesContent에 하드코딩 금지) +- HelmChartConfig 자체는 Git 관리 + +### 9. local-path provisioner는 dev/test 한정 + +Rancher Local Path Provisioner = node-local hostPath. 특성: + +- ReadWriteOnce only +- 노드 장애 시 데이터 접근 불가 +- 백업/DR 불가 (StorageClass 레벨 스냅샷 없음) +- binding mode = WaitForFirstConsumer (Pod가 뜰 때 PV 생성) + +기준: + +- dev/test StatefulSet의 PVC 기본값으로만 허용 +- prod의 DB / Vault / MinIO / Kafka / etcd backup target에 절대 사용 금지 +- prod storage는 **Longhorn (K3s 권장) / Ceph RBD / 외부 CSI** 중 택1 + +### 10. metrics-server는 유지 기본값 + +HPA v2 metrics, `kubectl top`, VPA, kube-state-metrics 연동 모두가 전제. disable 시 Prometheus Adapter 등 대체 pipeline을 먼저 준비한 뒤에만 꺼야 한다. + +### 11. traefik / servicelb는 포트 점유 + 노드 노출 전략을 같이 본다 + +- `servicelb` (klipper) = 모든 노드가 80/443 HostPort로 열림. prod에서는 거의 항상 disable + MetalLB 또는 외부 LB. +- `traefik` 유지 시 IngressClass / Middleware / EntryPoint 세 레이어가 전부 K3s 관리. prod에서는 disable + `ingress-nginx` DaemonSet 또는 Envoy Gateway Deployment. + +### 12. network policy controller 충돌 + +- 기본: K3s 내장 kube-router 기반 enforcer +- Cilium / Calico 도입 시: `--flannel-backend=none` + `--disable-network-policy` + `--disable=servicelb` +- 도입 계획은 클러스터 bootstrap 결정 사항 (리빌드 없이 swap 불가에 가까움) + +### 13. embedded registry mirror (Spegel): 명시적 opt-in + 네트워크 요구사항 + +- 기본 **비활성** +- 활성화 방법: `/etc/rancher/k3s/config.yaml`에 `embedded-registry: true` + `registries.yaml`에 mirror 설정 +- **네트워크 요구사항** (공식): 모든 노드가 서로 **TCP 5001 (p2p gossip) + TCP 6443 (local registry + supervisor)**에 도달 가능해야 한다. firewall / security group에서 해당 포트 오픈 필수. +- 활성화 대상: + - airgap / 반-airgap 환경 + - 이미지 pull bottleneck이 심한 대규모 배포 + - external registry 의존을 낮춰야 하는 환경 +- 클러스터 범위 기능이므로 **모든 server/agent에 동일 적용** + +### 14. 이미지 import / airgap 전략 + +- 평상시: registry pull (internal mirror 선호) +- airgap: `/var/lib/rancher/k3s/agent/images/*.tar{,.zst,.gz}` 사용, import 절차를 runbook에 명시 +- 이미지 import는 agent startup 때만 로드됨 → 런타임 교체는 re-push 필요 + +### 15. K3s version gating을 항상 확인 + +다음 기능은 버전에 따라 동작/옵션이 바뀌므로, 업그레이드 전 CHANGELOG 확인 필수: + +- embedded registry mirror (Spegel) +- image pre-import +- `HelmChartConfig` schema +- `disable-helm-controller` 동작 +- etcd snapshot / S3 backup 옵션 + +### 16. K3s-specific 예외는 component 문서보다 먼저 확정 + +이 문서에서 박고 내려가야 하는 결정: + +- traefik 유지/비활성 +- servicelb 유지/비활성 +- local-storage 유지 범위 (env별) +- metrics-server 유지 +- network policy controller 선택 +- embedded registry mirror 사용 여부 + +그 다음에 keycloak / vault / minio / ingress / storage 문서로 내려간다. + +### 17. `kubectl apply --server-side` 기본 사용 + +K3s도 SSA 지원. ArgoCD / Flux / CI 모두 `--server-side --field-manager=<id>` 기본. last-applied-configuration annotation 2MB 한계 회피 + multi-controller ownership 명시. + +### 18. etcd snapshot은 K3s 고유 메커니즘 사용 + +- embedded etcd면 `k3s etcd-snapshot` CLI 또는 `--etcd-snapshot-*` config +- S3 업로드 설정은 `/etc/rancher/k3s/config.yaml`에 선언 +- 외부 datastore(PostgreSQL/MySQL) 사용 시 backup은 해당 DB 레이어에서 따로 + +## 현재 스택 기본 권장안 (prod) + +- `traefik`: disable, ingress-nginx + cert-manager로 교체 +- `servicelb`: disable, MetalLB (L2 또는 BGP)로 교체 +- `local-storage`: disable (prod), dev/staging 만 유지. Longhorn으로 교체 +- `metrics-server`: keep (HPA 전제) +- `network policy`: 현 단계 kube-router 유지, Cilium 도입은 별 RFC +- `embedded registry mirror`: off (현재 airgap 아님), 옵션으로 남김 +- `etcd snapshot`: S3 업로드 활성, 6시간 주기, 72시간 retention +- `HelmChartConfig`: traefik 유지 경로를 쓰지 않으므로 현재 미사용 +- apply 방식: `kubectl apply --server-side --field-manager=argocd` + +## 프로젝트 기준 요약 + +- K3s 전용 규칙은 별도 문서 +- packaged component 직접 수정 금지 (HelmChartConfig / disable만) +- `manifests/`는 SoT 아님 +- critical config는 Git 단일 파일, 서버 간 동일 +- local-path는 dev/test만 +- embedded registry mirror는 TCP 5001 + 6443 reachability가 전제 +- prod에서 traefik/servicelb/local-storage 전부 disable이 기본 +- Server-Side Apply가 GitOps 기본 diff --git a/docs/standards/infra/keycloak.md b/docs/standards/infra/keycloak.md new file mode 100644 index 0000000..64b43a2 --- /dev/null +++ b/docs/standards/infra/keycloak.md @@ -0,0 +1,227 @@ +# Keycloak 기준 + +## 목적 + +이 문서는 Kubernetes 환경에서 Keycloak 26+ (Quarkus distribution)을 1000+ 서비스의 ID 브로커로 운영하기 위한 기준을 고정한다. + +- 빌드/실행 두 단계(`kc.sh build` → `kc.sh start --optimized`)를 전제한다 +- Hostname v2, proxy-headers, management port 9000, Infinispan 캐시를 명시한다 +- 단일 Deployment 수제 배포 대신 **Keycloak Operator**를 1차 권장 경로로 둔다 +- DB / 캐시 / probe / Ingress / RealmImport 를 YAML이 아닌 "설계 결정"으로 먼저 고정한다 +- auth-server(도메인 위임)와 Keycloak(IdP)의 ownership 경계를 분리한다 + +## 공식 의미 (Keycloak 26+ 기준) + +- 운영 실행 방식은 **두 단계**다. `kc.sh build`가 Quarkus augmentation을 수행해 optimized 이미지를 만들고, `kc.sh start --optimized`가 그 이미지를 기동한다. 빌드 시 configuration은 런타임에 변경 불가능하다. +- **`--proxy` 옵션은 v24에서 deprecated, v26에서 제거되었다.** 대체는 `--proxy-headers=xforwarded` 또는 `--proxy-headers=forwarded`다. +- **Hostname v2**가 기본값이며 `--hostname`은 full URL을 받는다. v24+ 이후 `hostname-url`, `hostname-path`, `hostname-port`는 제거되었다. admin 전용 주소는 `--hostname-admin`으로 지정한다. +- `--hostname-strict`의 production 기본값은 `true`다. `--hostname-backchannel-dynamic`은 기본 `false`다. +- HTTPS 종료를 Ingress/LB가 하면 Keycloak은 `KC_HTTP_ENABLED=true`로 HTTP를 수신한다. +- DB는 `KC_DB=postgres`, `KC_DB_URL`은 **JDBC URL**(`jdbc:postgresql://host:5432/db`) 형식이다. +- **Management interface는 기본 포트 `9000`**에서 제공되고, `/health`, `/health/started`, `/health/ready`, `/health/live`, `/metrics`를 호스팅한다. Pod probe와 Prometheus scrape는 모두 9000 대상이다. +- Production cache type 기본은 `ispn`(Infinispan distributed). **cache-stack 기본값이 `kubernetes`(DNS_PING)에서 v25부터 `jdbc-ping`으로 바뀌었다.** Operator가 관리하는 StatefulSet은 Raft-less 클러스터링을 jdbc-ping으로 수행한다. +- Operator가 생성하는 워크로드는 **StatefulSet**이다 (pod ordering이 Infinispan discovery와 맞물린다). 사용자 수제 YAML에서도 Operator 경로가 1차 권장이다. +- `KeycloakRealmImport` CR은 Keycloak server가 준비된 후 realm JSON을 server side로 import하는 1회성 Job을 생성한다. + +## 기본 규칙 + +### 1. 운영 실행은 `start --optimized` 두 단계 + +빌드 단계에서 feature/db/health/metrics를 굽고, 실행 단계에서 runtime config만 주입한다. + +기본: +- Dockerfile에서 `RUN /opt/keycloak/bin/kc.sh build`로 optimized 이미지 생성 +- 컨테이너 CMD는 `kc.sh start --optimized` +- runtime-only config: hostname, DB URL/credential, log level + +기본 금지: +- `start-dev` 운영 사용 +- `start` 단독 실행(build 없이 매 기동마다 augmentation) + +### 2. `--proxy-headers` 사용, `--proxy` 금지 + +Keycloak 26에서 `--proxy`는 제거되었다. + +기본: +- HTTPS 종료 proxy 뒤: `KC_PROXY_HEADERS=xforwarded` (nginx, Traefik, ingress-nginx 등) +- RFC 7239 지원 proxy: `KC_PROXY_HEADERS=forwarded` +- proxy가 Host / X-Forwarded-* 를 **덮어쓰도록** 고정 + +기본 금지: +- `KC_PROXY=edge|reencrypt|passthrough` 등 legacy 옵션 + +### 3. Hostname v2: full URL로 고정 + +기본: +- `KC_HOSTNAME=https://auth.example.com` (full URL) +- Admin Console 분리: `KC_HOSTNAME_ADMIN=https://admin-auth.example.com` +- `KC_HOSTNAME_STRICT=true` (production 기본 유지) +- `KC_HOSTNAME_BACKCHANNEL_DYNAMIC=false` (기본값; 다중 cluster federation일 때만 true 검토) + +기본 금지: +- 제거된 옵션 사용: `KC_HOSTNAME_URL`, `KC_HOSTNAME_PATH`, `KC_HOSTNAME_PORT` +- hostname 없이 요청 헤더에서 해석되도록 방치 + +### 4. HTTPS는 Ingress/LB에서 종료, Pod는 HTTP + +Pod 내부에서 TLS 재암호화가 필요 없으면 Pod는 HTTP로 수신한다. + +기본: +- `KC_HTTP_ENABLED=true`, `KC_HTTP_PORT=8080` +- Ingress가 TLS 종료 + proxy-header 주입 +- passthrough TLS가 필요한 보안 요구가 있을 때만 `KC_HTTPS_*` 경로 채택 + +### 5. DB는 외부 PostgreSQL + JDBC URL + +기본: +- `KC_DB=postgres` +- `KC_DB_URL=jdbc:postgresql://keycloak-db-rw:5432/keycloak` (CloudNativePG `-rw` RW endpoint 권장) +- `KC_DB_USERNAME`, `KC_DB_PASSWORD` → Secret `secretKeyRef` +- Keycloak schema와 auth-server schema는 **다른 DB 또는 다른 database**로 분리 + +기본 금지: +- 내장 H2 (`dev-file`, `dev-mem`) 운영 +- root/superuser credential 사용 +- Keycloak DB에 auth-server migration 수행 + +### 6. Management port 9000은 외부 비공개 + +기본: +- Pod containerPort 9000 (`KC_HTTP_MANAGEMENT_PORT=9000`) +- Service에 9000 expose하되 Ingress 대상 제외 +- probe는 9000 대상: `/health/started`, `/health/ready`, `/health/live` +- Prometheus scrape는 내부 scraper가 9000/`/metrics`에 직접 접근 + +### 7. Probe timing은 Keycloak 기동 특성에 맞춘다 + +Keycloak은 JVM + Quarkus + Infinispan + DB migration으로 cold start가 30~120초다. + +기본: +- `startupProbe`: `/health/started`, `periodSeconds: 5`, `failureThreshold: 60` → 최대 5분 유예 +- `readinessProbe`: `/health/ready`, `periodSeconds: 10`, `failureThreshold: 3` +- `livenessProbe`: `/health/live`, `periodSeconds: 30`, `failureThreshold: 3`, `initialDelaySeconds: 60` + +### 8. Cache: Infinispan + 버전별 stack 기본값 인지 + +v25+ 기본 stack은 **`jdbc-ping`**이다. DB를 discovery 매체로 쓰므로 headless service / ServiceAccount RBAC가 필요 없다. + +기본: +- Operator 관리 클러스터: `KC_CACHE=ispn`, `KC_CACHE_STACK=jdbc-ping` (명시) +- 수제 StatefulSet에서 headless service 경유 discovery를 쓰려면 `KC_CACHE_STACK=kubernetes` (DNS_PING) 선택 +- local mode 운영 금지 (`KC_CACHE=local`은 single replica 테스트 전용) + +### 9. Operator 경로를 1차 권장으로 + +1000+ 서비스 규모에서 realm import, CR 기반 롤아웃, cache stack 자동 설정, StatefulSet 관리를 Operator가 담당한다. + +기본: +- `Keycloak` CR + `KeycloakRealmImport` CR 조합 +- OLM(OperatorHub) 또는 공식 manifest 설치 +- 수제 StatefulSet 유지보수는 Operator 기능이 부족할 때만 허용 + +### 10. 공개 경로 최소화 + +Ingress에 허용하는 기본 경로: +- `/realms/` — OIDC / SAML endpoint +- `/resources/` — Keycloak theme / JS +- `/.well-known/` — OIDC discovery, JWKS +- `/js/` — Keycloak adapter JS (필요 시) + +기본 금지: +- `/admin/` 외부 공개 (별도 admin host 경유) +- `/metrics`, `/health*` 외부 공개 +- `/` 전체 wildcard 공개 + +### 11. Admin Console은 별도 host로 분리 + +Admin 접근은 일반 SSO host와 다른 경로로 둔다. + +기본: +- `KC_HOSTNAME_ADMIN=https://admin-auth.example.com` +- Admin host는 사내 IP 화이트리스트 / VPN / OIDC forward-auth로 추가 보호 +- production에서 `/admin/` 을 SSO 공용 host에 노출 금지 + +### 12. Realm은 `KeycloakRealmImport` CR로 선언적 관리 + +기본: +- realm JSON은 Git에 보관 +- `KeycloakRealmImport` CR이 Job을 생성해 server-side import +- secret이 들어가는 identity provider client secret은 Vault에서 주입 + +기본 금지: +- Admin REST / kcadm.sh를 CI/CD pipeline이 직접 호출해 상태 변경 +- realm export 파일을 Pod 내부 파일로 배포 + +### 13. High Availability: replicas ≥ 2 + PDB + topologySpread + +Operator는 `instances` 필드로 replica를 제어한다. + +기본: +- `instances: 3` (odd quorum 아님 — cache replication 안정성) +- `PodDisruptionBudget minAvailable: 2` +- `topologySpreadConstraints`로 node/zone 분산 + +### 14. Sticky session은 성능 최적화 옵션 + +Infinispan이 session을 복제하므로 필수는 아니지만, login flow 중간 redirect 지연을 줄인다. + +기본: +- Ingress controller에서 `AUTH_SESSION_ID` cookie affinity +- Service `sessionAffinity: ClientIP`는 2차 선택지 + +### 15. Security context: Restricted PSS 준수 + +기본: +- `runAsNonRoot: true`, `runAsUser: 1000` +- `readOnlyRootFilesystem: true` (Keycloak은 `/opt/keycloak/data` 만 writable 요구; emptyDir 마운트) +- `allowPrivilegeEscalation: false`, `capabilities.drop: [ALL]` +- `seccompProfile: RuntimeDefault` + +### 16. Resource 요청은 JVM 특성 반영 + +기본 단일 replica: +- requests: `cpu: 500m`, `memory: 1Gi` +- limits: `cpu: 2`, `memory: 2Gi` +- JVM: `JAVA_OPTS_APPEND=-XX:MaxRAMPercentage=70 -XX:InitialRAMPercentage=50` + +login throughput 요구가 높으면 replica 수평 확장 우선 (JVM heap 수직 확장 2차). + +### 17. Observability + +기본: +- `KC_METRICS_ENABLED=true`, `KC_HEALTH_ENABLED=true` +- `ServiceMonitor` 또는 `PodMonitor`로 9000/`/metrics` scrape +- event metric (login failure, token issuance)은 필요한 것만 활성화 (high cardinality 방지) + +### 18. DB credential / admin credential은 Vault 경유 + +기본: +- `KC_DB_PASSWORD`: VSO `VaultDynamicSecret`(postgres dynamic role) 또는 `VaultStaticSecret` → K8s Secret 동기화 +- Bootstrap admin (`KEYCLOAK_ADMIN`, `KEYCLOAK_ADMIN_PASSWORD`): 최초 기동 후 제거, 실 운영 admin은 realm-managed + +기본 금지: +- Secret을 Git에 평문 저장 +- 환경변수 default value로 credential 하드코딩 + +### 19. 현재 스택 기본 권장안 + +- 배포: Keycloak Operator + `Keycloak` CR + `KeycloakRealmImport` CR +- 워크로드: StatefulSet (Operator 생성) +- Service: ClusterIP (9000, 8080) +- Ingress: SSO host + Admin host 분리 +- DB: CloudNativePG PostgreSQL cluster + Vault dynamic secret +- Cache: `ispn` + `jdbc-ping` +- Probe: 9000 management port +- Replicas: 3 + PDB + topologySpread + +## 프로젝트 기준 요약 + +- Keycloak 26+ Quarkus distribution, `start --optimized` 두 단계 +- `--proxy-headers` 사용, `--proxy` 금지 +- Hostname v2 full URL, admin host 분리, strict=true 유지 +- DB: 외부 PostgreSQL, JDBC URL, Vault credential +- Management port 9000 내부 전용, probe / metrics 대상 +- Infinispan `ispn` + `jdbc-ping` (v25+) +- Operator 경로 1차 권장 (CR로 realm import 포함) +- Admin Console 별도 host, 공개 경로는 `/realms/`, `/resources/`, `/.well-known/` +- Replicas ≥ 2 + PDB + topologySpread + Restricted PSS diff --git a/docs/standards/infra/kustomize.md b/docs/standards/infra/kustomize.md new file mode 100644 index 0000000..cdef35c --- /dev/null +++ b/docs/standards/infra/kustomize.md @@ -0,0 +1,325 @@ +# Kustomize 기준 + +## 목적 + +Kustomize는 Kubernetes 리소스를 **template-free**로 조합하고 환경별 차이를 overlay로 표현하는 도구다. +1000+ 서비스 prod 스케일에서 기본 배포 도구로 사용하며, Helm 차트는 특정 플랫폼 컴포넌트(Prometheus Operator, cert-manager 등)에만 제한적으로 쓴다. + +목표: + +- base / overlay / component 세 축을 명확히 구분한다 +- `commonLabels`의 selector immutability 함정을 피한다 +- `kubectl apply --server-side`를 전제로 field manager ownership을 관리한다 +- GitOps (ArgoCD/Flux) 또는 CI `kubectl apply -k` 어느 쪽이든 같은 원본을 쓴다 + +## 공식 의미 (근거) + +- 공식 문서: `https://kubernetes.io/docs/tasks/manage-kubernetes-objects/kustomization/`, `https://kubectl.docs.kubernetes.io/references/kustomize/` +- `kubectl kustomize <dir>` 렌더, `kubectl apply -k <dir>` apply, `kubectl diff -k <dir>` diff. +- **Kustomize v5+ `labels:` 필드**: label을 리소스에 추가하되 **기본적으로 selector에 주입하지 않는다** (`includeSelectors: false`). 공식 문서 인용: *"A field that allows adding labels without also automatically injecting corresponding selectors. This can be used instead of the `commonLabels` field, which always adds selectors."* +- **`commonLabels`**: 모든 리소스의 `metadata.labels` + `spec.selector.matchLabels` + Pod template labels에 주입된다. Deployment/StatefulSet의 `selector.matchLabels`는 **immutable** 이므로, 이미 apply된 리소스에 `commonLabels`로 label을 추가하면 `field is immutable` 에러로 apply 실패. +- **`components:`** (v4+): 재사용 가능한 cross-cutting overlay 단위. `kind: Component`. resource 집합 + patch 집합을 하나의 단위로 묶어 여러 overlay에서 `components:` 키로 참조. +- `configMapGenerator` / `secretGenerator`: 이름 끝에 hash suffix가 자동으로 붙어 rollout trigger. `generatorOptions.disableNameSuffixHash: true`로 비활성 가능. +- `patches:` (v5 권장): `target:` 선택 + `patch:` inline 또는 `path:` 파일. strategic merge / JSON patch 양쪽 지원. +- `images:`: image name/tag/digest 교체. +- `replicas:`: resource별 replica 수 override. +- `namespace:` / `namePrefix:` / `nameSuffix:`: overlay에서 공통 변환. +- Server-Side Apply (`kubectl apply --server-side --field-manager=<id> -k`)가 GitOps 기본. + +## 기본 규칙 + +### 1. Kustomize 디렉터리가 선언형 source of truth + +- 렌더: `kubectl kustomize <dir>` +- diff: `kubectl diff --server-side -k <dir>` +- apply: `kubectl apply --server-side --field-manager=<ci-id> -k <dir>` + +`kubectl apply -f` 단일 파일 apply는 금지 (bootstrap 예외 제외). + +### 2. base는 환경 중립 + +허용: + +- Deployment/StatefulSet/DaemonSet/Job/CronJob 기본 shape +- `app.kubernetes.io/{name,instance,component,part-of,managed-by}` (`version`은 overlay에서 image tag와 함께 주입) +- 공통 container spec (resources, probes, securityContext) +- 공통 volume mount / ConfigMap reference + +금지: + +- replicas 고정값 (overlay `replicas:`에서 결정) +- 환경별 host / domain / issuer 이름 +- 환경별 secret / ConfigMap 이름 +- 환경별 resources requests/limits +- `example.com/environment` label (overlay에서 `labels:`로 주입) + +### 3. overlay는 환경 차이만, patches는 파일로 분리 + +overlay 한 디렉터리의 `kustomization.yaml`은 짧아야 한다. diff가 몇 백 줄을 넘으면 base 설계 실패 신호. + +권장 구조: + +``` +overlays/prod/ + kustomization.yaml + patches/ + auth-replicas.yaml + auth-resources.yaml + auth-topology-spread.yaml + ingress-host.yaml + postgres-storage.yaml +``` + +### 4. 디렉터리 구조는 base / components / overlays 3축 + +``` +k8s/ + base/ + app/units/<domain>/<service>/ + managing/<job>/ + plugins/<platform>/ + components/ + <reusable-cross-cutting>/ + overlays/ + <env>/[region/] +``` + +`components/`는 "Kustomize Components"로, 여러 overlay에서 재사용. + +### 5. `commonLabels` 금지, `labels:` 사용 + +신규 코드에서는 `commonLabels` 사용을 금지한다. + +```yaml +# DO +labels: + - pairs: + example.com/environment: prod + example.com/region: kr-main + includeSelectors: false + includeTemplates: true +``` + +이유: + +- `commonLabels`는 `selector.matchLabels`에 자동 주입 → live Deployment/StatefulSet apply 시 `field is immutable` 실패 +- `labels:`는 `includeSelectors: false`가 기본 → safe +- `includeTemplates: true`로 Pod template labels에는 전파되므로 관찰성은 유지 + +기존 `commonLabels` 사용 코드는 migration plan을 세워 교체. selector에 이미 들어간 label이 있다면 해당 리소스를 **재배포** (delete + recreate) 없이는 변경 불가. + +### 6. selector에는 불변 3종만 + +overlay에서 selector를 건드리지 않는다. selector에 허용되는 label은: + +- `app.kubernetes.io/name` +- `app.kubernetes.io/instance` +- `app.kubernetes.io/component` + +이 3종은 base에서 고정. overlay가 `labels:`로 추가하는 label은 반드시 `includeSelectors: false`. + +### 7. `patches:` (v5 스타일) 사용, `patchesStrategicMerge` / `patchesJson6902` 금지 + +```yaml +patches: + - target: + kind: Deployment + name: auth + path: patches/auth-resources.yaml + - target: + kind: Ingress + name: auth-public + patch: |- + - op: replace + path: /spec/rules/0/host + value: auth.example.com +``` + +이유: + +- 단일 키로 strategic merge + JSON patch 양쪽 지원 +- `target:` selector로 여러 리소스에 적용 가능 +- 레거시 `patchesStrategicMerge` / `patchesJson6902`는 v5에서 deprecated (여전히 작동하지만 신규 사용 금지) + +### 8. `components:`로 cross-cutting 재사용 + +multiple overlay에서 공통으로 끼워야 하는 변경(예: mTLS 활성화, sidecar 주입, monitoring label 추가)은 component로. + +``` +components/ + with-istio-sidecar/ + kustomization.yaml # kind: Component + patches/ + inject-sidecar.yaml + with-service-monitor/ + kustomization.yaml + service-monitor.yaml + with-pdb-tier1/ + kustomization.yaml + pdb-patch.yaml +``` + +overlay에서: + +```yaml +components: + - ../../components/with-service-monitor + - ../../components/with-pdb-tier1 +``` + +### 9. `namePrefix` / `nameSuffix`는 꼭 필요할 때만 + +리소스 이름이 바뀌면 ConfigMap/Secret 참조 (`envFrom`, `volumes.configMap.name`)도 모두 바뀐다. namespace 격리가 기본이고, 같은 cluster 안에서 같은 이름 리소스를 여러 번 생성할 때만 prefix/suffix를 쓴다. + +### 10. generator 기준 + +- `configMapGenerator`: 비민감 설정만. 기본 hash suffix로 rollout 자동 트리거. +- `secretGenerator`: 로컬/테스트/bootstrap 에만. prod secret은 External Secrets Operator / Vault Secrets Operator / SealedSecrets로 관리. +- `generatorOptions.disableNameSuffixHash: true`는 GitOps 외부 컨슈머가 이름을 하드코딩해야 할 때만 (예외). + +### 11. `images:`로 image tag/digest 고정 + +```yaml +images: + - name: registry.example.com/auth + newTag: "1.24.3" + - name: registry.example.com/keycloak + digest: "sha256:abcd1234..." +``` + +- prod에서는 digest 권장 (tag는 mutable) +- CI가 overlay의 `images:` 섹션을 빌드 후 새 digest로 patch (kustomize edit set image) + +### 12. `replicas:`는 overlay에서 resource별 값 주입 + +```yaml +replicas: + - name: auth + count: 6 + - name: keycloak + count: 3 +``` + +HPA 주도 rollout 환경에서는 `replicas:` override가 HPA와 충돌할 수 있다. HPA 활성 리소스는 base `replicas`를 HPA `minReplicas`와 일치시키고 overlay에서는 건드리지 않는다. + +### 13. `kubectl apply --server-side --field-manager=<id>` 기본 + +- ArgoCD: field manager `argocd-controller` +- Flux: field manager `kustomize-controller` +- CI manual: field manager `ci-<pipeline-id>` + +field manager 이름을 환경별로 통일해야 `managedFields` 충돌이 예측 가능해진다. + +### 14. render 전 검증 + +CI가 아래를 순서대로 실행: + +```bash +kubectl kustomize overlays/prod > /tmp/rendered.yaml +kubeconform -strict -summary -schema-location default -schema-location 'https://raw.githubusercontent.com/datreeio/CRDs-catalog/main/{{.Group}}/{{.ResourceKind}}_{{.ResourceAPIVersion}}.json' /tmp/rendered.yaml +kubectl diff --server-side --field-manager=ci -k overlays/prod +``` + +- kubeconform / kubeval: schema validation +- kyverno / OPA Gatekeeper: policy validation (post-render) +- conftest: opa policy bundle 실행 + +### 15. base는 overlay를 모른다 + +공식 원칙. base `kustomization.yaml`은 overlay에서만 의미 있는 설정(환경 host / issuer / region label)을 전제하지 않는다. 위반 시 base가 더 이상 재사용 가능한 unit이 아니다. + +### 16. Kustomize를 템플릿 엔진으로 남용하지 않는다 + +분기 / 조건 / 반복이 필요하면: + +1. 리소스 분리 +2. component 도입 +3. overlay 추가 +4. (마지막 수단) Helm / jsonnet / cdk8s + +Kustomize는 patch/overlay 도구다. Go template이 아니다. + +### 17. scripts는 Kustomize 보조, 대체 아님 + +`scripts/render.sh`, `scripts/diff.sh`, `scripts/apply.sh`는 Kustomize 명령의 wrapper에 그치고 overlay 구조를 우회하지 않는다. + +### 18. `resources:` vs `bases:` — v5에서는 `resources:` 통일 + +v2.1에서 `bases:`가 `resources:`로 통합됨. 신규 파일에서 `bases:` 금지. + +### 19. overlay에서 StatefulSet PVC retention 변경 주의 + +`persistentVolumeClaimRetentionPolicy`는 StatefulSet spec 필드 (GA 1.27). 환경별로 값이 다르면 overlay patch로 조정하되 prod는 기본 `{whenDeleted: Retain, whenScaled: Retain}` 유지. + +## 추천 폴더 구조 + +```text +k8s/ + base/ + app/ + kustomization.yaml + units/ + identity/ + auth/ + kustomization.yaml + deployment.yaml + service.yaml + servicemonitor.yaml + pdb.yaml + hpa.yaml + keycloak/ + kustomization.yaml + data/ + postgres-identity/ + kustomization.yaml + statefulset.yaml + service-headless.yaml + service.yaml + managing/ + flyway-migrate-identity/ + kustomization.yaml + job.yaml + backup-postgres/ + kustomization.yaml + cronjob.yaml + plugins/ + ingress-nginx/ + cert-manager/ + external-secrets/ + kube-prometheus-stack/ + fluent-bit/ + components/ + with-service-monitor/ + with-pdb-tier1/ + with-topology-spread-zone/ + with-network-policy-deny-default/ + overlays/ + dev/ + kustomization.yaml + staging/ + kustomization.yaml + prod/ + kr-main/ + kustomization.yaml + patches/ + kr-dr/ + kustomization.yaml + patches/ + scripts/ + render.sh + diff.sh + apply.sh + validate.sh +``` + +## 프로젝트 기준 요약 + +- Kustomize v5 문법 기준, `commonLabels` 금지, `labels:` 사용 +- `patches:` 단일 키, `target:` + `path:` 또는 `patch:` inline +- `components:`로 cross-cutting 재사용 +- selector에는 불변 3종만 (name / instance / component) +- generator는 configMap만 기본, secret은 External Secrets +- `kubectl apply --server-side --field-manager=<id>` 전제 +- render + schema + policy 검증을 CI에서 강제 +- base / components / overlays 3축 디렉터리 +- overlay diff는 짧아야 한다 (base 재작성 금지) diff --git a/docs/standards/infra/minio.md b/docs/standards/infra/minio.md new file mode 100644 index 0000000..22ec63c --- /dev/null +++ b/docs/standards/infra/minio.md @@ -0,0 +1,238 @@ +# MinIO 기준 + +## 목적 + +이 문서는 Kubernetes 환경에서 MinIO를 1000+ 서비스의 S3 호환 object storage로 운영하기 위한 기준을 고정한다. + +- MinIO Operator + **Tenant CRD** (`minio.min.io/v2`)를 기본 배포 모델로 둔다 +- Erasure coding 최소 요건 (`servers × volumesPerServer ≥ 4`)을 명시한다 +- KES sidecar + Vault transit backend로 SSE-KMS를 구성한다 +- STS + OIDC (Keycloak)로 서비스 인증을 수행한다 +- 버전 관리 / object lock / replication / lifecycle rule을 운영 필수 요소로 둔다 + +## 공식 의미 (MinIO Operator + Tenant CRD 기준) + +- MinIO Operator는 `minio.min.io/v2` API group의 **Tenant** CR을 watch하여 StatefulSet, Service, PVC, 인증서를 자동 생성한다. +- Tenant는 **namespace당 1개**를 권장한다 (namespace = 소유/정책/쿼터 경계). +- MinIO는 erasure coding을 사용한다. **`servers × volumesPerServer`는 최소 4이어야** 기동한다. EC:N parity (기본 EC:4 ~ EC:8)는 parity drive 수를 결정하며, 장애 허용 drive 수 = parity 수. +- MinIO pool은 불변이다(immutable). pool 내 servers / volumesPerServer는 Tenant 생성 후 변경 불가. 용량 확장은 **새 pool 추가**로 수행. +- **Health endpoints**: + - `/minio/health/live` — 프로세스 liveness (인증 없음) + - `/minio/health/cluster` — **write quorum** 기준 (rolling update 중 false 가능, readiness 비권장) + - `/minio/health/cluster/read` — **read quorum** 기준 (rolling update 허용, readiness 권장) +- **Metrics endpoints**: + - `/minio/v2/metrics/cluster` — cluster-wide (기본 Bearer token 필요) + - `/minio/v2/metrics/node` — per-node + - `/minio/v2/metrics/bucket/api/<bucket>` — bucket API metrics + - `mc admin prometheus generate` 로 scrape config + token 생성. 또는 `prometheusAuthType: public` 설정으로 unauth scrape 허용. +- **KES** (Key Encryption Service)는 별도 sidecar/Deployment로 Vault transit backend와 통신해 SSE-KMS / SSE-S3 per-object key를 발급한다. +- MinIO **service account**는 root access key의 하위 derived credential이다(IAM role 개념 아님). 앱은 service account만 사용하고 root는 bootstrap 전용. +- **Object lock**은 bucket 생성 시점에 활성화해야 하며, `GOVERNANCE` (bypass 권한자 우회 가능) vs `COMPLIANCE` (root도 우회 불가) 두 모드. +- **Site replication**은 최대 16개 MinIO 클러스터를 동기화한다 (IAM, bucket config, object 전체). **Bucket replication**은 특정 bucket만 대상. +- Console은 9090 port, API는 9000 port. + +## 기본 규칙 + +### 1. 배포는 MinIO Operator + Tenant CR + +기본: +- `kubectl apply -k "https://github.com/minio/operator?ref=v6.0.4"` 또는 Helm `minio-operator` + `tenant` chart +- Tenant CR로 pool, credential, TLS, KES, logging, monitoring 선언 +- 수제 StatefulSet 운영 금지 + +### 2. Tenant는 namespace당 1개 + +기본: +- `minio-prod` namespace에 Tenant 1개 +- 테넌트 간 격리가 필요하면 namespace를 복수 생성 +- 동일 namespace에 다른 워크로드와 공존 금지 + +### 3. Erasure coding 요건: `servers × volumesPerServer ≥ 4` + +기본 topology 후보: +| servers | volumesPerServer | 총 drive | 기본 EC parity | 장애 허용 drive | +|---------|------------------|----------|----------------|-----------------| +| 4 | 4 | 16 | EC:4 | 4 | +| 4 | 8 | 32 | EC:4 | 4 | +| 8 | 4 | 32 | EC:4 | 4 | +| 8 | 8 | 64 | EC:4 ~ EC:8 | 4 ~ 8 | + +기본: +- 최소 `4 × 4 = 16` drive 시작 (prod) +- `MINIO_STORAGE_CLASS_STANDARD=EC:4` 이상, critical data는 `EC:8` +- parity 증가 = 용량 감소 + 신뢰성 증가 + +기본 금지: +- `servers × volumesPerServer < 4` → Tenant가 기동 실패 + +### 4. Pool은 immutable — 확장은 새 pool 추가 + +기본: +- 초기 pool의 `servers`, `volumesPerServer`, `volumeClaimTemplate.size`는 평생 고정 +- 용량 부족 시 `spec.pools[]`에 `pool-1`, `pool-2` 추가 +- pool 간 데이터 rebalance는 `mc admin rebalance start` + +### 5. StorageClass 명시 (local-path 금지) + +기본: +- prod: `volumeClaimTemplate.spec.storageClassName: ceph-rbd-retain` / `ebs-gp3` / `local-volume-xfs` (명시적) +- 파일시스템은 `xfs` 권장 (MinIO는 ext4보다 xfs에 최적화) +- `reclaimPolicy: Retain` + PVC 삭제 가드 (Tenant 삭제 시 데이터 소실 방어) + +기본 금지: +- k3s local-path prod 사용 +- default StorageClass fallback + +### 6. Credential: root는 bootstrap 전용, 앱은 service account + +기본: +- `spec.configuration.name`에 root credential Secret (MINIO_ROOT_USER, MINIO_ROOT_PASSWORD) +- Vault KV에 root credential 저장, VSO로 Secret 동기화 +- 앱용 access는 `mc admin user svcacct add` 로 service account 발급 +- service account는 최소 권한 policy 바인딩 + +### 7. TLS는 기본 활성화 + +기본: +- `spec.requestAutoCert: true` → Operator가 Kubernetes CSR로 인증서 자동 발급 (MinIO 자체 CA) +- 사내 PKI 사용 시 `spec.externalCertSecret` + cert-manager Certificate +- API (9000), Console (9090), KES 전부 TLS + +### 8. KES + Vault transit으로 SSE-KMS + +기본: +- `spec.kes` 필드에 KES 사이드카 spec +- KES는 Vault transit engine을 key store로 사용 +- bucket 생성 시 `mc encrypt set sse-kms minio-backup/critical key-id=my-app-key` +- per-object DEK를 KES에서 받아 암호화 + +기본 금지: +- KES 없이 SSE-S3만 사용 (master key가 MinIO 내부에만 존재 → 분실 위험) +- KES가 local filesystem key store 사용 (prod) + +### 9. Versioning + Object Lock은 critical bucket 기본값 + +기본: +- 금융/감사 데이터: `mc version enable` + Object Lock `COMPLIANCE` 모드 +- 백업 bucket: Object Lock `GOVERNANCE` + retention 30일 +- 일반 app bucket: versioning만 (실수 복구) +- lifecycle rule로 오래된 버전 자동 정리 (`mc ilm add --expire-noncurrent-days 90`) + +### 10. Replication: site vs bucket + +기본: +- 전체 IAM/config 동기화 필요: **site replication** (`mc admin replicate add`) +- 특정 bucket만 cross-region 복제: **bucket replication** (`mc replicate add`) +- async replication 특성 인지 (RPO > 0) +- `mc mirror`는 DR 전략 아님 — 일회성 migration/sync 용도 + +### 11. STS + OIDC (Keycloak) 통합 + +기본: +- Keycloak에 `minio` client 생성 (confidential) +- MinIO 설정: + ``` + mc admin config set ALIAS identity_openid \ + config_url="https://auth.example.com/realms/platform/.well-known/openid-configuration" \ + client_id="minio" \ + client_secret="..." \ + claim_name="policy" \ + scopes="openid,profile,email" + ``` +- 앱은 `AssumeRoleWithWebIdentity`로 JWT → 임시 STS credential 교환 +- MinIO policy에 JWT `policy` claim으로 매핑 + +### 12. Health probe: read quorum을 readiness로 + +기본: +- `livenessProbe`: `/minio/health/live` (프로세스 생존) +- `readinessProbe`: `/minio/health/cluster/read` (read quorum) — rolling update 허용 +- `startupProbe`: `/minio/health/live` + `failureThreshold` 넉넉하게 + +기본 금지: +- `readinessProbe`로 `/minio/health/cluster` (write quorum) 사용 → rolling update 시 전체 pod unready + +### 13. Metrics: 내부 scrape 전용 + +기본: +- `spec.prometheus` 또는 `prometheusAuthType: public` (내부 network만) +- 또는 `mc admin prometheus generate` 로 scrape token 발급 후 `bearerTokenSecret` +- ServiceMonitor는 `/minio/v2/metrics/cluster` 대상 +- 외부 Ingress 공개 금지 + +### 14. API Ingress — Console은 내부 전용 + +기본: +- API (9000): 필요한 경우 Ingress로 공개 (S3 API host: `s3.example.com`) +- Console (9090): 내부/운영자 전용, 외부 공개 금지 (별도 host + IP whitelist + OIDC forward-auth) +- Console을 공개하면 root credential UI 로그인 표면 확장 + +### 15. Lifecycle rule로 용량 관리 + +기본: +- 로그 bucket: 30~90일 expire +- tmp / cache bucket: 7일 expire +- versioning enabled bucket: noncurrent version 90일 expire +- incomplete multipart upload: 7일 abort (`mc ilm add --expire-incomplete-upload-days 7`) + +### 16. 로깅 + +기본: +- `spec.log.audit` → bucket에 audit log 저장 또는 webhook으로 외부 전송 +- stdout으로 console log → fluent-bit / Loki 수집 +- audit log는 Object Lock bucket에 저장해 변조 방지 + +### 17. SecurityContext + Resource + +기본: +- `spec.securityContext`: `runAsNonRoot: true`, `runAsUser: 1000`, `fsGroup: 1000`, `runAsGroup: 1000` +- Restricted PSS 준수 +- 단일 pod resource (4 server cluster 기준): + - requests: `cpu: 500m`, `memory: 2Gi` + - limits: `cpu: 4`, `memory: 8Gi` + - 데이터 규모/동시 요청 수에 따라 조정 + +### 18. Anti-affinity + topologySpread + +기본: +- `podAntiAffinity`: hostname 기준 required (같은 node에 MinIO pod 복수 금지) +- `topologySpreadConstraints`: zone 분산 +- EC:4 + 4-zone = 1 zone 장애 허용 + +### 19. Console은 분리, auth는 OIDC + +기본: +- Console endpoint에 `MINIO_IDENTITY_OPENID_*` OIDC 설정 +- root credential UI 로그인은 break-glass 전용 +- 일반 운영자는 OIDC 로그인 + group → policy 매핑 + +### 20. 현재 스택 기본 권장안 + +- 배포: MinIO Operator + Tenant CR (`minio.min.io/v2`) +- Topology: 최소 `4 × 4 = 16` drive, prod는 `8 × 4 = 32` 이상 +- EC: `EC:4` 기본, critical data `EC:8` +- StorageClass: 명시적 (xfs, Retain) +- TLS: `requestAutoCert: true` +- KMS: KES sidecar + Vault transit +- 인증: root는 VSO 주입, 앱은 service account, 사용자는 Keycloak OIDC +- Health: live / cluster-read (readiness) +- Metrics: Prometheus bearer-token scrape +- Versioning + Object Lock: critical bucket 기본값 +- Replication: site (전체) / bucket (부분) 구분 +- Console: 내부 전용 + +## 프로젝트 기준 요약 + +- MinIO Operator + Tenant CR 기본 배포 +- namespace당 Tenant 1개 +- `servers × volumesPerServer ≥ 4` erasure coding 요건 +- Pool immutable — 확장은 새 pool +- StorageClass 명시 + xfs 권장 + Retain +- KES + Vault transit으로 SSE-KMS +- Root credential은 Vault → VSO → Secret 경로 +- 앱 접근은 service account, 사용자는 Keycloak OIDC STS +- Health: `/minio/health/live` + `/minio/health/cluster/read` +- Metrics: Bearer token scrape +- Console 외부 비공개, API만 필요 시 Ingress +- Versioning + Object Lock + Lifecycle rule로 데이터 보호 +- site/bucket replication으로 DR (`mc mirror`는 DR 아님) diff --git a/docs/standards/infra/network-ingress-tls.md b/docs/standards/infra/network-ingress-tls.md new file mode 100644 index 0000000..efae65d --- /dev/null +++ b/docs/standards/infra/network-ingress-tls.md @@ -0,0 +1,170 @@ +# network / ingress / TLS 기준 + +## 목적 + +이 문서는 K3s/Kubernetes(1000+ 서비스) 환경에서 +- 어떤 Service 타입을 언제 쓸지 +- 외부 공개는 Ingress/Gateway 어디로 할지 +- TLS를 어디서 종료할지 +- 인증서는 누가 발급·회전할지 +- NetworkPolicy로 L3/L4 경계를 어떻게 그을지 +를 단일 ground truth로 고정한다. + +이 문서의 목표는 다음과 같다. + +- 외부 attack surface를 최소화한다 +- `ClusterIP`/`NodePort`/`LoadBalancer`/`Ingress`의 역할을 섞지 않는다 +- 모든 Ingress는 cert-manager 발급 TLS + HTTPS redirect + HSTS + TLS 1.2+ 기본 +- K3s 기본 Traefik을 유지하되 packaged manifest는 수정하지 않는다 +- Keycloak/Vault/DB 같은 민감 컴포넌트의 노출 범위를 manifest로 증명한다 + +## 공식 의미 (근거) + +- Service 기본 타입은 `ClusterIP`. 외부 L4 노출은 `NodePort` 또는 `LoadBalancer`, 외부 L7은 Ingress 또는 Gateway API. +- Ingress v1 API는 GA이지만 spec은 frozen 상태이고, 신규 기능(L4, traffic split, header match)은 Gateway API로 이동 중이다. +- Ingress v1은 `spec.ingressClassName` 필드로 컨트롤러를 선택한다. 이전의 `kubernetes.io/ingress.class` annotation은 deprecated이며 1.22에서 공식 deprecation 고지. +- Ingress TLS Secret은 타입이 `kubernetes.io/tls`이고 data key는 `tls.crt`, `tls.key`여야 한다. `spec.tls[].hosts`와 `rules[].host`는 일치해야 한다. +- cert-manager는 `Issuer`/`ClusterIssuer`, `Certificate`, `CertificateRequest`, `Order`, `Challenge` CRD로 구성된다. `Certificate`가 참조하는 `secretName`에 자동으로 `kubernetes.io/tls` Secret이 생성·갱신된다. +- ACME HTTP-01은 public DNS + 80 reachable 필요. DNS-01은 wildcard(`*.example.com`) 발급에 필수이며 DNS provider API credential이 요구된다. +- Traefik v2/v3는 `IngressRoute`(CRD) + `Middleware`(CRD)로 L7 정책(redirect, HSTS, rate-limit, auth)을 체계적으로 구성한다. 기본 Ingress API도 annotation으로 일부 기능을 쓸 수 있다. +- K3s는 Traefik을 packaged component로 설치한다(`/var/lib/rancher/k3s/server/manifests/traefik.yaml`). packaged manifest 직접 수정은 재설치 시 덮어쓰인다. `HelmChartConfig`로 override한다. +- NetworkPolicy는 CNI가 지원해야 enforce된다. K3s 기본 flannel + kube-router policy controller는 v1 NetworkPolicy를 지원한다. + +## 기본 규칙 + +### 1. 기본 Service 타입은 `ClusterIP` +- 내부 통신: `ClusterIP` +- 외부 HTTP/HTTPS: Ingress +- 외부 TCP/UDP L4: `LoadBalancer`(ServiceLB/MetalLB/클라우드 LB 전제) +- `NodePort`는 개발/bootstrap 용도 외 운영 금지. namespace `ResourceQuota.services.nodeports: 0`으로 선제 차단. + +### 2. 모든 Service는 named port + `appProtocol` +```yaml +ports: + - name: http + port: 80 + targetPort: http + protocol: TCP + appProtocol: http +``` +- `name: http|https|grpc|metrics`로 명명. +- `appProtocol` 명시는 Ingress controller/서비스 메시가 L7 처리를 최적화할 수 있게 한다. +- container `ports[].name`과 Service `targetPort`를 이름으로 연결해 포트 번호 drift를 방지. + +### 3. Ingress는 `spec.ingressClassName: traefik` 필수 +- `kubernetes.io/ingress.class` annotation은 **deprecated**. 어떤 Ingress에도 남기지 않는다. +- 컨트롤러가 여러 개인 클러스터(예: Traefik + internal-only NGINX)는 `IngressClass` 리소스를 만들어 class를 명시한다. +- 기본 클래스는 `ingressclass.kubernetes.io/is-default-class: "true"` annotation으로 한 개만 지정. + +### 4. 외부 HTTPS는 cert-manager ClusterIssuer로 발급 +- 운영 공인 도메인: `letsencrypt-prod` ClusterIssuer(ACME HTTP-01) 기본. +- Wildcard/internal CA: DNS-01(`letsencrypt-prod-dns`) 또는 Vault PKI issuer. +- Staging 검증: `letsencrypt-staging` ClusterIssuer로 선행 테스트 후 prod 전환. +- Ingress에는 annotation으로 issuer 지정: `cert-manager.io/cluster-issuer: letsencrypt-prod`. cert-manager가 Certificate + Secret을 자동 생성·갱신한다. +- Certificate CRD를 명시적으로 선언하는 방식도 허용(공유 Secret 재사용, 세밀한 `duration`/`renewBefore` 제어 필요 시). + +### 5. HTTPS redirect + HSTS + TLS 1.2+ 기본 +- 모든 외부 Ingress는 HTTP → HTTPS 영구 리다이렉트. +- HSTS: `max-age=31536000; includeSubDomains; preload` 기본. +- TLS minVersion: `VersionTLS12`(가능하면 1.3). 취약 cipher(RC4, 3DES) disable. +- Traefik에서는 `Middleware`(redirectScheme, headers) + `TLSOption` CRD로 정책을 선언. Ingress annotation 방식 예: + - `traefik.ingress.kubernetes.io/router.entrypoints: websecure` + - `traefik.ingress.kubernetes.io/router.middlewares: default-hsts@kubernetescrd,default-https-redirect@kubernetescrd` + - `traefik.ingress.kubernetes.io/router.tls: "true"` + +### 6. Ingress host는 환경별로 분리, wildcard 남용 금지 +- dev/staging/prod별 호스트 분리(`auth.dev.example.com`, `auth.staging.example.com`, `auth.example.com`). +- Wildcard 인증서는 플랫폼 수준 Certificate로 관리하고 서비스 Ingress가 `secretName` 재사용. +- `defaultBackend`(host 없음) 금지. host가 명시된 rule만 허용. + +### 7. 외부 공개 범위 = "반드시 공개해야 하는 path"만 +- Keycloak: `/realms/`, `/resources/`, `/.well-known/`만 노출. `/admin/`, `/metrics`, `/health`는 공개 금지. +- 관리 포트(Keycloak 9000, Vault 8201, Postgres 5432, Redis 6379)는 Ingress 경유 금지. +- 내부 도구(Argo CD, Grafana, Kibana)는 VPN/zero-trust proxy(예: Pomerium, cloudflared tunnel)로만 노출. + +### 8. TLS 종료 위치와 내부 재암호화 정책 +- 기본: Ingress(Traefik)에서 TLS 종료, 내부 Pod까지는 ClusterIP 경유 평문. +- 민감 backend(Vault, Keycloak token endpoint)는 **Ingress→Pod 재암호화** 검토. Traefik `serversTransport` + `insecureSkipVerify: false`로 backend TLS 사용. +- E2E mTLS가 필요하면 서비스 메시(Linkerd/Istio) 도입을 별도 ADR로 결정. + +### 9. K3s 기본 Traefik은 유지·격리 +- packaged manifest(`traefik.yaml`) 직접 수정 금지. +- 커스터마이징은 `HelmChartConfig`(`kind: HelmChartConfig` in `helm.cattle.io/v1`)로 override. +- Traefik은 `ingress-traefik` namespace에 격리, PSA `baseline`, NetworkPolicy는 80/443/8443 inbound + 모든 app namespace outbound 허용. + +### 10. ServiceLB(klipper-lb) / MetalLB 결정 +- 단일 노드 또는 on-prem 초기: K3s ServiceLB. +- 다중 노드 + BGP/ARP 정책이 필요: MetalLB(`kubectl get deploy -n kube-system | grep servicelb`가 없어야 함, `--disable=servicelb`로 off). +- 클라우드(EKS/GKE/AKS): cloud-provider LoadBalancer가 우선. +- 이 결정이 ADR로 고정되기 전에는 `LoadBalancer` Service를 새로 만들지 않는다. + +### 11. NetworkPolicy는 namespace default-deny 기본 +모든 운영 namespace는 다음 3종 + 서비스별 allow가 기본 세트다. +1. `default-deny-all` (ingress+egress) +2. `allow-dns-egress` (to `kube-system` `k8s-app=kube-dns`, 53/UDP+TCP) +3. `allow-from-ingress-traefik` (특정 app Pod만 허용) + +### 12. NetworkPolicy `from`/`to` 엔트리 AND/OR 규칙 +- **동일 엔트리 내 `namespaceSelector`+`podSelector`** → AND(교집합). 권장 패턴. +- **별도 엔트리로 분리** → OR(합집합). 거의 항상 버그. +- `ipBlock`은 같은 엔트리 내 `namespaceSelector`/`podSelector`와 함께 쓸 수 없다. 외부 CIDR allow는 별도 엔트리. + +### 13. egress NetworkPolicy는 DNS 먼저, 서비스별 allow 나중 +- `default-deny-all`만 적용하면 DNS 해석 실패로 앱이 기동 불가. +- kube-dns 53/UDP+TCP가 첫 번째 allow. +- 외부 API(OIDC issuer, SMTP, S3)는 FQDN이 아니라 IP CIDR로 나와야 v1 NetworkPolicy로 표현 가능. FQDN 기반 egress가 필요하면 Cilium `CiliumNetworkPolicy` 또는 egress gateway 검토. + +### 14. Prometheus scrape는 ingress rule로 열기 +- `monitoring` namespace의 Prometheus Pod만 허용. +- `namespaceSelector: kubernetes.io/metadata.name=monitoring` + `podSelector: app.kubernetes.io/name=prometheus` AND. +- 포트는 `metrics`(9090/9100 등) 전용, 앱 `http` 포트 재사용 금지. + +### 15. Gateway API는 단계적 도입 +- 신규 요구사항(traffic split, header routing, gRPC filter)이 Ingress v1으로 표현 불가하면 Gateway API 검토. +- 전환은 서비스 단위로 Ingress → `HTTPRoute`로 마이그레이션. `GatewayClass`/`Gateway`는 platform-team 소유. + +### 16. health/metrics/admin endpoint 외부 공개 금지 +- `/actuator/*`, `/debug/pprof/*`, `/admin/*`, `/metrics`는 Ingress path에 포함하지 않는다. +- 별도 Service 포트(`name: metrics`)를 만들고 NetworkPolicy로 Prometheus만 허용. + +### 17. Ingress 경로 설계는 prefix + 명시 + 최소 +- `pathType: Prefix` 명시(`ImplementationSpecific` 금지). +- `/`를 바로 노출하기 전 사용자 경로만 선언 가능한지 검토(Keycloak 패턴 참조). +- Path rewrite가 필요하면 Traefik `Middleware.stripPrefix`를 사용하고 annotation으로 명시. + +### 18. ExternalName/headless Service는 용도에 맞춰 +- `ExternalName`은 클러스터 외부 CNAME alias 용도. 인증/TLS 경계와 별개 고려. +- Headless(`clusterIP: None`)는 StatefulSet DNS, client-side LB 용도. Ingress 대상 아님. + +### 19. 현재 스택 기본 권장안 + +#### auth-server / test-server +- Service: `ClusterIP` with named `http`, `metrics` +- Ingress: `ingressClassName: traefik`, cert-manager `letsencrypt-prod`, HSTS + HTTPS redirect +- NetworkPolicy: default-deny + dns + ingress-traefik + db + vault + prometheus + +#### keycloak +- Service: `ClusterIP`, named `http`(8080), `management`(9000) +- Ingress: `/realms/`, `/resources/`, `/.well-known/`만 노출. 9000 포트는 Service로도 cluster 외부 비공개. +- Certificate: 전용(`sso.example.com`), 전용 TLS Secret + +#### vault / db / migration-flyway +- Ingress 없음. ClusterIP only. 접근은 bastion + `kubectl port-forward` 또는 zero-trust proxy. + +#### minio +- API/Console Ingress 분리. Console은 내부 전용, API는 필요 시 signed URL 중심. + +#### ingress-traefik +- `ingress-traefik` namespace 격리, PSA `baseline` +- `Service type=LoadBalancer`(ServiceLB/MetalLB) 또는 `hostPort` 80/443만 + +## 프로젝트 기준 요약 + +- 기본 Service 타입은 `ClusterIP`, named port 필수 +- 모든 Ingress는 `spec.ingressClassName: traefik`, annotation `kubernetes.io/ingress.class` 금지 +- 모든 외부 HTTPS는 cert-manager ClusterIssuer 발급 + HSTS + HTTP→HTTPS redirect + TLS 1.2+ +- Keycloak/Vault/DB 노출 범위는 path/host로 증명, 관리 포트 비공개 +- K3s Traefik은 packaged manifest 직접 수정 금지, `HelmChartConfig` override +- NetworkPolicy default-deny + DNS allow + ingress-traefik allow 기본 세트 +- `namespaceSelector`+`podSelector` AND/OR 차이를 정확히 사용 +- Gateway API는 단계적 도입, 기존 Ingress 유지 diff --git a/docs/standards/infra/observability-health.md b/docs/standards/infra/observability-health.md new file mode 100644 index 0000000..ca26089 --- /dev/null +++ b/docs/standards/infra/observability-health.md @@ -0,0 +1,219 @@ +# observability / health 기준 + +## 목적 + +이 문서는 1000+ 서비스가 공통으로 따르는 observability 기준선이다. metrics 수집 경로, golden signal 정의, 로그 포맷 / 수집 stack, trace 수집(OTel), health endpoint 외부 비공개 원칙, cardinality 가드를 한 파일에 고정한다. + +## 공식 / 업계 근거 + +- **Google SRE Book (Ch.6)**: Four Golden Signals = **Latency, Traffic, Errors, Saturation**. 운영 대시보드의 기본 구성 원칙. +- **RED method (Tom Wilkie, Weaveworks)**: request-driven service에 대해 **Rate, Errors, Duration**. +- **USE method (Brendan Gregg)**: resource에 대해 **Utilization, Saturation, Errors**. +- **kube-prometheus-stack**: Prometheus Operator를 통한 `ServiceMonitor` / `PodMonitor` CRD가 primary scrape path. +- **Prometheus annotation fallback**: `prometheus.io/scrape: "true"` 등은 Operator가 없을 때만 사용. +- **OpenTelemetry**: OTLP protocol + OTel Collector (Deployment gateway + DaemonSet agent) 가 표준. +- **Log shipping canonical stacks**: Loki + Grafana Alloy (또는 Promtail) / Fluent Bit → OpenSearch. 한 플랫폼에서 둘 이상 섞지 않는다. +- `kubectl events` (1.27+ stable) — 기존 `kubectl get events`보다 sort/watch 기본 제공. +- metrics-server: HPA/VPA와 `kubectl top` 을 위한 최소 resource metric. full metrics와 분리. + +## 기본 규칙 + +### 1. Four Golden Signals를 모든 서비스 대시보드의 골격으로 + +각 traffic-facing service는 최소 4개 signal을 노출한다. + +- **Latency**: `request_duration_seconds` histogram (p50/p95/p99). +- **Traffic**: `requests_per_second` by method/status. +- **Errors**: `error_rate` (5xx / 전체). +- **Saturation**: resource utilization (CPU / memory / connection pool / queue depth). + +SLO / alert / dashboard가 이 4개에서 시작한다. + +### 2. RED는 request-driven, USE는 resource에 쓴다 + +- HTTP / gRPC 서비스 → **RED**. +- Node / disk / CPU / DB pool → **USE**. +- 두 방법론을 동시에 활용 가능 (golden signal은 양쪽 합집합). + +### 3. ServiceMonitor / PodMonitor 를 primary scrape path로 + +kube-prometheus-stack을 운영하는 플랫폼에서는 `ServiceMonitor` CRD가 표준이다. + +- `selector.matchLabels` 로 대상 Service 매칭. +- `namespaceSelector` 명시 (암묵적 전체 허용 금지). +- `endpoints[].port` 는 **named port**, 숫자 port 금지. +- `interval` (기본 30s), `scrapeTimeout` (interval < interval) 명시. +- `scheme` (http/https) 명시. +- `bearerTokenSecret` / `tlsConfig` 로 인증 scrape. +- `relabelings` 로 label 위생 (pod_template_hash drop 등). + +Pod에 직접 연결되는 경우 (Service가 없는 워크로드) `PodMonitor` 사용. + +### 4. Annotation-based scrape 는 fallback + +`prometheus.io/scrape: "true"` 계열 annotation은 Prometheus가 Operator 없이 kubernetes_sd_configs로 직접 discover하는 방식이다. ServiceMonitor 대비 label relabel / auth / tls 제어가 약하다. + +- kube-prometheus-stack이 있는 환경: **사용 금지**, ServiceMonitor 통일. +- legacy / 교체 진행 중인 플랫폼: 전환 기간 동안만 사용. + +지원 annotation: +- `prometheus.io/scrape: "true"` +- `prometheus.io/port: "8081"` +- `prometheus.io/path: "/metrics"` +- `prometheus.io/scheme: "http"` + +### 5. metrics port는 외부 비공개, NetworkPolicy로 scraper만 허용 + +- `/metrics` 는 절대 Ingress 경로에 노출하지 않는다. +- metrics port는 별도 containerPort (ex: 8081, 9000). +- NetworkPolicy로 **monitoring namespace의 prometheus pod만** 해당 port에 ingress 허용. + +### 6. Cardinality는 label 설계 단계에서 가드 + +Prometheus TSDB에서 **각 label value 조합 = 새 time series**. cardinality 폭발은 쿼리 OOM / storage 폭증의 가장 흔한 원인. + +금지 label: + +- `user_id`, `tenant_id` (높은 기수) — 대신 top-N aggregation 또는 별도 logging. +- `path` (path에 UUID / numeric ID 포함) — template된 route로 바꾼다 (`/users/:id`). +- `url` 전체, `request_id`, `trace_id`, `session_id`. +- timestamp, epoch value. + +허용 label 예: +- `method` (GET/POST/…), `status_code` (bucketed 2xx/4xx/5xx가 더 안전), `route` (template). + +규칙: **한 metric당 series 수 ≤ 10,000** 목표. 10만 넘어가면 review. + +### 7. Histogram 을 p99 표현 기본값으로 + +- summary는 aggregatable 하지 않다 (서비스 간 p99 합산 불가). +- `histogram_quantile()` 를 위한 `_bucket` + `_count` + `_sum` 를 쓴다. +- bucket boundary는 SLO에 맞춰 튜닝 (`le: 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10`). + +### 8. 로그는 JSON structured, stdout/stderr 로만 + +application log는 **JSON one-line per record**, stdout/stderr로 출력. PVC / hostPath / 컨테이너 내부 file 금지. + +필수 field: + +- `timestamp` (ISO 8601 RFC3339, UTC). +- `level` (`DEBUG`/`INFO`/`WARN`/`ERROR`). +- `service` (= `app.kubernetes.io/name`). +- `trace_id`, `span_id` (OTel에서 주입). +- `message`. +- `error` (object with `type`, `message`, `stacktrace` when level=ERROR). +- optional: `user_id` (hashed), `request_id`, `http_status`. + +### 9. 로그 수집 stack은 한 플랫폼당 하나 + +canonical choice: + +- **Loki + Grafana Alloy (권장)**: 낮은 storage cost, Grafana 통합. +- **Fluent Bit → OpenSearch/Elasticsearch**: full-text search 중심, 높은 storage cost. + +플랫폼 하나에서 둘 다 운영하지 않는다. AI agent가 매니페스트 생성할 때 플랫폼 선택을 context에서 받아 일관되게 적용한다. + +node-level: DaemonSet으로 agent 배포 → tail `/var/log/containers/*.log`. + +### 10. 민감정보는 로그 금지 + 자동 masking + +금지: + +- access/refresh token, bearer, API key. +- DB password, connection string의 password 부분. +- Vault secret value. +- full Authorization header. +- PII (email, phone, SSN 등) 원문. + +구현: + +- logging framework의 structured field 에서만 쓰고 `toString()` 흐름 차단. +- 중앙 수집 파이프라인에 redaction filter 추가. +- 의심스러운 pattern은 debug 로그에서도 masking. + +### 11. OpenTelemetry / OTLP를 trace / metrics 통로로 + +- 애플리케이션: OTel SDK로 계측, OTLP (gRPC 4317 또는 HTTP 4318) 로 export. +- 수집: **OTel Collector DaemonSet (agent)** → **OTel Collector Deployment (gateway)** → backend (Tempo / Jaeger / New Relic / Datadog). +- gateway에서 sampling / tail-based sampling / PII scrubbing 적용. +- app은 cluster 내부 agent endpoint만 알면 됨 (localhost:4317 → DaemonSet). + +### 12. health / metrics / admin endpoint 는 외부 비공개 기본값 + +외부 비공개 대상: + +- `/health`, `/health/*`, `/actuator/*`. +- `/metrics`. +- `/admin`, `/internal`, `/debug`. +- Keycloak management port 9000. +- Vault `/sys/*` endpoint. + +외부 공개는 명시적 review 필요. + +### 13. probe는 health endpoint 와 목적을 구분 + +- probe용 endpoint는 shallow, 빠른 응답. +- 운영자 점검용 deep health는 별도 endpoint (ex: `/ops/deep-health`), 인증 필요. +- Prometheus 가 `/metrics` 를 스크레이프하더라도 probe가 `/metrics` 를 쓰지 않는다 (cost 문제). + +### 14. `kubectl events` 를 기본 event 조회 수단으로 (1.27+) + +Kubernetes 1.27+ 부터 `kubectl events` 가 stable. + +- `kubectl events -A --watch` — cluster-wide live view. +- `kubectl events -n <ns> --for pod/<name>` — 특정 오브젝트. +- `kubectl events --types=Warning` — 경고만. + +`kubectl get events` 대비 sort-by-timestamp 기본, watch 안정적. + +### 15. 알림 기준: Golden Signal 에 SLO 를 먼저 정의 + +- availability SLO: 99.9% / 99.95% 등. +- latency SLO: p99 < 500ms. +- error budget: (1 - SLO) × 기간. +- alert는 **burn rate** 기준 (1h/6h fast burn + 6h/3d slow burn 이중 창). + +단순 "CPU > 80%" alert 는 actionable 하지 않다 (saturation은 dashboard용, 알림은 SLO 기반). + +### 16. 워크로드별 기본 권장안 + +#### auth-server (Spring Boot) +- metrics: micrometer + prometheus registry, `/actuator/prometheus`. +- ServiceMonitor with named port `metrics` (8081). +- tracing: OTel Java agent, OTLP to DaemonSet. +- logging: logback JSON encoder → stdout. + +#### keycloak +- metrics: management port 9000 `/metrics`. +- ServiceMonitor 대상, `/admin` 과 `9000` 외부 비공개. +- event metric cardinality는 `event_type` level 까지만, user / session ID 금지. + +#### vault +- `/sys/metrics?format=prometheus` (token 필요) → ServiceMonitor with `bearerTokenSecret`. +- `/sys/health` 는 sealed/standby 구분해서 alert 룰 따로. + +#### minio +- `/minio/v2/metrics/cluster` + `/node` + `/bucket`. +- bucket metric은 bucket 수 폭증 시 cardinality 주의. + +#### db (PostgreSQL / MySQL) +- postgres_exporter / mysqld_exporter sidecar 또는 별도 Deployment. +- USE method (connection pool saturation, lock wait). + +#### ingress-controller +- RED + upstream response time. +- path label은 반드시 template 화. + +## 프로젝트 기준 요약 + +- Four Golden Signals를 dashboard 골격으로, RED/USE를 세부 방법론으로. +- ServiceMonitor / PodMonitor 를 primary scrape, annotation은 fallback. +- metrics port는 NetworkPolicy로 monitoring namespace만 허용. +- Cardinality는 label 설계에서 가드 (user_id / raw path / timestamp 금지). +- 로그는 JSON structured stdout, trace_id/span_id 포함. +- log shipping stack은 플랫폼당 하나 (Loki+Alloy 또는 Fluent Bit→OpenSearch). +- 로그에 민감정보 금지, 중앙 파이프라인 redaction. +- OpenTelemetry DaemonSet agent + Deployment gateway. +- health / metrics / admin endpoint 외부 비공개. +- `kubectl events` 를 기본 event 조회 수단으로 (1.27+). +- alert는 SLO burn rate 기반, CPU% 같은 단순 threshold 금지. diff --git a/docs/standards/infra/operations-runbook-upgrade-rollback.md b/docs/standards/infra/operations-runbook-upgrade-rollback.md new file mode 100644 index 0000000..4878948 --- /dev/null +++ b/docs/standards/infra/operations-runbook-upgrade-rollback.md @@ -0,0 +1,301 @@ +# operations / runbook / upgrade / rollback 기준 + +## 목적 + +이 문서는 1000+ 서비스를 운영하는 플랫폼에서 모든 변경이 거쳐야 하는 **runbook 규칙**을 고정한다. GitOps 원본, rolling update 파라미터 튜닝, 진보된 배포 전략 (Argo Rollouts, canary, blue/green), K3s 자동 업그레이드, node 작업(drain/cordon), rollback 의미와 경계가 대상이다. + +## 공식 / 업계 근거 + +- Kubernetes `Deployment.spec.strategy`: `RollingUpdate` (default, maxSurge/maxUnavailable 25%/25%) 또는 `Recreate` (singleton). +- `kubectl rollout`: `status --timeout`, `history`, `undo --to-revision`, `pause`, `resume`, `restart`. +- **Argo Rollouts** (https://argoproj.github.io/argo-rollouts/): `Rollout` CRD가 Deployment의 대체제로 canary / blueGreen 지원. `AnalysisTemplate` + Prometheus metric으로 자동 승격/롤백. +- **Flagger**: Argo Rollouts의 대안, service-mesh 친화적 (Istio/Linkerd/App Mesh). +- **ArgoCD**: sync wave (`argocd.argoproj.io/sync-wave: "<int>"`), sync phase hook (`PreSync`, `Sync`, `PostSync`, `SyncFail`, `PostDelete`). +- **Flux**: `Kustomization.spec.dependsOn` 으로 순서 명시. +- `kubectl drain --ignore-daemonsets --delete-emptydir-data --grace-period=30` 가 node maintenance 표준. PDB를 존중하므로 PDB 설계가 전제. +- **K3s System Upgrade Controller** (https://docs.k3s.io/upgrades/automated): `Plan` CRD로 server-plan / agent-plan 분리, concurrency 제어, nodeSelector로 대상 제한. +- Flyway `validate`, `info`, `migrate` — application rollout 과 분리. + +## 기본 규칙 + +### 1. Source of truth = Git 의 Kustomize / Helm overlay + +운영 변경은 Git에 있는 선언형 원본에서만 시작한다. + +기본 금지: + +- 운영 노드에서 manifest 파일 직접 편집. +- `kubectl edit` 로 live object 수정 후 문서 없음. +- `/var/lib/rancher/k3s/server/manifests` 를 1차 원본처럼 사용. + +### 2. 변경 절차는 render → diff → apply → status → post-check 로 고정 + +``` +1. kubectl kustomize <overlay> # render +2. kubectl diff -k <overlay> # preview +3. kubectl apply -k <overlay> # apply +4. kubectl rollout status ... --timeout=10m +5. post-check (smoke test, SLO check) +``` + +`diff` 없는 `apply` 는 프로덕션 금지. + +### 3. `rollingUpdate.maxSurge` / `maxUnavailable` 는 워크로드별 튜닝 + +기본값 `25% / 25%` 는 **replica 수에 따라 틀릴 수 있다**. + +- **replica 2**: default는 maxUnavailable 0, maxSurge 1 추천 → 항상 최소 2 유지 + 1 추가. +- **replica 3**: `maxSurge: 1, maxUnavailable: 0` → 가용성 우선. +- **replica 10+**: `maxSurge: 25%, maxUnavailable: 10%` → 속도와 가용성 균형. +- **latency-sensitive**: `maxUnavailable: 0` 고정. +- **cost-sensitive large fleet**: `maxSurge: 10%, maxUnavailable: 10%`. + +### 4. `Recreate` 전략은 singleton / 동시성 금지 워크로드에만 + +- PVC ReadWriteOnce + 단일 pod 가 전제인 app (legacy MySQL single instance 등). +- Old/New 동시 실행 시 데이터 부정합이 나는 앱. +- 짧은 downtime이 허용되는 경우. + +일반 stateless app은 절대 Recreate 쓰지 않는다. + +### 5. `kubectl rollout` 명령 계열 + +- `kubectl rollout status deployment/<name> --timeout=10m`: 타임아웃 필수. +- `kubectl rollout history deployment/<name>`: revision 확인. +- `kubectl rollout undo deployment/<name> --to-revision=<N>`: 이전 revision으로 되돌림. +- `kubectl rollout pause deployment/<name>`: 롤아웃 중단 (부분 적용 뒤 관찰용). +- `kubectl rollout resume deployment/<name>`: 재개. +- `kubectl rollout restart deployment/<name>`: 이미지 변경 없이 Pod 재생성 (secret 갱신 후 등). + +### 6. 진보된 배포 전략: Argo Rollouts (canary / blueGreen) + +표준 `Deployment` 로는 부족한 경우 (자동화된 canary, metric-based 승격) 에는 Argo Rollouts 의 `Rollout` CRD 를 쓴다. + +- **canary**: `steps:` 로 traffic %, pause, analysis 순서 기술. +- **blueGreen**: `activeService` / `previewService` 로 서비스 두 개 전환. +- **AnalysisTemplate**: Prometheus query로 success rate / p99 latency 측정 → 자동 promote or abort. +- **대안 Flagger**: Istio / Linkerd / App Mesh + Flagger `Canary` CRD. service mesh 있는 플랫폼에서 선택. + +### 7. Argo Rollouts 기본 canary 스텝 + +``` +steps: + - setWeight: 10 + - pause: { duration: 2m } + - analysis: { templates: [{ templateName: success-rate }] } + - setWeight: 25 + - pause: { duration: 5m } + - analysis: { templates: [...] } + - setWeight: 50 + - pause: { duration: 10m } + - setWeight: 100 +``` + +각 setWeight 사이에 pause + analysis 로 자동 abort gate. + +### 8. blueGreen 은 traffic cutover 가 필요한 경우만 + +blueGreen은: +- schema 변경이 양립 불가해서 instant cutover가 필요. +- 외부 system 과 coordination 필요 (rollback도 instant). + +일반 변경은 canary 가 우선. blueGreen 은 trade-off (리소스 2배, warm-up 부담) 때문에 default 가 아니다. + +### 9. ArgoCD sync wave / hook + +배포 순서는 sync wave annotation 으로 명시한다. + +- `argocd.argoproj.io/sync-wave: "-2"` → CRD. +- `argocd.argoproj.io/sync-wave: "-1"` → namespace, secret store, operator. +- `argocd.argoproj.io/sync-wave: "0"` → 본 리소스 (기본). +- `argocd.argoproj.io/sync-wave: "1"` → Ingress, post-deploy job. + +hook: + +- `PreSync`: schema migration job. +- `Sync`: 본 리소스 (default). +- `PostSync`: smoke test Job, cache warm. +- `SyncFail`: 실패 시 알림 Job. +- `PostDelete`: 삭제 후 cleanup. + +### 10. Flux Kustomization dependsOn + +Flux 플랫폼에서는 `Kustomization.spec.dependsOn` 으로 순서를 명시한다. + +```yaml +apiVersion: kustomize.toolkit.fluxcd.io/v1 +kind: Kustomization +metadata: + name: auth-server + namespace: flux-system +spec: + interval: 5m + path: ./k8s/overlays/prod + prune: true + sourceRef: + kind: GitRepository + name: platform + dependsOn: + - name: cert-manager + - name: postgres-operator +``` + +### 11. node 작업 (drain / cordon) 은 PDB 존중 흐름 + +``` +1. kubectl cordon <node> +2. kubectl drain <node> \ + --ignore-daemonsets \ + --delete-emptydir-data \ + --grace-period=30 \ + --timeout=10m +3. 작업 수행 +4. kubectl uncordon <node> +``` + +옵션 의미: + +- `--ignore-daemonsets`: DaemonSet pod는 evict 대상이 아님. +- `--delete-emptydir-data`: ephemeral 데이터 수용. +- `--grace-period=30`: preStop + terminationGracePeriod 존중. +- `--timeout=10m`: PDB 로 인한 무한 대기 차단. + +**PDB 없는 critical workload** 는 drain 실패 또는 downtime 유발. PDB 설계가 선결 조건. + +### 12. K3s System Upgrade Controller + +K3s 자동 업그레이드는 `system-upgrade-controller` 의 `Plan` CRD 를 쓴다. + +구성: + +- **server-plan**: control-plane 먼저 업그레이드. `concurrency: 1`, nodeSelector: `node-role.kubernetes.io/control-plane=true`. +- **agent-plan**: agent 노드. `concurrency: 1~N` (small cluster는 1), server-plan 완료 후. +- `cordon: true`, `drain.force: true, deleteEmptydirData: true, ignoreDaemonsets: true` 표준. +- `version:` 또는 `channel:` 로 target K3s version. +- `upgrade.image: rancher/k3s-upgrade` + 버전 tag. + +### 13. blue/green via two Services (수동 패턴) + +Argo Rollouts 없이 간단 blue/green 이 필요하면: + +- Deployment A (blue), Deployment B (green) 각각. +- Service selector 의 `version` label 만 전환 (blue → green). +- rollback = selector 를 다시 blue 로. +- canary 는 이 방식으로 구현하지 않는다 (Argo Rollouts 사용). + +### 14. Rollback 은 DB rollback 이 아니다 + +**가장 자주 오해되는 규칙**. 반드시 내재화한다. + +- `kubectl rollout undo` 는 Deployment workload 만 되돌린다. +- **DB schema 변경 / migration 은 되돌아가지 않는다**. +- rollback 설계는 **schema-forward-compatible** 로 한다: + - Expand (schema 추가 → 이전 코드도 호환) → Migrate (데이터 이전) → Contract (이전 코드용 schema 제거). Expand/Contract를 별도 릴리스로 분리. +- 긴급 상황에서도 rollout undo 로 DB 를 되돌릴 수 없다. DB 는 별도 restore 절차 (PITR, snapshot). + +### 15. Flyway validate → migrate 를 application rollout 과 분리 + +``` +1. flyway validate # checksum / 순서 확인 +2. flyway info # 대기 migration 확인 +3. flyway migrate # 실제 적용 +4. kubectl apply -k ... # app rollout (별도 단계) +5. kubectl rollout status # 앱 기동 확인 +``` + +application startup 안에 migration 을 숨기지 않는다 (rollout 실패와 migration 실패 섞임). + +### 16. restore 와 rollout 구분 + +**rollout** (workload 변경 되돌리기): + +- `kubectl rollout undo` 또는 이전 Git revision apply. +- Deployment / StatefulSet / DaemonSet 대상. + +**restore** (상태 복구): + +- K3s control plane → etcd snapshot restore. +- PostgreSQL → PITR / base backup + WAL. +- Vault → raft snapshot restore. +- MinIO → replication resync 또는 DR site cutover. + +서로 다른 runbook 이다. "rollback" 이라는 한 단어로 뭉치지 않는다. + +### 17. 긴급 변경도 runbook 을 벗어나지 않음 + +장애 대응 hot-fix 라도: + +- 어떤 overlay 를 바꿨는지 commit / PR. +- 어떤 명령을 실행했는지 기록 (shell history / runbook log). +- 사후 Git 반영 (live-cluster drift 제거). +- 임시 조치의 만료 / 정리 시점 기록. + +### 18. destructive 작업은 명시 승인 + 증거 보존 + +요구 작업: + +- namespace 삭제. +- PVC 삭제. +- StatefulSet 삭제 + PVC 정리. +- K3s snapshot restore. +- Vault raft snapshot restore. +- DB restore overwrite. +- MinIO bucket purge / replication cutover. + +규칙: + +- 2-person approval. +- 작업 전 full snapshot 확보. +- dry-run / diff 선행. +- post-mortem 작성. + +## 권장 절차 템플릿 + +### 일반 app 변경 +1. PR 생성 + review +2. `kubectl kustomize <overlay>` → 렌더 검증 +3. `kubectl diff -k <overlay>` → 변경 확인 +4. `kubectl apply -k <overlay>` +5. `kubectl rollout status deployment/<name> --timeout=10m` +6. smoke test + SLO dashboard 확인 +7. 결과 PR comment + +### DB migration 포함 변경 +1. migration SQL review +2. `flyway validate` → `flyway info` → `flyway migrate` +3. app overlay apply +4. `kubectl rollout status` +5. post-check +6. 실패 시 DB runbook 과 app rollback runbook 분리 적용 + +### K3s control plane upgrade +1. 해당 버전 release notes / caveat 확인 +2. etcd snapshot 확보 +3. `Plan` CRD apply (server-plan) +4. control-plane 업그레이드 완료 확인 +5. `Plan` CRD apply (agent-plan) +6. agent 업그레이드 완료 확인 +7. packaged component 영향 확인 +8. 실패 시 etcd restore runbook + +### node maintenance +1. `kubectl cordon <node>` +2. `kubectl drain <node> --ignore-daemonsets --delete-emptydir-data --grace-period=30 --timeout=10m` +3. 작업 수행 +4. `kubectl uncordon <node>` +5. `kubectl get pods -o wide` 로 재배치 확인 + +## 프로젝트 기준 요약 + +- source of truth = Git Kustomize/Helm overlay, live-cluster 수정 금지. +- render → diff → apply → rollout status → post-check 순서 고정. +- rollingUpdate 파라미터는 워크로드별 튜닝, default 25%/25% 맹신 금지. +- Argo Rollouts 로 canary + AnalysisTemplate 자동 gate, Flagger 는 mesh 환경 대안. +- ArgoCD sync wave / hook, Flux dependsOn 으로 순서 명시. +- node 작업은 PDB 존중 drain 흐름, PDB 설계가 선결. +- K3s 업그레이드는 System Upgrade Controller `Plan` CRD (server → agent). +- blue/green 은 cutover 필요 시, canary 가 default. +- **rollback 은 DB rollback 이 아니다** — schema-forward-compatible 로 설계. +- Flyway validate/migrate 는 application rollout 과 분리. +- restore 와 rollout 은 다른 runbook. +- destructive 작업은 2-person approval + snapshot. diff --git a/docs/standards/infra/resources-probes-availability.md b/docs/standards/infra/resources-probes-availability.md new file mode 100644 index 0000000..29c2079 --- /dev/null +++ b/docs/standards/infra/resources-probes-availability.md @@ -0,0 +1,199 @@ +# resources / probes / availability 기준 + +## 목적 + +이 문서는 1000+ 서비스를 운영하는 Kubernetes 플랫폼에서 AI coding agent가 생성하는 모든 워크로드 매니페스트의 ground truth다. 모든 rule은 Google SRE / Netflix / Shopify의 실제 프로덕션 합의를 기반으로 한다. + +정하는 것: + +- resource requests/limits를 어떤 값으로, 어떤 QoS class로 줄지 +- probe (startup / readiness / liveness) 세 축을 어떻게 분리할지 +- 가용성(PDB / topologySpread / HPA)을 어떤 조합으로 구성할지 +- K3s 환경에서 metrics-server 전제를 어떻게 다룰지 + +## 공식 / 업계 근거 + +- Kubernetes QoS class는 resources 값에 의해 자동 결정된다 (`Guaranteed`, `Burstable`, `BestEffort`). +- CPU는 compressible resource로 limit 초과 시 throttle된다. memory는 incompressible로 OOM kill된다. +- Tim Hockin (Google, Kubernetes co-founder) 및 다수 SRE 컨퍼런스 토크: **CPU limit는 CFS throttling을 quota 미만에서도 유발하므로 대부분의 프로덕션 워크로드에서 제거한다**. CPU request만 설정하여 노드 capacity를 공정 공유한다. +- memory limit는 OOM kill의 유일한 제어 수단이므로 반드시 설정한다. +- `topologySpreadConstraints`는 1.19+ stable. zone과 host 두 축으로 skew를 제한하는 것이 표준이다. +- `podAntiAffinity`는 legacy 대안, 현대 가이드는 topologySpreadConstraints 우선. +- HPA v2 (`autoscaling/v2`) 는 `behavior` block으로 scale up/down stabilizationWindow와 policy를 분리 제어한다. +- PodDisruptionBudget은 `maxUnavailable` 또는 `minAvailable`. 대규모 fleet에서는 `maxUnavailable` 권장 (replica scale 변화 추종). +- startup probe는 성공 전까지 liveness/readiness를 차단한다. slow boot 서비스에 필수. +- Kubernetes 1.29+ native sidecar: init container에 `restartPolicy: Always` 명시. + +## 기본 규칙 + +### 1. QoS class는 의도적으로 선택한다 + +QoS class는 `resources` 값의 결과물이 아니라 **선택**이다. + +- **Guaranteed**: 모든 컨테이너의 request == limit. 가장 높은 eviction 우선순위 보호. + - 적용: latency-sensitive JVM (Keycloak, auth-server critical tier), stateful 단일 인스턴스 (vault active), 단일 ReplicaSet critical path. +- **Burstable**: request < limit 또는 일부만 설정. 탄력적 CPU burst 허용. + - 적용: stateless HTTP API, worker, generic service — 기본값. +- **BestEffort**: request/limit 모두 없음. 가장 먼저 evict됨. + - 적용: 일시적 debugging pod, 무영향 experiment. 프로덕션 금지. + +### 2. CPU limit anti-pattern — 기본은 CPU request only + +Google SRE 및 Tim Hockin의 공식 stance는 "대부분의 워크로드에서 CPU limit를 설정하지 말 것"이다. Linux CFS의 quota 회계가 sub-period burst에서도 throttle을 유발하기 때문이다. + +기본: + +- **CPU**: request만 설정, limit 생략. +- **Memory**: limit 반드시 설정. + - Guaranteed를 원하면: `limits.memory == requests.memory`. + - Burstable 기본값: `limits.memory = 1.1 ~ 1.5 × requests.memory`. + +예외 (CPU limit를 설정해야 하는 경우): + +- multi-tenant 노드에서 noisy neighbor가 측정 가능한 손해를 유발. +- batch/cron Job에서 예산 통제가 필요. +- billing-backed 측정으로 인한 compliance 요구. + +### 3. requests 값은 측정 기반으로 잡는다 + +- p95 cpu usage × 1.2 가 request 시작점. +- p99 memory (steady state) × 1.3 이 memory request 시작점. +- 최초 배포는 **overprovision** 으로 시작 → 1~2주 관측 후 right-sizing. +- VPA recommendation을 참고하되 자동 적용은 하지 않는다 (review 필요). + +### 4. `limit`만 있고 `request`가 없는 구성 금지 + +Kubernetes는 request 미설정 시 limit를 request로 복사한다. 이는 암묵적 Guaranteed QoS로 귀결되며 의도와 다를 수 있다. 반드시 둘 다 명시한다. + +### 5. Probe는 세 축으로 분리한다 + +- **startup probe**: "부팅이 끝났는가". 성공 전까지 readiness/liveness는 실행되지 않는다. + - 필수: Keycloak, Vault, JVM warm-up이 긴 서비스. + - 타이밍 규칙: `failureThreshold × periodSeconds ≥ 최악의 cold start (p99)`. 예: Keycloak `periodSeconds: 10, failureThreshold: 30` = 300s. +- **readiness probe**: "지금 트래픽을 받아도 되는가". 실패 시 Service endpoint에서 제외. + - 모든 traffic-facing 서비스 필수. + - 외부 의존성 전체 가용성을 묶지 않는다 (동시 탈락 방지). +- **liveness probe**: "재시작이 치료인가" (deadlock only). + - Default = 설정하지 않거나 readiness와 다른 가벼운 self-check. + - **잘못 설정하면 cascading restart 유발**. Kubernetes 공식 문서 명시. + +### 6. readiness는 shallow, liveness는 더 shallow + +readiness는 "app loop이 요청을 처리 가능한가"까지만 검사한다. DB connection pool 초기화처럼 intra-pod 조건은 OK. 외부 DB `SELECT 1` 전체 가용성 체크는 금지. + +liveness는 process deadlock 감지 전용. HTTP endpoint면 `/livez` 같은 매우 가벼운 200 응답. + +### 7. topologySpreadConstraints를 기본 가용성 primitive로 + +production multi-zone cluster에서는 **zone + host 두 축** 모두 제약한다. + +```yaml +topologySpreadConstraints: + - maxSkew: 1 + topologyKey: topology.kubernetes.io/zone + whenUnsatisfiable: DoNotSchedule + labelSelector: + matchLabels: + app.kubernetes.io/name: auth-server + - maxSkew: 1 + topologyKey: kubernetes.io/hostname + whenUnsatisfiable: ScheduleAnyway + labelSelector: + matchLabels: + app.kubernetes.io/name: auth-server +``` + +- zone `DoNotSchedule`: 프로덕션에서 zone 장애 격리에 필수. +- host `ScheduleAnyway`: 노드 부족 시 배포 불가 방지. + +### 8. podAntiAffinity는 legacy로 본다 + +topologySpreadConstraints가 등장한 뒤 podAntiAffinity는 대부분의 use case에서 대체되었다. 신규 매니페스트는 topologySpreadConstraints를 우선 적용한다. + +예외: 단순 "한 노드에 두 개 이상 금지" 규칙만 필요하고 spread 회계가 불필요한 경우. + +### 9. HPA는 autoscaling/v2, behavior block 필수 + +`autoscaling/v1`은 더 이상 사용하지 않는다. `autoscaling/v2`를 기본으로 한다. + +- `metrics:` 유형: `Resource` (cpu/memory), `Pods`, `Object`, `External`, `ContainerResource`. +- `behavior.scaleUp.stabilizationWindowSeconds: 0` (트래픽 급증에 빠르게 반응). +- `behavior.scaleDown.stabilizationWindowSeconds: 300` (flapping 방지). +- `policies` 조합: `type: Percent` (현 replica의 X%)와 `type: Pods` (절대 수) 동시 지정, `selectPolicy: Max` 또는 `Min`. + +### 10. HPA 전제 조건 + +- resource requests가 먼저 잡혀 있어야 한다 (utilization target이 request 기준). +- startup probe가 안정화되어 있어야 한다 (scale-up 중 flapping 방지). +- 해당 워크로드가 **horizontal scale로 효과가 있는** 성격이어야 한다. stateful / DB / quorum 기반은 HPA 비대상. +- K3s metrics-server가 packaged로 배포되어 있음을 전제로 하되, availability를 runbook에서 점검한다. + +### 11. PDB는 fleet 규모에 맞춰 `maxUnavailable` 우선 + +- replica ≥ 3: `maxUnavailable: 1` 또는 `maxUnavailable: 25%`. +- replica 대규모 (10+): `maxUnavailable: 10%` 권장 (유연성). +- replica 2: `maxUnavailable: 1`. +- replica 1: PDB 금지 (node drain을 막는다). +- quorum 기반 (etcd, vault raft, DB cluster): `minAvailable` 로 quorum 수 명시. + +### 12. PDB zero disruption 금지 + +`maxUnavailable: 0` 또는 `minAvailable: 100%` 는 node drain / maintenance를 완전 차단한다. Kubernetes 업그레이드 자체가 불가능해진다. 명시적 예외 승인 없이 사용 금지. + +### 13. init container 와 sidecar 순서 (1.29+) + +- **init container**: main 전에 실행, 완료 후 종료. schema migration, secret preparation 용. +- **native sidecar (1.29+)**: init container에 `restartPolicy: Always` 명시. main과 병렬 실행, main 종료 후 종료. + - 사용: log forwarder, metrics exporter, service mesh proxy. +- `initContainers` 배열 순서가 실행 순서다. + +### 14. 워크로드별 기본 권장안 + +#### auth-server (stateless Spring Boot) +- QoS: **Burstable**. +- CPU: request only (`500m`). Memory: request `1Gi`, limit `1.5Gi`. +- Probes: startup `/actuator/health/started` (60s), readiness `/actuator/health/readiness`, liveness `/actuator/health/liveness`. +- HPA: CPU 70%, min 3, max 20, scale-down 300s. +- PDB: `maxUnavailable: 1`. +- topologySpread: zone `DoNotSchedule`, host `ScheduleAnyway`. + +#### keycloak (JVM, slow boot, latency-sensitive) +- QoS: **Guaranteed** (request == limit, memory 2Gi 고정). +- CPU: request `1`, limit `1` (Guaranteed 요구). +- Probes: startup 5분 budget (`periodSeconds: 10, failureThreshold: 30`), readiness `/health/ready` on 9000, liveness `/health/live` on 9000. +- HPA: 보통 **비대상**. 고정 replica (3)로 시작, 측정 후 검토. +- PDB: `maxUnavailable: 1`. + +#### vault (raft quorum) +- QoS: **Guaranteed**. +- Probes: readiness/liveness는 raft sealed/active 상태 구분. +- HPA: 비대상. +- PDB: `minAvailable: 2` (3-node raft 기준 quorum 보존). + +#### minio (erasure coded storage) +- QoS: **Guaranteed**. +- PDB: `minAvailable: N-1` (erasure set 기준). +- HPA: 비대상. + +#### migration-flyway (Job) +- probe 없음 (Job은 probe 무의미). +- requests 명시, limit는 memory만. +- activeDeadlineSeconds 설정. +- HPA/PDB 비대상. + +#### ingress-controller +- QoS: **Burstable** 또는 Guaranteed (tier에 따라). +- HPA 후보 (traffic 기반). +- PDB: `maxUnavailable: 1`. + +## 프로젝트 기준 요약 + +- QoS는 의도적으로 선택. Guaranteed는 latency-sensitive JVM, Burstable은 stateless 기본. +- CPU limit 기본 제거 (throttling 회피). Memory limit 필수. +- requests/limits 함께 명시. limit만 단독 금지. +- probe 세 축 분리. startup 타이밍은 worst-case cold start 기준. +- topologySpreadConstraints zone + host 두 축으로 기본 구성. +- HPA v2 + behavior block. resource requests / startup 안정화 후 적용. +- PDB는 `maxUnavailable` 우선, replica 전략과 함께 결정. +- 1.29+ native sidecar는 init container `restartPolicy: Always`. +- K3s metrics-server는 HPA 전제로만 신뢰, full metrics는 별도 stack. diff --git a/docs/standards/infra/scripts.md b/docs/standards/infra/scripts.md new file mode 100644 index 0000000..88fd45b --- /dev/null +++ b/docs/standards/infra/scripts.md @@ -0,0 +1,293 @@ +# infra scripts 기준 + +## 목적 + +이 문서는 1000+ 서비스 플랫폼에서 인프라 스크립트가 지켜야 할 품질 기준선이다. 스크립트는 선언형 원본 (Kustomize / Helm / ArgoCD / Flux) 을 **대체하지 않는다**. 렌더 / diff / 적용 / 백업 / 복구 / 부트스트랩을 **orchestration** 하는 얇은 레이어로 제한한다. + +## 공식 / 업계 근거 + +- **Google Shell Style Guide**: `#!/usr/bin/env bash`, `set -e`, `main "$@"`, function-first, `local`. +- **Unofficial Bash Strict Mode (Aaron Maxwell)**: `set -euo pipefail` + `IFS=$'\n\t'` 가 사실상 표준. +- **ShellCheck** (https://www.shellcheck.net/): 정적 분석. CI에서 mandatory. +- **shfmt** (mvdan/sh): 자동 포맷터. line-length / indent 규격 강제. +- **GitOps 원칙** (Weaveworks 정의): 선언형 원본 + auto-reconcile. 스크립트는 원본을 소유하지 않는다. + +## 기본 규칙 + +### 1. 모든 스크립트 맨 위에 strict mode + +```bash +#!/usr/bin/env bash +set -euo pipefail +IFS=$'\n\t' +``` + +의미: + +- `set -e` : 명령 실패 시 즉시 종료. +- `set -u` : unset variable 참조 시 에러. +- `set -o pipefail` : pipeline 중 하나라도 실패하면 전체 실패. +- `IFS=$'\n\t'` : 기본 IFS에서 space 제거 → 파일명 공백 sane split. + +예외 금지. CI lint 에서 검사. + +### 2. 정리 작업은 `trap` 으로 보장 + +임시 파일 / 임시 kubeconfig / port-forward / background job 은 반드시 trap EXIT 에서 정리. + +```bash +TMPDIR="$(mktemp -d)" +trap 'rm -rf "${TMPDIR}"' EXIT INT TERM +``` + +- `EXIT`: 정상/비정상 종료 모두 잡음. +- `INT TERM`: signal 기반 종료 시에도 실행. +- trap은 setup 직후 즉시 설치. + +### 3. ShellCheck + shfmt 는 CI 에서 필수 + +- `shellcheck -S style scripts/**/*.sh` → CI fail 시 merge 금지. +- `shfmt -i 2 -bn -ci -d scripts/` → 자동 포맷 검증. +- suppress (`# shellcheck disable=...`) 는 **줄 단위**로만, 이유 주석 필수. +- "경고 너무 많아서 꺼둔다" 금지. + +### 4. 표준 `log()` 함수 (ISO 8601 timestamp + level, stderr) + +```bash +log() { + local level="$1"; shift + local ts + ts="$(date -u +'%Y-%m-%dT%H:%M:%SZ')" + printf '%s [%s] %s\n' "${ts}" "${level}" "$*" >&2 +} + +info() { log INFO "$@"; } +warn() { log WARN "$@"; } +error() { log ERROR "$@"; } +fatal() { log FATAL "$@"; exit 1; } +``` + +- stdout 은 머신 판독용 결과 전용. +- stderr 로 로그 → pipeline 안전. +- UTC ISO 8601 로 tz 모호성 제거. + +### 5. 엔트리포인트 `main "$@"` 패턴 + +```bash +usage() { + cat <<'EOF' >&2 +Usage: render-diff-apply.sh [--overlay PATH] [--context NAME] [--yes] + --overlay PATH path to kustomize overlay (required) + --context NAME kube context (required) + --yes skip confirmation for apply +EOF +} + +main() { + # 인자 파싱 + # 환경 검증 + # 함수 호출 + : +} + +main "$@" +``` + +- 엔트리포인트 스크립트는 **얇게**. 비즈니스 로직은 `lib/` 또는 `tasks/`. +- `usage()` 함수 필수. + +### 6. 변수는 `local`, command substitution 은 분리 + +```bash +bad_pattern() { + local ctx="$(kubectl config current-context)" # local 이 exit status 가려버림 +} + +good_pattern() { + local ctx + ctx="$(kubectl config current-context)" # 분리 → $? 보존 +} +``` + +ShellCheck SC2155 가 이것을 잡음. + +### 7. Idempotent 를 기본값으로 + +- `create` 보다 `apply` / `ensure` 성격. +- `kubectl apply -k` 는 idempotent. +- `mkdir -p`, `kubectl create namespace X --dry-run=client -o yaml | kubectl apply -f -` 패턴. +- destroy 성격은 반드시 opt-in. + +### 8. `kubectl diff` → `kubectl apply` 필수 흐름 + +프로덕션 적용 스크립트 기본 흐름: + +``` +1. kubectl kustomize <overlay> > render.yaml # render +2. kubeconform / kubectl apply --dry-run=server # validate +3. kubectl diff -k <overlay> # preview +4. confirm gate (CONFIRM=yes 또는 --yes) +5. kubectl apply -k <overlay> # apply +6. kubectl rollout status ... --timeout=10m # watch +``` + +### 9. `--dry-run=server` 를 validation 기본값으로 + +client-side dry run 은 CRD schema / admission webhook 을 평가하지 않는다. **server-side dry run** 을 쓴다: + +```bash +kubectl apply -k "${OVERLAY}" --dry-run=server +``` + +### 10. destructive 작업은 `--yes` 또는 `CONFIRM=yes` gate + +delete / prune / restore overwrite 류는 명시적 opt-in 없이 실행 금지. + +```bash +if [[ "${CONFIRM:-no}" != "yes" ]]; then + fatal "destructive operation requires CONFIRM=yes" +fi +``` + +또는: + +```bash +if [[ "${YES:-0}" -ne 1 ]]; then + warn "re-run with --yes to confirm" + exit 2 +fi +``` + +### 11. 환경을 암묵적으로 추론하지 않는다 + +- 대상 overlay / namespace / context 는 **명시적 인자**로. +- `kubectl config current-context` 에 몰래 의존 금지. +- 필요한 env var 는 시작 시 `[[ -z "${FOO:-}" ]] && fatal "FOO required"` 로 검증. + +### 12. JSON 파싱은 `jq` / `kubectl -o jsonpath`, 절대 regex 로 하지 않는다 + +```bash +# BAD +kubectl get pod foo -o yaml | grep "image:" | awk '{print $2}' + +# GOOD +kubectl get pod foo -o jsonpath='{.spec.containers[0].image}' + +# GOOD +kubectl get pod foo -o json | jq -r '.spec.containers[0].image' +``` + +kubectl/kubernetes 출력에 regex 쓰면 field 순서 / 라벨 / 버전 변화에 깨진다. + +### 13. 비밀값은 로그 / stdout / 파일에 남기지 않는다 + +- env var / secret value 를 `set -x` 아래에서 직접 사용 금지. +- debug 모드에서는 masking: + +```bash +mask_secrets() { + sed -E \ + -e 's/(password=)[^ ]+/\1***/g' \ + -e 's/(token=)[^ ]+/\1***/g' \ + -e 's/(Authorization: Bearer )[A-Za-z0-9._-]+/\1***/g' +} + +some_command --debug | mask_secrets +``` + +- secret 을 참조해야 하면 `--from-file` 이나 stdin pipe 로 주입, argv 금지. + +### 14. 스크립트는 선언형 원본을 소유하지 않는다 + +**금지**: + +- 대규모 heredoc YAML 생성기 (스크립트 내부에 매니페스트 숨김). +- 환경별 로직이 if/else 로만 존재. +- 스크립트만 실행해야 실제 상태를 알 수 있는 구조. + +**허용**: + +- `kubectl apply -k overlays/<env>` wrapping. +- Helm chart render + apply orchestration. +- backup / restore (stateful data 만 대상). +- bootstrap (namespace, secret store 설치 같은 일회성). +- smoke test. + +### 15. 폴더 구조 + +```text +scripts/ + bin/ # 엔트리포인트 (얇게) + render + diff + apply + backup-k3s + restore-k3s + lib/ # 공통 함수 + common.sh # log, fatal, require_cmd, confirm + kubectl.sh # kubectl wrappers + kustomize.sh # kustomize render helpers + tasks/ # 도메인 작업 + keycloak.sh + vault.sh + flyway.sh + ci/ # CI 검증 전용 + lint.sh + validate.sh +``` + +- `bin/` 파일 이름은 동사. +- `lib/` 는 20개 내외, 잡동사니 함수 금지. +- 하나의 거대 `deploy.sh` 금지. + +### 16. retry 는 함수화, 무한 루프 금지 + +```bash +retry() { + local max="$1"; shift + local delay="$1"; shift + local n=0 + until "$@"; do + n=$((n + 1)) + if (( n >= max )); then + return 1 + fi + sleep "${delay}" + done +} + +retry 5 3 kubectl rollout status deployment/foo --timeout=30s +``` + +backoff 는 선형/지수 명시, 무한 retry 금지. + +### 17. quoting / array 기본값 + +- 모든 변수 전개는 `"${VAR}"`. +- 인자 list 는 array: `args=(--namespace foo --context bar)`. +- `"$@"` 유지. +- unquoted glob / word splitting 금지. + +### 18. 출력 채널 규칙 + +- stdout → 머신 판독 결과 (jsonpath 결과, 렌더된 YAML 등). +- stderr → 로그, 경고, 에러, 진행 표시. +- exit code → 0 success, 1 error, 2 usage error. + +pipeline 하류 도구가 stdout 을 parse 한다는 전제로 작성. + +## 프로젝트 기준 요약 + +- strict mode `set -euo pipefail` + `IFS=$'\n\t'` 필수. +- trap EXIT INT TERM 으로 정리 보장. +- ShellCheck + shfmt CI 필수. +- ISO 8601 UTC + LEVEL 로그 함수 (stderr). +- `main "$@"` 패턴 + usage() 함수. +- local 선언과 command substitution 분리. +- idempotent 기본, destructive 는 `--yes` / `CONFIRM=yes` gate. +- `kubectl diff` → `apply`, `--dry-run=server` validation. +- 환경 추론 금지, overlay/namespace/context 명시. +- JSON 은 jq / jsonpath, 절대 regex 금지. +- secret 은 log / argv 에 남기지 않고 masking. +- 스크립트는 선언형 원본을 소유하지 않는 orchestration 레이어. +- `bin/ lib/ tasks/ ci/` 폴더 분리, giant deploy.sh 금지. diff --git a/docs/standards/infra/security-hardening.md b/docs/standards/infra/security-hardening.md new file mode 100644 index 0000000..4cf5edb --- /dev/null +++ b/docs/standards/infra/security-hardening.md @@ -0,0 +1,183 @@ +# security hardening 기준 + +## 목적 + +이 문서는 K3s/Kubernetes 기반 인프라(1000+ 서비스 규모)에서 +- 어떤 Pod Security Standard(PSS) 수준을 강제할지 +- Pod/ServiceAccount/RBAC/NetworkPolicy/Secret/Image supply chain을 어디까지 하드닝할지 +- Platform 예외를 어떻게 선언할지 +를 단일 ground truth로 고정한다. + +이 문서의 목표는 다음과 같다. + +- root/privileged/host namespace 사용을 기본 금지하고 예외는 manifest로 증명한다 +- allow-all network/RBAC을 운영 기본값으로 두지 않는다 +- Secret의 저장·접근·전송 모든 단계에서 신뢰 경계를 명시한다 +- K3s production hardening(PSS, NetworkPolicy, audit, at-rest encryption)을 묶음으로 본다 + +## 공식 의미 (근거) + +- Kubernetes 1.25부터 `PodSecurityPolicy`(PSP)는 제거되었다. 대체는 **Pod Security Admission (PSA)** + `pod-security.kubernetes.io/*` namespace label이다. +- Pod Security Standards는 `privileged`, `baseline`, `restricted` 세 프로파일이다. `restricted`는 업계 최신 hardening best practice를 반영한다. +- PSA는 `enforce`, `audit`, `warn` 세 모드를 지원하고, 각 모드마다 버전을 `latest`/`vX.Y`로 고정할 수 있다. +- `restricted` 프로파일이 강제하는 주요 필드: `runAsNonRoot=true`, `allowPrivilegeEscalation=false`, `capabilities.drop=["ALL"]`(네트워크 capability는 `NET_BIND_SERVICE`만 추가 허용), `seccompProfile.type in {RuntimeDefault, Localhost}`, host namespace/Port/Path 금지, `privileged=false`, `procMount=Default`, ephemeral volume 화이트리스트. +- NetworkPolicy는 namespace 내 매칭되는 Pod가 하나라도 있으면 그 Pod의 해당 방향 트래픽은 **정책 합집합**만 허용된다(그 외 default deny). 매칭되는 Pod가 없으면 기본은 allow-all이다. +- NetworkPolicy `from`/`to` 원소 내에서 `namespaceSelector`와 `podSelector`를 **동일 엔트리** 안에 두면 AND(교집합), **별도 엔트리**로 두면 OR(합집합)로 계산된다. 이 차이가 cross-namespace 정책 버그의 1순위 원인이다. +- Secret은 기본적으로 etcd에 base64로만 저장되므로 운영 클러스터는 `EncryptionConfiguration`(aescbc/aesgcm/KMS)을 필수로 구성한다. K3s는 `--secrets-encryption` 플래그로 aescbc provider를 활성화한다. +- ServiceAccount token은 Pod에 기본 자동 마운트된다. 1.24부터는 time-bound projected token이 기본이다. +- RBAC는 additive-only이며 `Role`/`RoleBinding`(namespace) 우선, `ClusterRole`/`ClusterRoleBinding`은 예외적이다. + +## 기본 규칙 + +### 1. 모든 application namespace는 PSA `restricted` enforce 라벨이 기본 +namespace 생성 시 다음 라벨을 **enforce** 수준으로 붙인다(예외는 rule 3). + +```yaml +metadata: + labels: + pod-security.kubernetes.io/enforce: restricted + pod-security.kubernetes.io/enforce-version: v1.29 + pod-security.kubernetes.io/audit: restricted + pod-security.kubernetes.io/audit-version: v1.29 + pod-security.kubernetes.io/warn: restricted + pod-security.kubernetes.io/warn-version: v1.29 +``` + +- `enforce`: deny on violation (hard gate) +- `audit`: audit log 기록 +- `warn`: kubectl 사용자에 경고 +- version은 `latest` 대신 **명시 버전 pin**을 기본으로 한다. 업그레이드는 ADR로 관리한다. +- PSP는 1.25에서 제거되었으므로 어떤 manifest/차트에도 `policy/v1beta1 PodSecurityPolicy`를 남기지 않는다. + +### 2. Restricted 프로파일 전 필드 계약 +application Pod/Deployment는 아래 전부를 만족해야 한다. 하나라도 빠지면 PSA가 reject한다. + +- `spec.automountServiceAccountToken: false` (API 호출 불필요 시) +- `spec.securityContext.runAsNonRoot: true` +- `spec.securityContext.runAsUser: <non-zero numeric>` (예: `10001`) +- `spec.securityContext.runAsGroup: <non-zero numeric>` (예: `10001`) +- `spec.securityContext.fsGroup: <numeric>` (volume 쓰기 필요 시) +- `spec.securityContext.seccompProfile.type: RuntimeDefault` (Pod 또는 컨테이너 레벨) +- 컨테이너 `securityContext`: + - `allowPrivilegeEscalation: false` + - `privileged: false` + - `readOnlyRootFilesystem: true` + - `runAsNonRoot: true` + - `capabilities.drop: ["ALL"]` + - `capabilities.add`: 비어있거나 `["NET_BIND_SERVICE"]`만 허용 +- Pod spec 금지 필드: `hostNetwork`, `hostPID`, `hostIPC`, `hostUsers=false`, `hostPath` volume, `hostPort`, `ephemeralContainers`에 한해 `privileged` + +### 3. Platform 예외 namespace는 ADR 문서 + 좁은 enforce +`kube-system`, `ingress-traefik`, `vault`, `cert-manager`, `vault-secrets-operator`, `monitoring` 등은 `baseline` 또는 `privileged` 프로파일이 필요할 수 있다. 예외는 다음을 manifest에 고정한다. + +- namespace label은 **필요한 최저 수준**(`baseline` 우선, `privileged`는 CNI/CSI/Node-exporter에 한정) +- 예외 근거 ADR 링크 annotation: `platform.example.com/psa-exception: "ADR-0042"` +- 예외 받는 구체 필드(예: `hostNetwork`, `CAP_NET_ADMIN`)만 열고 나머지는 restricted + +### 4. privileged / host namespace 기본 금지 +application Pod는 `privileged: true`, `hostNetwork`, `hostPID`, `hostIPC`, `hostPath`, `hostPort`를 쓰지 않는다. 필요성이 있으면 rule 3의 platform 예외로 이관한다. + +### 5. runAsNonRoot + numeric UID 강제 +image가 `USER` 지시어로 non-numeric user만 지정해도 PSA는 runtime에 UID 확인이 불가능하면 reject할 수 있다. 항상 numeric UID를 명시한다(권장 범위: 10000–65535). UID 0은 전 영역 금지. + +### 6. seccompProfile은 RuntimeDefault 우선 +Pod 레벨 `seccompProfile.type: RuntimeDefault`를 기본으로 두어 모든 컨테이너에 상속. 특정 컨테이너가 custom profile이 필요하면 `Localhost`로 개별 선언하고 profile 파일 경로를 문서화한다. kubelet `--seccomp-default=true`를 클러스터 플래그로 검토한다. + +### 7. capabilities drop-first +`drop: ["ALL"]`이 기본이다. 추가 허용 화이트리스트는 `NET_BIND_SERVICE`뿐. `CAP_SYS_ADMIN`, `CAP_NET_ADMIN`, `CAP_SYS_PTRACE`, `CAP_NET_RAW`는 platform 예외에서만 허용한다. + +### 8. readOnlyRootFilesystem + writable emptyDir +모든 application container는 `readOnlyRootFilesystem: true`. 쓰기 경로는 `emptyDir`(가능하면 `medium: Memory`, `sizeLimit` 명시)로 분리한다. `/tmp`, `/var/run`, 앱 cache 경로는 별도 volume mount. + +### 9. ServiceAccount는 workload 1:1, 토큰 기본 비마운트 +- `default` SA 사용 금지. namespace당 Deployment별 전용 SA 생성. +- `automountServiceAccountToken: false`를 Pod spec에 기본 명시. +- Kubernetes API를 호출해야 하는 Pod만 `true` + projected token volume을 명시적으로 선언. + +### 10. RBAC는 namespace Role + RoleBinding 우선 +- `*` resource/verb 금지. +- `secrets` 리소스는 `get` + `resourceNames` 명시. `list`/`watch`는 controller/operator에만 허용. +- `ClusterRole`/`ClusterRoleBinding`은 CRD controller, metrics scraper, admission webhook 같은 cluster-wide 컴포넌트에 한정하고 subject는 platform SA로 제한. +- `system:masters` group binding 금지. + +### 11. NetworkPolicy default-deny + 명시적 allow +운영 namespace는 생성 직후 다음 3종을 배포한다. + +1. default-deny-all (ingress + egress) +2. allow-dns-egress (to `kube-system`의 `k8s-app=kube-dns`, UDP/TCP 53) +3. allow-from-ingress-controller (namespaceSelector=`ingress-traefik` + podSelector=`app.kubernetes.io/name=traefik`) + +추가 allow는 서비스별 요구(예: DB, Redis, Vault, S3, OIDC endpoint)에 맞춰 **한 엔트리 = AND, 여러 엔트리 = OR** 규칙을 지켜 작성한다. + +### 12. NetworkPolicy enforcement 전제 검증 +- K3s 기본 CNI(flannel) + kube-router policy controller가 실제로 enforce하는지 배포 후 negative test 필수. +- Calico/Cilium 전환 시 `--disable-network-policy` + `--flannel-backend=none` 조합을 ADR로 관리. +- 운영 정책 변경 후에는 synthetic probe(`netshoot` Pod)로 deny/allow 경로를 모두 검증한다. + +### 13. Secret at-rest encryption은 운영 필수 +- API Server `--encryption-provider-config` 지정: `aescbc` 또는 KMS provider(권장: AWS KMS/GCP KMS/HashiCorp Vault Transit). +- K3s는 `--secrets-encryption` 플래그 활성화(aescbc). 기존 Secret은 `kubectl get secrets -A -o json | kubectl replace -f -`로 재암호화. +- etcd 백업 자체도 별도 암호화 저장. + +### 14. 민감정보 delivery 경로 표준화 +1순위: Vault Secrets Operator(VSO)가 Vault → K8s Secret으로 sync → envFrom/volume +2순위: External Secrets Operator(ESO) + AWS/GCP Secret Manager +3순위: CSI Secret Store Driver (volume mount only, K8s Secret 미생성) +4순위: SealedSecrets / SOPS (GitOps + encrypted-at-rest in Git) +모든 경로는 config-and-secrets 문서의 선택 기준 표를 따른다. + +### 15. Image supply chain 통제 +- `imagePullPolicy: Always`는 mutable tag(`latest`, `main`)에만. 운영은 **digest pin** `image: registry.example.com/auth-server@sha256:<64hex>` 을 기본으로. +- `kubernetes.io/dockerconfigjson` 타입 `imagePullSecret`은 workload SA에 `spec.imagePullSecrets`로 연결. +- Private registry만 허용: `ImagePolicyWebhook` 또는 Kyverno/Gatekeeper로 public Docker Hub deny. +- Image signing(cosign)과 SBOM 요구를 CI에서 강제, cluster-level로는 `ClusterImagePolicy` (sigstore policy-controller) 검토. + +### 16. health/metrics/admin endpoint 내부 전용 기본 +- `/metrics`는 Service 별도 포트(`name: metrics`) + NetworkPolicy로 `monitoring` namespace Prometheus만 허용. +- `/actuator/*`, `/admin`, `/debug/pprof`는 Ingress 경로에 노출 금지. +- Keycloak `/admin/`, `/metrics`, `/health`는 외부 공개 금지 기본값(network-ingress-tls 문서와 함께 enforce). + +### 17. audit + policy together +- API Server `--audit-policy-file`로 최소한 Secret/RBAC/PodSecurity violation을 `RequestResponse` 수준으로 기록. +- PSA `audit` 라벨을 모든 namespace에 붙여 violation을 audit log로 수집. +- Kyverno 또는 Gatekeeper로 PSA 밖의 policy(resource limits, image registry, required labels)를 보완. + +### 18. Pod spec 기타 하드닝 기본 +- `resources.limits.cpu`, `resources.limits.memory` 필수. memory limit 없는 Pod는 OOM-Kill 전파 위험. +- `terminationGracePeriodSeconds` 명시(기본 30은 서비스별로 재조정). +- `readinessProbe` + `livenessProbe` 분리. `startupProbe`는 JVM/느린 기동 앱에 필수. +- `topologySpreadConstraints` 또는 `podAntiAffinity`로 node 단일 장애 블라스트 반경 축소. + +### 19. 현재 스택 기본 권장안 + +#### auth-server / test-server / keycloak / migration-flyway +- namespace PSA: `restricted` enforce pinned to `v1.29` +- SA: 서비스별 1:1, `automountServiceAccountToken: false` +- Secret: VSO로 Vault → K8s Secret sync +- NetworkPolicy: default-deny + dns + ingress + db + vault + metrics-scrape + +#### ingress-traefik +- namespace PSA: `baseline` (예외 ADR 기록) +- `hostNetwork` 금지(ServiceLB 또는 MetalLB 사용), hostPort는 80/443/8443만 +- CAP_NET_BIND_SERVICE만 add + +#### vault / vault-secrets-operator +- namespace PSA: `baseline` (Vault server IPC_LOCK 필요) +- storage PVC는 encrypted StorageClass +- unseal key는 cluster 밖(HSM/KMS auto-unseal) + +#### db (Postgres/MySQL) +- namespace PSA: `restricted` (StatefulSet, fsGroup 999) +- NetworkPolicy: application SA의 Pod만 5432 허용 +- backup은 별도 namespace의 Job에서 수행, 해당 Job에만 read-only secret 부여 + +## 프로젝트 기준 요약 + +- 모든 app namespace에 `pod-security.kubernetes.io/enforce: restricted` + pinned version 라벨 부착 +- PSP는 제거되었으므로 어디에도 남기지 않는다 +- Restricted 프로파일 전 필드 계약을 Pod/Deployment가 만족 +- default-deny NetworkPolicy + DNS allow + ingress allow + metrics-scrape allow를 namespace 기본 세트로 배포 +- `namespaceSelector`+`podSelector` AND/OR 차이를 정확히 사용 +- Secret at-rest encryption + VSO(1순위) delivery 표준화 +- 운영 이미지는 digest pin, private registry only +- audit policy + Kyverno/Gatekeeper 보완 정책과 함께 운영 diff --git a/docs/standards/infra/storage-pvc.md b/docs/standards/infra/storage-pvc.md new file mode 100644 index 0000000..c6c81b5 --- /dev/null +++ b/docs/standards/infra/storage-pvc.md @@ -0,0 +1,227 @@ +# storage / PVC 기준 + +## 목적 + +PVC는 단순히 "데이터를 남기기 위한 옵션"이 아니라, +- 어떤 워크로드가 상태를 가지는지 +- 그 상태의 수명과 복구 단위가 무엇인지 +- 어떤 storage class / access mode / reclaim policy / binding mode가 필요한지 +- snapshot / expansion 지원이 필요한지 +를 먼저 고정한 뒤에 사용한다. + +이 문서의 목표는 다음과 같다. + +- 상태 저장 워크로드와 무상태 워크로드를 저장소 기준으로 명확히 구분한다 +- separate PVC 남발을 막는다 +- K3s 기본 local-path provisioner의 운영 사용 범위를 통제한다 +- PVC lifecycle과 backup/restore 단위를 먼저 고정한다 +- StorageClass / VolumeSnapshotClass / reclaim policy / binding mode를 선언적으로 명시한다 + +## 공식 의미 (Kubernetes 기준) + +- PV는 클러스터의 저장소 리소스이며 Pod lifecycle과 독립적이다. +- PVC는 저장소에 대한 요청(size, access mode, StorageClass, volumeMode 등)이다. +- StorageClass는 동적 프로비저닝 파라미터, `reclaimPolicy`, `allowVolumeExpansion`, `volumeBindingMode`, `mountOptions`를 정의한다. +- `reclaimPolicy`는 PV 해제 시 동작을 결정한다. 동적 프로비저닝 PV의 기본값은 `Delete`다. 운영 데이터가 있으면 StorageClass에서 `Retain`을 명시한다. +- `volumeBindingMode`의 기본값은 `Immediate`이며, topology-aware / late-binding이 필요하면 `WaitForFirstConsumer`를 사용한다. +- `hostPath`는 single-node testing 전용이다. 운영 클러스터에서 사용하지 않는다. +- K3s는 Rancher Local Path Provisioner를 기본 제공해 노드 로컬 저장소를 사용할 수 있지만, RWO만 지원하고 snapshot/expansion은 지원하지 않는다. +- VolumeSnapshot / VolumeSnapshotContent / VolumeSnapshotClass는 CSI snapshot을 위한 K8s API다. `deletionPolicy: Retain` / `Delete`를 정책에 맞게 선택한다. +- StatefulSet은 `persistentVolumeClaimRetentionPolicy`로 삭제/스케일다운 시 PVC 보존 여부를 제어할 수 있다. + +## 기본 규칙 + +### 1. PVC는 상태가 있을 때만 사용 +다음 중 하나가 아니면 PVC를 붙이지 않는다. + +- 재시작 후에도 유지되어야 하는 데이터가 있음 +- Pod 교체와 무관하게 보존되어야 하는 파일/데이터가 있음 +- 복구 대상이 되는 저장 상태가 있음 +- 애플리케이션이 명시적으로 영속 저장소를 요구함 + +금지: +- "혹시 몰라서" PVC 추가 +- 로그/캐시/임시 파일을 습관적으로 PVC에 저장 +- stateless 앱에 관성적으로 PVC 부착 + +### 2. PVC 존재만으로 StatefulSet을 결정하지 않는다 +PVC가 있다고 무조건 StatefulSet은 아니다. + +먼저 묻는다. +- Pod마다 고유한 저장소가 필요한가? +- stable network identity가 필요한가? +- 순서 있는 확장/축소가 필요한가? + +아니면: +- Deployment + 단일 PVC(RWO, replicas 1) 또는 Deployment + RWX PVC +도 가능하다. + +### 3. separate PVC는 "데이터 수명과 복구 단위가 다를 때만" +하나의 워크로드가 여러 PVC를 가져도 되는 경우는 아래와 같다. + +- 데이터 종류별 수명주기가 다름 +- backup/restore 단위가 다름 +- 성능 요구(StorageClass) 또는 IOPS 특성이 다름 +- 보안/접근 제어 단위가 다름 +- 장애 시 독립적으로 보존/삭제되어야 함 + +금지: +- 디렉터리 몇 개를 기계적으로 PVC로 분리 +- mount path별로 습관적으로 PVC 추가 +- 이유 없이 "앱 데이터/설정/로그"를 모두 개별 PVC로 분리 + +### 4. 기본 원칙은 "적게, 명확하게" +기본적으로는 하나의 워크로드 / 하나의 상태 저장 목적 / 하나의 PVC를 먼저 검토한다. +분리는 정당한 이유(#3)가 있을 때만 한다. + +### 5. StorageClass는 항상 명시적으로 지정 +PVC는 `storageClassName`을 항상 명시한다. 클러스터 default annotation에 의존하지 않는다. + +기본: +- 운영 표준 StorageClass 3~5개를 미리 정의 (예: `fast-ssd-retain`, `standard-delete`, `archive-retain`, `rwx-shared`) +- 성능/복제/노드 종속성 차이가 있으면 workload별로 구분 +- 각 StorageClass는 `provisioner`, `reclaimPolicy`, `volumeBindingMode`, `allowVolumeExpansion`을 모두 선언 + +### 6. StorageClass `volumeBindingMode` 기본값은 `WaitForFirstConsumer` +운영 표준은 `WaitForFirstConsumer`다. + +이유: +- Pod가 스케줄되는 노드의 topology(zone, node-local disk, GPU affinity 등)에 맞춰 PV를 바인딩한다 +- `Immediate`는 PVC 생성 즉시 PV를 바인딩하므로, 이후 Pod가 해당 노드/zone에 스케줄되지 못하는 상황이 생긴다 +- K3s local-path provisioner는 노드 로컬이므로 반드시 `WaitForFirstConsumer`여야 한다 + +`Immediate` 허용 예외: +- 네트워크 스토리지(Ceph, NFS, S3 CSI 등)이고 topology 제약이 없는 경우 +- 사전에 PV를 warm-up 해야 하는 특수 케이스 + +### 7. StorageClass `reclaimPolicy`는 데이터 등급에 맞춘다 +동적 프로비저닝의 기본 `reclaimPolicy`는 `Delete`다. 이는 PVC 삭제 시 PV와 데이터가 사라진다는 뜻이다. + +기본: +- production stateful data (DB, object store backend, identity store 등) → `Retain` +- dev/test, ephemeral cache, rebuild-safe data → `Delete` +- `Retain`을 쓰면 PVC 삭제 후 남은 PV를 정리하는 책임이 운영자에게 생긴다. runbook에 정리 절차를 명시한다. + +### 8. `allowVolumeExpansion`은 기본 `true`로 두되 축소는 불가 +PVC 확장 요구는 자주 생긴다. StorageClass에서 `allowVolumeExpansion: true`를 기본으로 둔다. + +주의: +- PVC 용량 축소는 K8s가 지원하지 않는다 +- 파일시스템 online expansion 지원 여부는 CSI 드라이버마다 다르다 +- 확장 후 Pod 재시작이 필요한 드라이버가 있다 + +### 9. AccessMode는 실제 요구에 맞게 고른다 +기본: +- 단일 writer면 `ReadWriteOnce` (RWO) +- 동일 노드의 여러 Pod가 공유 필요시 `ReadWriteOncePod` (K8s 1.27+) 또는 RWO +- 여러 Pod/노드 동시 read/write가 진짜 필요할 때만 `ReadWriteMany` (RWX) +- 읽기 전용 공유는 `ReadOnlyMany` (ROX) + +편의상 RWX를 기본값으로 두지 않는다. RWX는 NFS/CephFS 같은 별도 스토리지 백엔드를 요구한다. + +### 10. K3s local-path provisioner는 운영에서 기본값 아님 +K3s 기본 local-path provisioner의 하드 제약: + +- RWO 전용 (RWX 불가) +- VolumeSnapshot 미지원 +- VolumeExpansion 미지원 +- 노드 로컬이므로 Pod가 특정 노드에 pin 됨 → 노드 장애 시 데이터 접근 불가 +- backup은 노드 파일시스템에 직접 접근해야 함 + +기본: +- dev/test: 허용 +- production: Longhorn, OpenEBS, Rook-Ceph, 또는 클라우드 CSI driver(EBS, PD, Azure Disk 등)로 교체 +- 불가피하게 prod에서 local-path를 쓸 경우 `backup-restore.md`와 반드시 연동하고 노드 affinity/zone 분리를 명시 + +### 11. `hostPath` 직접 사용 금지 +운영 PV/PVC에 `hostPath`를 사용하지 않는다. + +예외: +- 학습/단일 노드 로컬 테스트 +- 매우 제한된 디버깅 용도 (CSI driver 진단 등) + +운영 표준으로 채택하지 않는다. + +### 12. VolumeSnapshotClass를 StorageClass와 1:1로 매칭 +snapshot 대상 PVC가 있는 StorageClass는 대응되는 VolumeSnapshotClass를 반드시 정의한다. + +기본: +- `driver`는 StorageClass의 provisioner와 맞춤 +- `deletionPolicy`는 운영 데이터면 `Retain`, ephemeral이면 `Delete` +- snapshot class는 `labels`로 RPO/retention 정책과 연결 + +### 13. PVC lifecycle은 workload 생성 전에 문서화 +PVC를 만들기 전에 아래를 정한다. + +- 누가 생성하는가 (Helm, Kustomize, Operator, manual) +- 누가 삭제하는가 (GitOps sync, 운영자 수동) +- scale down 시 어떻게 되는가 +- workload 삭제 시 어떻게 되는가 +- backup 대상인가 (어떤 RPO/RTO) +- restore 단위인가 (PVC / VolumeSnapshot / backup tool 별) + +"삭제하면 같이 정리되겠지"를 금지한다. + +### 14. StatefulSet의 PVC retention policy를 명시적으로 검토 +StatefulSet을 쓰는 경우 `persistentVolumeClaimRetentionPolicy.whenDeleted` / `whenScaled`를 기본값에 두지 않는다. + +기본: +- 운영 데이터: 둘 다 `Retain` +- ephemeral 데이터: 둘 다 `Delete` +- 혼용시 명시적 이유를 주석에 남김 + +### 15. Pod와 PVC는 같은 namespace 소유권 +PVC는 Pod와 같은 namespace에서 사용된다. 스토리지도 workload의 namespace 소유권을 따라간다. + +금지: +- "공용 저장소 namespace"에 무분별하게 PVC 몰아넣기 +- 여러 서비스가 의미 없이 같은 PVC를 기대하는 구조 + +### 16. 워크로드별 기본 선택 + +| Workload | 기본 PVC | StorageClass | AccessMode | Snapshot | +|---|---|---|---|---| +| auth-server | 없음 | - | - | - | +| test-server | 없음 | - | - | - | +| ingress-controller | 없음 | - | - | - | +| migration-flyway (Job) | 없음 | - | - | - | +| Keycloak (external DB) | 없음 | - | - | - | +| PostgreSQL / CNPG | 필수 | fast-ssd-retain | RWO | 필수 | +| Vault (raft) | 필수 | fast-ssd-retain | RWO | 필수 | +| MinIO | 필수 | standard-retain | RWO | 보조 (replication 우선) | + +### 17. 로그와 임시 파일은 PVC 기본 금지 +다음은 기본적으로 PVC에 저장하지 않는다. + +- application log (→ stdout + 로그 수집기) +- temp file (→ `emptyDir`) +- cache (→ `emptyDir` 또는 memory-backed) +- rendered config copy +- transient upload staging + +정말 영속화가 필요하면 이유를 주석에 명시한다. + +### 18. backup/restore와 반드시 연결 +PVC를 허용한 워크로드는 반드시 아래와 연결한다. + +- `backup-restore.md` (Velero schedule, snapshot class, RPO/RTO) +- `operations-runbook-upgrade-rollback.md` (복구 절차) + +PVC가 생기면 복구 전략도 같이 생겨야 한다. 백업 없는 PVC는 merge 금지. + +### 19. 파일시스템 / 블록 모드 명시 +`volumeMode`는 기본 `Filesystem`이지만, DB raw block 같은 경우 `Block`을 쓸 수 있다. DB 운영이 요구하지 않으면 `Filesystem` 고정. + +### 20. securityContext와 fsGroup +PVC를 쓰는 Pod는 `securityContext.fsGroup` 또는 `fsGroupChangePolicy: OnRootMismatch`를 명시해서 permission 문제를 예방한다. restricted PSA 하에서는 `runAsNonRoot: true`, `runAsUser`, `fsGroup`을 모두 설정한다. + +## 프로젝트 기준 요약 + +- PVC는 상태가 있을 때만, separate PVC는 수명/복구 단위가 다를 때만 +- StorageClass는 항상 명시, `volumeBindingMode: WaitForFirstConsumer` 기본, `reclaimPolicy`는 데이터 등급에 맞춤 +- 동적 프로비저닝 기본 `reclaimPolicy=Delete`를 인지하고 운영 데이터는 `Retain` 명시 +- K3s local-path는 RWO / no snapshot / no expansion — prod 기본값 아님 +- VolumeSnapshotClass를 StorageClass와 매칭해서 정의 +- StatefulSet PVC retention policy 명시 +- 로그/임시 파일은 PVC 기본 금지 +- PVC가 생기면 backup/restore 기준도 같이 만든다 diff --git a/docs/standards/infra/vault.md b/docs/standards/infra/vault.md new file mode 100644 index 0000000..8f72f71 --- /dev/null +++ b/docs/standards/infra/vault.md @@ -0,0 +1,277 @@ +# Vault 기준 + +## 목적 + +이 문서는 Kubernetes 환경에서 HashiCorp Vault 1.17+ 를 1000+ 서비스의 secret / PKI / dynamic credential 소스로 운영하기 위한 기준을 고정한다. + +- Integrated Storage (Raft) HA + auto-unseal을 1차 권장 경로로 둔다 +- 공식 Helm chart (`hashicorp/vault`) values.yaml의 핵심 필드를 명시한다 +- Vault Secrets Operator (VSO) 0.8+ CRD 경로를 secret delivery 기본값으로 둔다 +- Vault Agent Injector는 Kubernetes Secret을 우회하고 싶은 워크로드의 2차 경로로 둔다 +- Raft snapshot / audit device / telemetry / TLS / Kubernetes auth role을 운영 필수 요소로 둔다 + +## 공식 의미 (Vault 1.17+ 기준) + +- Vault는 **sealed** 상태로 기동한다. Shamir 수동 unseal 또는 auto-unseal (`awskms`, `gcpckms`, `azurekeyvault`, `transit`)로 unseal한다. +- **Integrated Storage (Raft)**는 공식 지원 HA backend다. 기동 시 `storage "raft"` stanza, `cluster_addr`, listener의 `cluster_address`가 모두 필요하다. `ha_storage`와 동시 선언 금지. +- Vault는 두 포트를 쓴다: **`8200` (API/client), `8201` (cluster-to-cluster Raft replication)**. Service는 8201을 반드시 expose해야 peer-to-peer Raft가 성립한다. +- `/v1/sys/health` 는 단일 endpoint로 상태 코드로 응답한다: `200` active, `429` standby (`standbyok=true`면 200), `472` DR secondary, `473` performance standby, `501` uninitialized, `503` sealed. +- **Audit device는 최소 하나 활성화해야 한다.** audit device가 전부 실패하면 Vault는 요청 처리를 멈춘다(블로킹). 여러 개 운영 권장. +- Kubernetes auth method는 ServiceAccount JWT를 TokenReview API로 검증한다. Vault 1.17+는 short-lived projected SA token(`audiences`)을 권장한다. +- VSO 0.8+는 `secrets.hashicorp.com/v1beta1` API group을 사용하고 `VaultConnection`, `VaultAuth`, `VaultStaticSecret`, `VaultDynamicSecret`, `VaultPKISecret`, `HCPAuth`, `HCPVaultSecretsApp` CRD를 제공한다. +- Vault Agent Injector는 `vault.hashicorp.com/agent-inject: "true"` 같은 Pod annotation으로 sidecar/init container를 주입해 secret을 `/vault/secrets/<name>` 파일로 렌더링한다. +- DR replication / Performance replication은 **Enterprise 기능**이다. OSS에서는 Raft snapshot restore가 복구 경로다. +- Telemetry는 `telemetry { prometheus_retention_time = "24h" disable_hostname = true }` stanza로 활성화하고 `/v1/sys/metrics?format=prometheus`에서 scrape한다. + +## 기본 규칙 + +### 1. 배포는 공식 Helm chart (`hashicorp/vault`) + +기본: +- `helm repo add hashicorp https://helm.releases.hashicorp.com` +- `server.ha.enabled=true` + `server.ha.raft.enabled=true` +- `injector.enabled` 는 secret delivery 전략에 따라 결정 (VSO만 쓰면 `false`) +- values.yaml은 Git에 보관 + Helmfile / Argo CD Application로 배포 + +기본 금지: +- 수제 StatefulSet으로 처음부터 조립 +- `dev` 모드 운영 +- chart 기본 `standalone` 모드 production 사용 (single node + file storage) + +### 2. HA topology: Raft 3-node 또는 5-node + +기본: +- `server.ha.replicas: 3` (과반수 장애 허용: 1 node) +- critical path면 `5`로 확장 (2 node 장애 허용) +- `server.ha.raft.setNodeId: true` (각 pod의 hostname을 node_id로 자동 주입) +- anti-affinity: hostname 기준 required, zone 기준 preferred + +### 3. Raft config: listener 8200 + cluster 8201 + service_registration + +`server.ha.raft.config` HCL에 최소한 아래 stanza가 필요하다. + +```hcl +ui = true +listener "tcp" { + address = "[::]:8200" + cluster_address = "[::]:8201" + tls_disable = 0 + tls_cert_file = "/vault/tls/tls.crt" + tls_key_file = "/vault/tls/tls.key" +} +storage "raft" { + path = "/vault/data" + node_id = "$(HOSTNAME)" +} +cluster_addr = "https://$(HOSTNAME).vault-internal:8201" +api_addr = "https://$(HOSTNAME).vault-internal:8200" +service_registration "kubernetes" {} +telemetry { + prometheus_retention_time = "24h" + disable_hostname = true +} +``` + +`cluster_addr`는 headless service(`vault-internal`)의 pod FQDN을 쓴다. 8201 Service expose 필수. + +### 4. Auto-unseal 채택 (1차 권장) + +기본: +- AWS: `seal "awskms" { region = "..." kms_key_id = "..." }` +- GCP: `seal "gcpckms" { project = "..." region = "..." key_ring = "..." crypto_key = "..." }` +- Azure: `seal "azurekeyvault" { tenant_id = "..." vault_name = "..." key_name = "..." }` +- Vault-to-Vault: `seal "transit" { address = "..." token = "..." key_name = "autounseal" mount_path = "transit/" }` + +기본 금지: +- Shamir key를 CI/CD 환경변수나 Kubernetes Secret에 저장 +- seal backend에 lifecycle 보호 없음 (KMS key deletion protection 필수) + +### 5. Audit device는 최소 2개 + +Audit device 전부 실패 시 Vault가 요청을 block한다. redundancy 확보. + +기본: +- `auth/kubernetes/login` 경로 포함 모든 API 감사 +- `file`: `server.auditStorage.enabled: true` → `/vault/audit/audit.log` +- `syslog` 또는 `socket`: 중앙 로그 파이프라인 (Loki, Splunk, CloudWatch) +- `vault audit enable file file_path=/vault/audit/audit.log` + +기본 금지: +- audit device 0개 운영 +- audit log PVC 용량 무제한 (log rotation + sink 필수) + +### 6. TLS는 end-to-end + +기본: +- cert-manager Certificate로 `vault-tls` Secret 발급 (cluster issuer) +- listener에 `tls_cert_file`, `tls_key_file`, `tls_min_version = "tls13"` +- client (app, VSO, Injector)는 CA bundle trust +- Vault ↔ Storage ↔ seal backend 전 구간 TLS + +### 7. Vault는 기본 내부 전용 (ClusterIP) + +기본: +- Service type: ClusterIP (8200, 8201) +- Ingress 기본 금지 +- 외부 관리자 접근은 VPN / bastion / port-forward / OIDC-protected admin Ingress + +### 8. Probe: `/v1/sys/health` 상태코드 의미 반영 + +기본: +- readiness: `GET /v1/sys/health?standbyok=true&perfstandbyok=true&uninitcode=204` (uninitialized를 200으로 수용 초기 bootstrap 허용) +- liveness: `GET /v1/sys/health?standbyok=true&sealedcode=204&uninitcode=204` (sealed + uninit이어도 pod 생존) +- startup: initialDelay 10s, failureThreshold 12 (2분 유예) + +기본 금지: +- `GET /` 단순 probe +- sealed 상태에서 liveness 실패 → 무한 재시작 루프 + +### 9. Kubernetes auth method 구성 + +Vault 쪽 (1회 bootstrap): + +```bash +vault auth enable kubernetes +vault write auth/kubernetes/config \ + token_reviewer_jwt="$(cat /var/run/secrets/kubernetes.io/serviceaccount/token)" \ + kubernetes_host="https://kubernetes.default.svc.cluster.local" \ + kubernetes_ca_cert=@/var/run/secrets/kubernetes.io/serviceaccount/ca.crt \ + disable_iss_validation=false +``` + +Role은 ServiceAccount + namespace에 바인딩: + +```bash +vault write auth/kubernetes/role/auth-server \ + bound_service_account_names=auth-server \ + bound_service_account_namespaces=auth-prod \ + policies=auth-server-read \ + ttl=1h \ + audience=vault +``` + +기본 금지: +- `bound_service_account_names=*` 또는 `bound_service_account_namespaces=*` +- TTL 무한 또는 24h 이상 + +### 10. Secret delivery: VSO가 1차 권장 + +기본: +- 클러스터 전체 1개 `VaultConnection` (namespace: `vault`) +- 앱 namespace마다 `VaultAuth` (ServiceAccount 바인딩) +- 정적 KV 동기화: `VaultStaticSecret` +- 동적 DB credential: `VaultDynamicSecret` +- TLS 인증서: `VaultPKISecret` +- `destination.create: true`로 K8s Secret 자동 생성, `rolloutRestartTargets`로 consumer 재시작 + +### 11. Vault Agent Injector: Kubernetes Secret 우회가 필요할 때 + +기본 annotation set: + +```yaml +vault.hashicorp.com/agent-inject: "true" +vault.hashicorp.com/role: "auth-server" +vault.hashicorp.com/agent-inject-secret-db-creds: "database/creds/auth-server" +vault.hashicorp.com/agent-inject-template-db-creds: | + {{ with secret "database/creds/auth-server" -}} + DATABASE_USERNAME={{ .Data.username }} + DATABASE_PASSWORD={{ .Data.password }} + {{- end }} +vault.hashicorp.com/agent-pre-populate-only: "true" # init-only (앱이 파일 1회 읽음) +vault.hashicorp.com/agent-inject-file-db-creds: "db.env" +``` + +기본: +- etcd에 민감정보를 남기고 싶지 않을 때 선택 +- 앱이 파일 기반 secret 소비 가능해야 함 +- 장기 실행 sidecar 대신 `agent-pre-populate-only: "true"`로 init container만 사용해 resource overhead 감소 + +### 12. Raft snapshot 백업은 운영 필수 + +기본: +- 하루 1회 `vault operator raft snapshot save` CronJob +- snapshot을 off-cluster object storage (S3, GCS, MinIO replicated bucket)에 저장 +- retention 30일 이상 + 주간 / 월간 snapshot 분리 +- restore 절차를 runbook으로 문서화 + +### 13. Telemetry + Prometheus scrape + +기본: +- config: `telemetry { prometheus_retention_time = "24h" disable_hostname = true }` +- 내부 Prometheus token policy: + ``` + path "sys/metrics" { capabilities = ["read"] } + ``` +- Prometheus scrape: `/v1/sys/metrics?format=prometheus` + Bearer token (unauth-endpoint 가능하지만 권장하지 않음) + +### 14. 포트 expose: 8200 + 8201 둘 다 + +기본: +- Pod containerPort: 8200 (api), 8201 (cluster) +- Service `vault`: ClusterIP, 8200 +- Service `vault-internal`: Headless, 8200 + **8201** (Raft peer discovery 필수) +- 8201 누락 시 Raft peer-to-peer 실패, leader election 불가 + +### 15. Replication 경계: OSS vs Enterprise + +DR replication, performance replication, namespace multi-tenancy는 **Vault Enterprise** 전용이다. + +OSS 기준 복구: +- Raft snapshot restore로 state 복원 +- 동일 seal backend 요구 (auto-unseal이면 KMS key 필요) + +기본 금지: +- OSS에서 DR topology를 가정한 설계 +- Enterprise 기능을 OSS manifest에 넣기 + +### 16. Security context: Restricted PSS + +기본: +- `runAsNonRoot: true`, `runAsUser: 100` (vault user) +- `readOnlyRootFilesystem: true` +- `allowPrivilegeEscalation: false`, `capabilities.drop: [ALL]`, `capabilities.add: [IPC_LOCK]` (mlockall을 위함, swap 방지) +- `seccompProfile: RuntimeDefault` + +### 17. Resource 요청 + +기본 단일 replica (Raft 3 node 클러스터 중 하나): +- requests: `cpu: 250m`, `memory: 256Mi` +- limits: `cpu: 1`, `memory: 512Mi` + +대규모 PKI / dynamic secret 발급량이 많으면 `memory: 1Gi` 이상. + +### 18. Token / root token 취급 + +기본: +- `vault operator init` 출력 root token은 1회성 bootstrap +- 초기 설정 완료 후 `vault token revoke <root-token>` +- 장기 root 필요 시 `vault operator generate-root` 절차로 ephemeral 생성 +- app token은 Kubernetes auth login 경로로만 발급 +- CLI history에 unseal key, root token 남기지 않음 (`HISTCONTROL=ignorespace`) + +### 19. 현재 스택 기본 권장안 + +- 배포: Helm chart `hashicorp/vault`, `server.ha.enabled=true` + `server.ha.raft.enabled=true` +- Replicas: 3 +- Storage: Integrated Storage (Raft) + dataStorage PVC + auditStorage PVC +- Unseal: auto-unseal (awskms / gcpckms / azurekeyvault / transit) +- TLS: end-to-end, cert-manager Certificate +- Service: ClusterIP 8200 + Headless 8200/8201 +- Ingress: 기본 금지 (관리자 경로만 OIDC-protected 예외) +- Probe: `/v1/sys/health` status-code aware +- Audit: file + syslog 중복 +- Secret delivery: VSO 1차, Injector 2차 +- Backup: daily Raft snapshot → off-cluster object storage + +## 프로젝트 기준 요약 + +- Helm chart 공식 배포, HA Raft 3-node, auto-unseal +- 8200 (client) + 8201 (cluster) Service expose 필수 +- `/v1/sys/health` status-code 기반 probe +- audit device 최소 2개, 전체 실패 시 block 특성 인지 +- Kubernetes auth role은 SA + namespace 단위, wildcard 금지 +- VSO 1차 / Injector 2차 (`agent-pre-populate-only` init-only 선호) +- Raft snapshot daily CronJob → off-cluster 보관 +- Telemetry `/v1/sys/metrics?format=prometheus` + Prometheus token policy +- DR/perf replication은 Enterprise 기능, OSS 경계 분명 +- Restricted PSS + IPC_LOCK capability (mlockall) diff --git a/docs/standards/infra/workload-selection.md b/docs/standards/infra/workload-selection.md new file mode 100644 index 0000000..922826e --- /dev/null +++ b/docs/standards/infra/workload-selection.md @@ -0,0 +1,324 @@ +# workload selection 기준 + +## 목적 + +이 문서는 각 컴포넌트를 +- Deployment +- StatefulSet +- DaemonSet +- Job +- CronJob + +중 무엇으로 배포할지 먼저 고정한다. + +목표: + +- 상태 저장 / 무상태 / 노드로컬 / 일회성 / 주기성 워크로드를 섞지 않는다 +- PVC가 필요하다는 이유만으로 StatefulSet을 선택하는 실수를 막는다 +- Ingress controller / CNI / CSI / 로그 shipper / node-exporter 같은 노드로컬 에이전트를 Deployment로 배포하는 실수를 막는다 +- migration / bootstrap / 백업을 장기 실행 앱과 분리한다 +- 1000+ 서비스 스케일에서 operator-managed 패턴이 기본인 영역(DB, Kafka, monitoring)은 operator를 기본 선택으로 문서화한다 + +## 공식 의미 (근거) + +- **Deployment**: stateless 장기 실행. `spec.replicas` 기반 수평 확장. ReplicaSet으로 rolling update. `https://kubernetes.io/docs/concepts/workloads/controllers/deployment/`. +- **StatefulSet**: stable network identity, stable persistent storage, ordered deployment/scaling. 각 Pod는 `<name>-<ordinal>` 이름을 가지고 PVC가 `volumeClaimTemplates`로 자동 생성. `persistentVolumeClaimRetentionPolicy` (GA since 1.27) 필드: 기본값 `{whenDeleted: Retain, whenScaled: Retain}`. `podManagementPolicy` (OrderedReady / Parallel). `updateStrategy` (RollingUpdate / OnDelete). +- **DaemonSet**: 선택된 모든 노드에 정확히 한 Pod를 실행. 노드 추가/제거에 따라 자동 생성/삭제. `https://kubernetes.io/docs/concepts/workloads/controllers/daemonset/`. +- **Job**: 한 번 실행되어 완료. `restartPolicy: Never | OnFailure`. `backoffLimit` / `activeDeadlineSeconds` / `ttlSecondsAfterFinished` / `parallelism` / `completions`. +- **CronJob**: 시간 기반 스케줄 Job. `concurrencyPolicy: Allow | Forbid | Replace`, `startingDeadlineSeconds`, `successfulJobsHistoryLimit` / `failedJobsHistoryLimit`. +- **Operator pattern**: CRD + controller. 1000-서비스 스케일에서 DB/Kafka/Redis/monitoring은 사실상 Deployment/StatefulSet을 직접 쓰지 않고 operator가 소유. + +## 기본 규칙 + +### 1. 기본 선택 기준은 "상태 + 수명 + 배치 위치" + +3축으로 먼저 분류: + +1. **수명**: 장기 실행 / 일회성 / 주기성 +2. **상태**: stable identity + persistent storage 필요 / 불필요 +3. **배치**: 노드별 한 Pod 필요 / cluster-wide 자유 배치 + +매핑: + +- 장기 + 무상태 + 자유 배치 → **Deployment** +- 장기 + stateful + 자유 배치 → **StatefulSet** (또는 operator) +- 장기 + 무상태 + 노드별 한 Pod → **DaemonSet** +- 일회성 → **Job** +- 주기성 → **CronJob** + +### 2. Deployment는 stateless 장기 실행 기본값 + +조건: + +- Pod identity가 교체 가능 +- durable state가 외부 DB / 외부 storage / 외부 cache에 있음 +- 수평 확장이 자연스러움 +- Pod 이름 / 순서가 의미 없음 + +적용 후보: + +- `auth-server` +- `test-server` (장기 실행 모드) +- 외부 DB 사용하는 `keycloak` +- 대부분의 stateless API / worker + +**필수 동반 리소스 (replicas≥2인 prod 워크로드):** + +- `PodDisruptionBudget` (minAvailable ≥ 50% 또는 SLO tier에 맞춘 값) +- `HorizontalPodAutoscaler` v2 (behavior 포함) +- `topologySpreadConstraints` (zone + hostname) +- `ServiceMonitor` 또는 Prometheus scrape annotation + +### 3. StatefulSet은 "stable identity + storage" 모두 맞을 때만 + +조건 중 하나라도 강하면: + +- stable network identity (DNS name per replica) 필요 +- stable persistent storage per Pod 필요 +- ordered rollout / termination 필요 +- replica 간 peer discovery가 ordinal에 의존 + +적용 후보: + +- `postgres` (operator 없을 때 — 있으면 CloudNativePG 같은 operator 우선) +- `minio` (MinIO Operator 가능) +- `vault` (Raft storage mode) +- `etcd` (외부) +- `kafka`, `zookeeper` (Strimzi operator 우선) +- `elasticsearch` (ECK operator 우선) + +**필수 선언**: + +- `serviceName` (headless Service 참조) +- `volumeClaimTemplates` +- `podManagementPolicy: OrderedReady` 기본. Parallel은 peer discovery가 순서를 요구하지 않을 때만. +- `updateStrategy: RollingUpdate` + `partition`으로 canary rollout +- `persistentVolumeClaimRetentionPolicy` 명시 (prod 기본 `{whenDeleted: Retain, whenScaled: Retain}`) + +### 4. DaemonSet은 노드 전역 에이전트 전용 + +조건: + +- 노드별 한 개만 떠야 함 (또는 특정 노드 group에 한 개) +- 노드 추가/제거에 자동 반응 +- hostPath / hostNetwork / hostPID 필요한 경우 다수 + +적용 후보: + +- 로그 shipper: `fluent-bit`, `fluentd`, `vector` +- metric exporter: `node-exporter`, `cadvisor` +- CNI agent: `calico-node`, `cilium-agent` +- CSI node plugin: `longhorn-manager`, `ceph-csi-node` +- security agent: `falco`, `tetragon` +- service mesh node proxy: `istio-cni-node` +- ingress-nginx **DaemonSet 모드** (edge 노드가 고정되고 모든 edge 노드가 80/443 HostPort / hostNetwork로 외부 노출해야 할 때) + +**Ingress controller: DaemonSet vs Deployment 결정**: + +- **Deployment** + `Service type=LoadBalancer` (MetalLB / 외부 LB): 기본 권장. 노드와 ingress replica 수가 분리됨. HPA 적용 가능. +- **DaemonSet** + `hostNetwork: true` / HostPort 80,443: bare-metal + 외부 LB 없이 모든 노드가 ingress가 되어야 할 때. 80/443 노드 포트 점유, HPA 불가, 노드 수 = replica 수. + +**필수**: + +- `tolerations`로 노드 taint (예: `node-role.kubernetes.io/control-plane`) 대응 결정 +- `nodeSelector` 또는 `affinity`로 대상 노드 그룹 명시 (label 기반) +- `updateStrategy: RollingUpdate` + `maxUnavailable` 지정 +- `priorityClassName: system-node-critical` (필수 인프라 에이전트) + +### 5. PVC가 있다고 무조건 StatefulSet 아니다 + +체크리스트: + +- Pod마다 고유한 storage identity 필요? 아니면 단일 PVC 공유? +- Pod 이름 / 순서가 의미 있나? +- peer discovery가 stable DNS name에 의존? + +아니면: + +- **단일 replica Deployment + PVC (ReadWriteOnce)** 도 유효 +- **Deployment + ReadWriteMany PVC** (여러 replica가 동일 storage 공유) 도 유효 (shared cache 등) + +### 6. Job은 migration / bootstrap / one-off 기본값 + +적용 후보: + +- `flyway-migrate` / `liquibase-migrate` +- schema validation +- 초기 admin 사용자 bootstrap +- 데이터 리페어 / 정리 +- 이미지 빌드 trigger + +**필수**: + +- `restartPolicy: Never` (실패 원인 디버깅 가능) 또는 `OnFailure` (transient 실패 재시도) +- `backoffLimit` 명시 (기본값 6은 prod에서 너무 관대할 수 있음) +- `activeDeadlineSeconds` (무한 실행 방지) +- `ttlSecondsAfterFinished` (완료 Job 자동 정리, 1000-서비스 스케일 필수) +- ServiceAccount 최소 권한 + +### 7. CronJob은 주기 실행 전용 + +적용 후보: + +- etcd / DB 백업 +- 정기 정리 (old PVC, old Snapshot, old Job) +- 정기 검증 / 리포트 +- 비즈니스 배치 (야간 집계) + +**필수**: + +- `concurrencyPolicy: Forbid` 기본 (동시 실행 방지). 멱등하면 `Allow`. +- `startingDeadlineSeconds` (노드 장애로 miss 된 job 무한 누적 방지) +- `successfulJobsHistoryLimit: 3` / `failedJobsHistoryLimit: 5` +- schedule timezone 명시 (`spec.timeZone` v1.25+) + +금지: + +- 항상 떠 있어야 하는 서버를 CronJob으로 배포 +- 본 서비스 온라인 처리를 CronJob에 의존 + +### 8. Stateful workload는 retention / scale-down 정책을 먼저 박는다 + +StatefulSet의 `persistentVolumeClaimRetentionPolicy`: + +- `whenDeleted` (StatefulSet이 삭제될 때 PVC 처리): `Retain` (기본) / `Delete` +- `whenScaled` (replica 축소될 때 PVC 처리): `Retain` (기본) / `Delete` + +**prod 기본**: `{whenDeleted: Retain, whenScaled: Retain}` (기본값). DB/Vault/MinIO 모두 여기서 이탈하지 않는다. +**dev/staging**: `{whenDeleted: Delete, whenScaled: Delete}` 허용 (클러스터 재생성 시 자동 정리). + +### 9. 외부 DB를 쓰는 앱 서버는 stateless 우선 + +Pod에 durable state가 없으면 Deployment. Pod identity가 고정되어야 한다는 이유만으로 StatefulSet 선택 금지. + +기준: + +- `auth-server` → Deployment +- 외부 DB 사용 `keycloak` → Deployment (caching은 external Redis / Infinispan cluster) +- 외부 object store 사용 앱 → Deployment + +### 10. Keycloak은 앱 레이어와 저장소를 분리 + +Keycloak 서버 자체는 stateless로 다룬다. + +- **외부 DB (PostgreSQL)** 사용이 prod 기본 +- session / cache는 Infinispan embedded 또는 remote 모드 결정 (remote 선호, replica 간 peer discovery는 Kubernetes DNS) +- **Deployment** + externalTrafficPolicy 고려 +- HA replica ≥ 2 + PDB + +### 11. Vault는 모드별로 다르다 + +- **dev mode**: 학습 전용. prod 절대 금지. +- **standalone (file storage)**: StatefulSet + PVC. replica=1. 단일 장애점. +- **HA Raft**: StatefulSet (integrated storage). replica 3 또는 5. `podManagementPolicy: Parallel` 허용. +- **HA Consul backend**: StatefulSet (Vault) + StatefulSet (Consul). operator 권장. +- **external Vault**: 클러스터 내부 서버 없음, ExternalSecrets로 참조만. + +prod 권장: **HA Raft mode StatefulSet (replica 3)** 또는 **external Vault**. + +### 12. MinIO는 Operator 기본 (StatefulSet은 fallback) + +1000-서비스 스케일에서 MinIO는 MinIO Operator + `Tenant` CRD가 기본. tenant가 StatefulSet을 내부적으로 생성. +raw StatefulSet은 단일 node/dev 환경에서만 예외 허용. + +### 13. Flyway는 Job 기본값 + +- 앱 startup 내부 migration 금지 (앱 부팅 실패와 migration 실패가 섞임) +- Flyway Job이 선행되고 Success 후에 Deployment rollout +- ArgoCD PostSync hook 또는 Argo Workflows로 순서 제어 +- `migrate`, `validate`, `info`, `repair` 각각 독립 Job + +### 14. Ingress controller 배치 결정 + +prod 권장: + +- **ingress-nginx Deployment** + `Service type=LoadBalancer` (MetalLB L2 또는 BGP, 또는 외부 LB) +- 또는 **Envoy Gateway / Gateway API 기반 Deployment** +- HPA 가능, PDB 필수 (tier-1 로 취급) +- 복수 IngressClass (`nginx-public`, `nginx-internal`) 분리 + +DaemonSet 선택 조건: + +- edge 노드가 고정되어 있고 hostNetwork 80/443이 필요 +- 외부 LB가 없고 DNS round-robin으로 다수 노드 IP 노출 + +### 15. DB는 operator 기본, StatefulSet은 fallback + +1000-서비스 스케일의 PostgreSQL: + +- **CloudNativePG Operator** 기본 → `Cluster` CRD. operator가 StatefulSet/Service/Secret/ConfigMap/Backup 전부 관리. +- **Zalando Postgres Operator**도 대안 +- raw StatefulSet은 dev / 특수 케이스에만 + +MySQL/MariaDB: + +- **MariaDB Operator** / **mysql-operator** + +Redis: + +- **Spotahome redis-operator** / **Redis Enterprise Operator** +- cluster 모드면 StatefulSet, sentinel 모드면 Deployment(sentinel) + StatefulSet(redis) + +### 16. Batch / 대량 병렬은 Job + `parallelism` + IndexedJob + +단일 Job으로 수천 개 task 병렬 실행: + +- `completionMode: Indexed` + `parallelism: N` +- 각 Pod가 `JOB_COMPLETION_INDEX` env로 자기 작업 식별 +- 더 복잡한 DAG는 Argo Workflows / Tekton + +### 17. workload 종류만 맞는다고 품질이 보장되지 않는다 + +최종 결정 전 동반 표준 확인: + +- `storage-pvc.md` (StorageClass / volumeClaimTemplate / snapshot) +- `network-ingress-tls.md` (ingressClassName / TLS) +- `resources-probes-availability.md` (PDB / HPA / probe / resources) +- `backup-restore.md` (RPO / RTO / 절차) +- `security-podsecurity.md` (PSA / seccomp / capabilities) +- `observability.md` (ServiceMonitor / log shipping) + +### 18. priority class / preemption 전략 + +- 플랫폼 에이전트 (CNI, CSI, log shipper, node-exporter): `system-node-critical` +- 클러스터 컨트롤러 (cert-manager, external-secrets, operator): `system-cluster-critical` +- 비즈니스 tier-1: 커스텀 `tier-1-critical` (value 1000000) +- 비즈니스 tier-2: `tier-2` (value 100000) +- 비즈니스 tier-3 / batch: `tier-3` (value 10000) + +## 현재 스택 기본 권장안 (prod) + +| 컴포넌트 | workload 종류 | operator | 비고 | +|----------------------|--------------------------------|-------------------|--------------------------------| +| `auth-server` | Deployment | — | tier-1 HPA+PDB | +| `test-server` (장기) | Deployment | — | | +| `test-server` (검증) | Job | — | ttlSecondsAfterFinished | +| `keycloak` | Deployment (외부 DB) | — | HA replicas≥2 | +| `postgres-identity` | StatefulSet (via CNPG) | CloudNativePG | replica 3 | +| `vault` | StatefulSet (Raft) | Vault Operator | replica 3 | +| `minio` | StatefulSet (via Tenant) | MinIO Operator | | +| `kafka` | StatefulSet (via Strimzi) | Strimzi | | +| `redis` | StatefulSet (via operator) | redis-operator | sentinel 또는 cluster 모드 | +| `flyway-migrate` | Job | — | ArgoCD PostSync hook | +| `postgres-backup` | CronJob | CNPG ScheduledBkp | operator가 소유 | +| `ingress-nginx` | Deployment + MetalLB | — | public / internal 분리 | +| `cert-manager` | Deployment | — | system-cluster-critical | +| `external-secrets` | Deployment | — | system-cluster-critical | +| `prometheus` | StatefulSet (via Prometheus) | Prometheus Op | | +| `fluent-bit` | DaemonSet | — | system-node-critical | +| `node-exporter` | DaemonSet | — | system-node-critical | +| `cilium-agent` | DaemonSet | Cilium Op (opt) | system-node-critical | +| `longhorn-manager` | DaemonSet | Longhorn | | + +## 프로젝트 기준 요약 + +- stateless 장기 → Deployment (+PDB+HPA+topologySpread 필수) +- stateful + stable identity/storage → StatefulSet (또는 operator) +- 노드 전역 에이전트 → DaemonSet +- 일회성 → Job (ttlSecondsAfterFinished 필수) +- 주기성 → CronJob (concurrencyPolicy + startingDeadlineSeconds) +- PVC ≠ StatefulSet 신호 전부 아님 +- DB/Kafka/Redis/Prometheus는 operator 기본 +- `persistentVolumeClaimRetentionPolicy` 명시, prod는 Retain/Retain +- Flyway는 Job, 앱 startup 내부 migration 금지 +- ingress controller는 Deployment+LB 기본, DaemonSet은 edge 조건에만 diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md new file mode 100644 index 0000000..d0ca5c2 --- /dev/null +++ b/docs/troubleshooting.md @@ -0,0 +1,1168 @@ +# 운영 중 만난 함정 9건 — 사건 카탈로그 + +K3s 기반 로컬 클러스터에서 Project-Infra 를 부트스트랩 / 운영하면서 실제로 만났던 사건들의 narrative 정리. +운영자 절차서 톤은 [`guide.md`](../guide.md) 의 15장 (`15.1` ~ `15.11`) 에 있고, 이 문서는 사건 단위로 "무엇을 보고 / 왜 그랬고 / 어떻게 풀었는지" 를 짧게 쓰기 위한 자료다. + +| # | 사건 | guide.md cross-ref | +|---|---|---| +| 1 | Registry image pull 실패 (`ImagePullBackOff`) | (별건 — guide.md 15.1 은 ContainerCreating 사건) | +| 2 | `vault-0` 가 `0/1 Running` 에서 멈춤 | [15.10](../guide.md#1510-vault-0-이-01-running-에서-멈춤) | +| 3 | `helm upgrade` 가 `has no deployed releases` 로 실패 | [15.11](../guide.md#1511-helm-upgrade-가-has-no-deployed-releases-로-실패) | +| 4 | VSO 가 기존 K8s Secret 을 덮어쓰지 않음 | [15.8](../guide.md#158-기존-k8s-secret-이-남아있을-때) | +| 5 | ForwardAuth 로그인 E2E 검증 실패 | (현장 검증 사건) | +| 6 | namespace 가 `Terminating` 에 걸림 | [15.9](../guide.md#159-namespace-가-terminating-에-걸림) | +| 7 | PodSecurity 위반 경고 (admission) | [15.7](../guide.md#157-podsecurity-위반-경고) | +| 8 | VSO 가 Vault 로그인 실패 | [15.2](../guide.md#152-vso-가-vault-에-로그인-실패) | +| 9 | Registry 는 살아났지만 auth-server 새 이미지 pull 이 끝나지 않음 | (해결됨 — registries.yaml 제거 + hosts.toml 직접 작성) | + +--- + +## 1. Registry image pull 실패 (`ImagePullBackOff`) + +### 한 줄 요약 + +사설 registry 도메인(`registry.project.com`) 이 클러스터 노드의 호스트 OS DNS 에 등록되지 않아, 노드의 containerd 가 image pull 단계에서 도메인을 해석하지 못하고 모든 Pod 이 `ImagePullBackOff` 로 멈췄다. + +### 배경 + +- 클러스터 안에 사설 OCI registry (MinIO + 도메인 `registry.project.com`) 를 띄우고, 다른 앱이 그 registry 의 이미지를 pull 하도록 구성. +- 도메인은 K8s service DNS 에는 보이지만 클러스터 외부 DNS / 호스트 OS DNS 에는 없음. + +### 증상 + +``` +Failed to pull image "registry.project.com/...": rpc error: code = Unknown +desc = failed to resolve reference: failed to do request: ... no such host +``` + +`kubectl describe pod` 의 Events 에 `ErrImagePull` → `ImagePullBackOff`. Pod 자체는 스케줄링 됐지만 컨테이너가 시작되지 못함. + +### Root Cause + +K8s 의 service DNS (CoreDNS) 와 **노드의 image pull 경로는 분리되어 있다**. + +- Pod 이 런타임에 `registry.project.com` 으로 HTTP 호출 → CoreDNS 가 해석 (✅ 동작함) +- 노드의 **containerd 가 image pull** → 호스트 OS 의 `/etc/resolv.conf` 만 본다 (CoreDNS 안 봄) + +따라서 호스트 OS 에서 `registry.project.com` 을 해석하지 못하면, 클러스터 안에 service / endpoint 가 정상이어도 image pull 은 실패한다. + +### 해결 + +K3s 가 사용하는 containerd 에 mirror 또는 host 매핑을 직접 알려준다. + +**A. registries.yaml mirror (권장)** + +```yaml +# /etc/rancher/k3s/registries.yaml (각 노드) +mirrors: + registry.project.com: + endpoint: + - "https://<클러스터 내부 ingress 주소>" +configs: + registry.project.com: + tls: + insecure_skip_verify: true # 사설 인증서일 경우 +``` + +설정 후 `systemctl restart k3s`. + +**B. 임시 우회 — `/etc/hosts`** + +``` +<ingress IP> registry.project.com +``` + +### 검증 + +노드에서 직접: + +```bash +sudo crictl pull registry.project.com/<image>:<tag> +``` + +성공하면 Pod 의 `ImagePullBackOff` 도 자동으로 회복된다 (`kubelet` 의 backoff retry). + +### 교훈 + +- **K8s service DNS 가 보인다고 image pull 도 된다고 가정하지 말 것.** image pull 은 노드의 컨테이너 런타임이 직접 수행하고, 호스트 OS 의 resolver 를 따른다. +- 사설 registry 를 클러스터 안에 두면 **bootstrap 순서 의존성** 이 생긴다 (registry 가 떠야 다른 이미지 pull 가능). 이 의존성은 mirror config / hosts 매핑으로만 풀린다. + +--- + +## 9. Registry 는 살아났지만 auth-server 새 이미지 pull 이 끝나지 않음 + +### 한 줄 요약 + +`docker-registry` 자체는 MinIO S3 backend 설정 오류를 고쳐 정상화했고 `auth-server` 새 이미지는 registry 에 업로드했다. 하지만 K3s 노드의 containerd pull 경로는 아직 완전히 검증되지 않아, 새 `auth-server` Pod 는 `ImagePullBackOff` 상태로 남아 있다. + +### 배경 + +- `auth-server` 에 Keycloak 사용자 정보를 token claim 에서 받아 간단히 저장하는 변경을 적용했다. +- 로컬 빌드 태그는 `manual-20260512071751` 이고, 배포 대상 이미지는 `registry.project.com/auth-platform/auth-server:manual-20260512071751` 이다. +- 현재 dev 클러스터 namespace 는 `mnt` 이며, `auth-server` Deployment 는 기존 `0.1.0` 이미지 Pod 1개가 계속 Running 중이다. +- registry 는 `docker-registry` Deployment + MinIO bucket `docker-registry` 조합으로 동작한다. + +### 증상 1: registry Pod 가 readiness/liveness 에서 무너짐 + +`docker-registry` Pod 가 `/v2/` probe 에서 일시적으로 `200` 을 반환하다가 `503` 으로 떨어지고 `CrashLoopBackOff` 로 진입했다. + +관찰된 설정: + +```yaml +REGISTRY_STORAGE: "s3" +REGISTRY_STORAGE_S3_REGIONENDPOINT: "https://minio.mnt.svc.cluster.local" +``` + +하지만 dev MinIO Service 는 HTTP 로 노출되어 있었다. + +```text +service/minio +port: 80 +targetPort: 9000 +``` + +### Root Cause 1 + +registry 의 S3 endpoint 가 `https://...` 로 설정되어 있었지만, 실제 MinIO Service 경로는 HTTP 였다. registry 가 storage health check 와 blob 접근에서 MinIO 에 정상 접근하지 못해 `/v2/` probe 가 실패했다. + +또한 endpoint 를 `http://minio` 로 바꾸면 registry egress NetworkPolicy 도 HTTP port `80` 을 허용해야 한다. 기존 policy 는 `443`, `9000` 만 열려 있었다. + +### 해결 1 + +Git/Kustomize 원천 파일을 수정했다. + +변경 파일: + +- `k8s/base/plugins/docker-registry/configmap.yaml` +- `k8s/overlays/dev/registry/networkpolicy.yaml` + +변경 내용: + +```yaml +REGISTRY_STORAGE_S3_REGIONENDPOINT: "http://minio" +REGISTRY_STORAGE_REDIRECT_DISABLE: "true" +``` + +```yaml +ports: + - protocol: TCP + port: 80 + - protocol: TCP + port: 443 + - protocol: TCP + port: 9000 +``` + +적용: + +```bash +kubectl diff -k k8s/overlays/dev/registry +kubectl apply -k k8s/overlays/dev/registry +kubectl -n mnt rollout restart deployment/docker-registry +kubectl -n mnt rollout status deployment/docker-registry --timeout=180s +``` + +검증 결과: + +```text +deployment.apps/docker-registry 1/1 Available +GET /v2/ HTTP/1.1 200 +``` + +### 증상 2: Docker push 가 포트포워딩에서 반복 실패 + +`kubectl -n mnt port-forward svc/docker-registry 5000:5000` 후 `docker push localhost:5000/...` 를 시도했지만, Docker 의 동시 layer upload 와 `kubectl port-forward` 의 SPDY stream 이 맞물려 connection reset / timeout 이 반복됐다. + +대표 오류: + +```text +write: connection reset by peer +error creating error stream for port 5000 -> 5000: Timeout occurred +``` + +BuildKit builder 에 HTTP/insecure registry 설정을 넣어도 image exporter 가 `https://localhost:5000` 또는 `https://127.0.0.1:5000` 로 HEAD 요청을 시도해 실패했다. + +### Root Cause 2 + +문제가 두 겹이었다. + +1. registry 의 기본 S3 redirect 가 켜져 있으면 client 가 `http://minio/...` presigned URL 로 직접 접근하려고 한다. 클러스터 밖 client 는 `minio` DNS 를 해석할 수 없다. +2. Docker/BuildKit push 는 여러 blob stream 을 동시에 열고, 이 환경의 `kubectl port-forward` 가 긴 업로드 stream 을 안정적으로 유지하지 못했다. + +### 해결 2 + +registry 에 `REGISTRY_STORAGE_REDIRECT_DISABLE: "true"` 를 추가해 client 가 MinIO 로 직접 redirect 되지 않게 했다. + +그 다음 Docker daemon 설정을 바꾸지 않기 위해, 이미지를 OCI tar 로 내보낸 뒤 Registry HTTP API 로 blob 과 manifest 를 순차 업로드했다. + +진행 요약: + +```bash +docker buildx build \ + --platform linux/amd64 \ + --provenance=false \ + --sbom=false \ + -f deploy/docker/application/Dockerfile \ + --output type=oci,dest=/tmp/auth-server-manual-20260512071751.oci.tar \ + . + +# Registry HTTP API 로 auth-platform/auth-server:manual-20260512071751 업로드 +``` + +검증: + +```text +GET http://registry.project.com/v2/auth-platform/auth-server/tags/list +{"name":"auth-platform/auth-server","tags":["manual-20260512071751"]} + +GET http://registry.project.com/v2/auth-platform/auth-server/manifests/manual-20260512071751 +200 application/vnd.oci.image.manifest.v1+json +``` + +### 증상 3: 새 auth-server Pod 가 TLS 오류로 image pull 실패 + +Deployment image 를 새 태그로 변경했다. + +```bash +kubectl -n mnt set image deployment/auth-server \ + auth-server=registry.project.com/auth-platform/auth-server:manual-20260512071751 +``` + +처음에는 kubelet 이 아래 오류를 냈다. + +```text +failed to do request: +Head "https://registry.project.com/v2/auth-platform/auth-server/manifests/manual-20260512071751": +tls: failed to verify certificate: +x509: certificate is valid for ...traefik.default, not registry.project.com +``` + +### Root Cause 3 + +registry Ingress 에 TLS 가 붙어 있어서가 아니다. 현재 registry Ingress 는 HTTP `80` 으로 노출되어 있고 TLS secret 을 명시하지 않는다. + +문제는 containerd 의 기본 image pull 동작이다. `image: registry.project.com/...` 는 scheme 을 쓸 수 없고, containerd 는 기본적으로 `https://registry.project.com/v2/...` 를 먼저 호출한다. Traefik 의 `443` default/self-signed certificate 를 밟으면서 hostname mismatch 가 발생했다. + +### 해결 3 + +각 K3s 노드의 `/etc/rancher/k3s/registries.yaml` 에 dev registry 를 HTTP/insecure registry 로 등록했다. + +```yaml +mirrors: + registry.project.com: + endpoint: + - "http://registry.project.com" +configs: + registry.project.com: + tls: + insecure_skip_verify: true +``` + +적용 대상: + +- `dev-wk-1`: `systemctl restart k3s-agent` +- `dev-wk-2`: `systemctl restart k3s-agent` +- `dev-cp-1`: `systemctl restart k3s` + +검증: + +```bash +kubectl wait node/dev-wk-1 --for=condition=Ready --timeout=120s +kubectl wait node/dev-wk-2 --for=condition=Ready --timeout=120s +kubectl wait node/dev-cp-1 --for=condition=Ready --timeout=180s +``` + +이후 Pod event 에서 TLS certificate mismatch 오류는 사라졌다. + +### 증상 4: registries.yaml 을 HTTP 로 바꿨는데도 여전히 `not found` + +해결 3 으로 TLS cert mismatch 는 사라졌지만, 새 Pod 는 여전히 `ImagePullBackOff` 였다. 메시지가 바뀌었다. + +```text +Failed to pull image "registry.project.com/auth-platform/auth-server:manual-20260512071751": +rpc error: code = NotFound desc = +failed to resolve reference "...": +registry.project.com/auth-platform/auth-server:manual-20260512071751: not found +``` + +진단 절차에서 확인된 사실: + +- registry 에 자격증명 + OCI Accept 헤더로 curl 하면 manifest 200 OK. +- `imagePullSecrets` 는 `auth-server-sa` 와 Pod spec 양쪽에 정상 박힘. +- `crictl pull --creds testuser:...` 도 동일하게 `not found`. +- **`ctr images pull --plain-http --user testuser:...` 만 정상 동작.** +- registry access log 에 ImagePullBackOff Pod 시도 시 manifest 호출 자체가 안 도착. + +→ containerd 가 registry 까지 HTTP 호출을 시도조차 하지 않는 상태. ctr 의 `--plain-http` 플래그가 결정적이라는 건 **containerd 가 HTTP scheme 을 인식하지 못하고 있음** 을 의미했다. + +K3s 가 자동 생성한 hosts.toml 을 직접 확인했더니 원인이 드러났다. + +```toml +# /var/lib/rancher/k3s/agent/etc/containerd/certs.d/registry.project.com/hosts.toml +# File generated by k3s. DO NOT EDIT. +server = "https://registry.project.com/v2" # ← origin 은 HTTPS 강제 +capabilities = ["pull", "resolve", "push"] +skip_verify = true + +[host."http://registry.project.com/v2"] # ← mirror 만 HTTP + capabilities = ["pull", "resolve"] + skip_verify = true +``` + +### Root Cause 4 + +K3s 1.34 + containerd 2.x 환경에서 `registries.yaml` → `hosts.toml` 자동 변환 동작: + +1. **`mirrors.<host>.endpoint`** 는 `[host."<endpoint>"]` 블록으로 그대로 옮겨진다 (HTTP scheme 보존). +2. 그러나 **최상위 `server`** 는 `host` 이름 기준으로 `https://<host>/v2` 가 강제된다 — registry 가 default 로 HTTPS 라는 가정. +3. containerd 는 mirror 가 fail 하거나 manifest 협상 실패 시 `server` 로 fallback. 우리 registry 는 origin 도 HTTP 라서 fallback 이 cert mismatch 또는 connection 실패로 끝남. +4. 추가로 endpoint URL 에 `/v2` path 까지 박혀 있다. containerd hosts.toml 명세상 `[host."<URL>"]` 의 URL 은 scheme + host 만 허용, path 는 host matcher 를 깨뜨릴 수 있다. + +즉 **registry 가 HTTPS 인 일반 환경을 가정한 K3s 의 자동 변환 로직이, HTTP-only registry 환경과 불일치** 를 일으킨 것이다. + +### 해결 4 + +K3s 의 hosts.toml 자동 생성 자체를 끄고 직접 작성해야 한다. K3s 는 `/etc/rancher/k3s/registries.yaml` 이 존재하는 한 무조건 hosts.toml 을 재생성한다 (재시작 시 사용자 작성을 덮어씀). + +각 노드에서: + +```bash +# 1) 자동 생성을 막기 위해 registries.yaml 비활성화 +sudo mv /etc/rancher/k3s/registries.yaml /etc/rancher/k3s/registries.yaml.bak + +# 2) hosts.toml 직접 작성 +sudo mkdir -p /var/lib/rancher/k3s/agent/etc/containerd/certs.d/registry.project.com +sudo tee /var/lib/rancher/k3s/agent/etc/containerd/certs.d/registry.project.com/hosts.toml > /dev/null <<'EOF' +server = "http://registry.project.com" + +[host."http://registry.project.com"] + capabilities = ["pull", "resolve", "push"] + skip_verify = true + + [host."http://registry.project.com".auth] + username = "testuser" + password = "abcd6845" +EOF + +# 3) 적용 +sudo systemctl restart k3s-agent # 워커 노드 +sudo systemctl restart k3s # control-plane +``` + +핵심 차이: + +- `server` 도 `http://` 명시 → fallback 도 HTTP. +- endpoint URL 에서 `/v2` path 제거. +- `auth` 를 hosts.toml 에 박아 `imagePullSecrets` 와 무관하게 노드 단에서 인증 자동 첨부. + +### 검증 + +```bash +# 노드의 registry 연결 직접 검증 +sudo /usr/local/bin/k3s ctr -a /run/k3s/containerd/containerd.sock \ + images pull --plain-http --user 'testuser:abcd6845' \ + registry.project.com/auth-platform/auth-server:manual-20260512071751 + +# Pod 가 새 이미지로 정상 Running 되는지 +kubectl -n mnt get pod -l app.kubernetes.io/name=auth-server \ + -o custom-columns='NAME:.metadata.name,READY:.status.containerStatuses[0].ready,IMAGE:.spec.containers[0].image' +# → READY=true, IMAGE=...:manual-20260512071751 + +# registry pod 의 access log 에서 containerd 호출 확인 +kubectl -n mnt logs deployment/docker-registry --tail=50 | grep "containerd/v" +# → "useragent": "containerd/v2.2.2-bd1.34" 가 manifests/blobs 호출에 보이면 OK +``` + +### 이전 항목과의 관계 — 같은 증상, 다른 root cause + +이번 사건은 표면 증상이 [#1](#1-registry-image-pull-실패-imagepullbackoff) 과 비슷해 보이지만 실제 원인 layer 가 다르다. + +| 항목 | 시점의 환경 | 원인 layer | 해결 | +|---|---|---|---| +| #1 | registry 가 HTTPS (사설 인증서) | 노드 호스트 OS 가 `registry.project.com` 을 DNS 해석 못 함 | `/etc/hosts` 또는 `registries.yaml` mirror endpoint | +| #9 해결 3 | registry 를 **HTTP-only 로 변경**한 직후 | containerd 가 default HTTPS 로 시도 → cert mismatch | `registries.yaml` 의 endpoint 를 `http://...` 로 | +| #9 해결 4 (이번) | 위 변경 후에도 남아 있던 문제 | K3s 자동 생성 hosts.toml 의 `server` 가 여전히 `https://` 강제 + `/v2` path 포함 | `registries.yaml` 제거 + `hosts.toml` 직접 작성 | + +→ **#1 의 해결이 잘못됐던 것이 아니다**. #1 은 그 시점 (registry HTTPS) 의 정확한 fix 였고 한동안 정상 동작했다. registry 를 HTTP-only 로 변경하면서 새 layer 의 호환성 문제가 드러난 것이며, K3s + containerd 2.x 의 자동 변환 로직이 HTTP-only origin 을 상정하지 않은 것이 진짜 원인이다. + +### 교훈 + +- registry Pod 의 `/v2/` readiness 가 `200` 이라고 해서 push/pull 경로 전체가 정상인 것은 아니다. S3 backend, redirect, NetworkPolicy, ingress auth, containerd mirror 설정을 분리해서 봐야 한다. +- registry 를 MinIO S3 backend 로 둘 때 클러스터 밖 client 가 접근할 수 없는 내부 DNS 로 redirect 되지 않게 `REGISTRY_STORAGE_REDIRECT_DISABLE` 를 검토해야 한다. +- dev 에서 TLS 를 의도적으로 빼더라도 containerd 는 registry 를 기본 HTTPS 로 당긴다. `image:` 필드에는 `http://` scheme 을 넣을 수 없다. +- **K3s 의 `registries.yaml` 자동 변환은 registry 가 HTTPS 라는 가정을 깔고 동작한다**. HTTP-only registry 인 경우 자동 변환을 끄고 (`registries.yaml` 제거) `hosts.toml` 을 직접 작성해야 `server` URL scheme 을 통제할 수 있다. +- 진단 시 `crictl pull` 과 `ctr pull` 의 차이 (특히 `--plain-http` 동작 여부) 를 비교하면 containerd 가 HTTP scheme 을 인식하고 있는지 빠르게 분리할 수 있다. +- 같은 증상이 다시 나타날 때, 이전 사건의 해결책을 그대로 적용하기 전에 **그 시점의 환경 가정과 현재 환경이 같은지** 부터 확인해야 한다. 표면 증상이 같아도 layer 가 다른 경우가 흔하다. +- 노드 런타임 설정은 K8s resource 가 아니므로, 임시 `kubectl debug node` 변경은 반드시 후속으로 운영 source-of-truth (Ansible / Fleet / cloud-init) 에 반영해야 한다. Kustomize manifest 로는 이 파일을 관리하지 않는다. + +--- + +## 2. `vault-0` 가 `0/1 Running` 에서 멈춤 + +### 한 줄 요약 + +Vault Pod 의 readiness probe 는 `vault status` 가 `sealed: false` 여야만 통과한다. 처음 띄운 Vault 는 `sealed/uninitialized` 상태이므로 의도적으로 `0/1` 로 멈추고, 운영자가 init + unseal 을 명시적으로 해야 Ready 가 된다. + +### 배경 + +- Vault Helm chart 의 기본 readiness probe 는 `vault status` 의 health 코드 기반. +- Sealed Vault 가 트래픽을 받으면 안 되므로, **Sealed = NotReady 가 정상**. + +### 증상 + +``` +NAME READY STATUS RESTARTS AGE +vault-0 0/1 Running 0 5m +``` + +`kubectl logs vault-0` 에는 에러 없음. `kubectl exec -it vault-0 -- vault status` 하면 `Initialized: false` 또는 `Sealed: true`. + +### Root Cause + +- Vault 는 첫 기동 시 자동으로 init / unseal 되지 않는다 — unseal key 를 누가 / 어떻게 보관할지가 운영 정책 영역이기 때문. +- 따라서 첫 부트스트랩에는 반드시 운영자 / 자동화 스크립트의 init 절차가 필요하다. + +### 해결 + +```bash +REPO_ROOT="$(pwd)" ENV_NAME=dev bash k8s/scripts/tasks/vault-init.sh +``` + +이 스크립트는 idempotent — 다음을 차례로 처리한다. + +1. `vault operator init` (이미 init 됐으면 skip) +2. unseal keys 를 사용해 unseal +3. `kubernetes` auth method enable + `kubernetes_ca_cert` + `token_reviewer_jwt` 설정 +4. policy / role 등록 + +unseal 이 끝나면 readiness probe 가 통과하고 Pod 이 `1/1 Ready` 로 전환된다. + +### 검증 + +```bash +kubectl get pod -n mnt vault-0 +# vault-0 1/1 Running + +kubectl exec -n mnt vault-0 -- vault status +# Sealed: false +``` + +### 교훈 + +- Pod 이 `Running` 인데 `0/1` 일 때, 무한 대기하지 말고 readiness probe 의 의미부터 본다 — 많은 경우 **의도된 NotReady** 다. +- Vault 같이 운영자 수동 절차가 필요한 컴포넌트는, 이 절차를 스크립트로 idempotent 하게 묶어두는 게 부트스트랩 / 재부팅 / DR 복구를 단순하게 만든다. + +--- + +## 3. `helm upgrade` 가 `has no deployed releases` 로 실패 + +### 한 줄 요약 + +이전 `helm upgrade --install` 시도가 `--atomic` 으로 인해 자동 rollback 되면서 release 가 `failed` / `uninstalled` 상태로만 남았고, 다음 호출이 `upgrade` 분기로 진입하려다 deployed release 가 없어 실패했다. + +### 증상 + +``` +Error: UPGRADE FAILED: "vault-secrets-operator" has no deployed releases +``` + +`helm list -A` 로는 release 가 보이지 않거나 `STATUS=failed` / `uninstalled` 로 보임. + +### Root Cause + +- `helm upgrade --install` 은 release metadata 가 있으면 upgrade 분기로 간다. +- `--atomic` 은 설치 실패 시 자동 rollback. rollback 결과로 metadata 는 남고 실제 배포물은 없는 상태가 되면 다음 `--install` 도 "이미 release 가 있다고 판단 → upgrade → deployed release 없음 → 실패" 로 간다. +- 즉 `--atomic` + 실패 케이스가 쌓이면 멱등성이 무너진다. + +### 해결 + +`tasks/vso-install.sh` 에서 두 가지를 바꿈. + +1. **release status 선검사 + 자동 uninstall** + + ```bash + status=$(helm -n vault-secrets-operator-system status vault-secrets-operator -o json | jq -r '.info.status') + case "$status" in + failed|pending-*|uninstalling|uninstalled) + helm -n vault-secrets-operator-system uninstall vault-secrets-operator || true + ;; + esac + ``` + +2. **`--atomic` 제거** — 실패 시 자동 rollback 보다 다음 실행에서 cleanup + 재시도가 더 안전. + +### 검증 + +```bash +helm list -n vault-secrets-operator-system +# vault-secrets-operator ... STATUS=deployed +``` + +### 교훈 + +- `helm --atomic` 은 단발성 install 에는 좋지만, 부트스트랩 스크립트에서 **반복 실행으로 복구되어야 하는** 경로에는 안 어울린다. +- install/upgrade 스크립트에선 항상 **현재 상태를 먼저 검사하고, 망가진 상태면 cleanup 후 재시작** 하는 패턴이 더 견고하다. + +--- + +## 4. VSO 가 기존 K8s Secret 을 덮어쓰지 않음 + +### 한 줄 요약 + +Vault KV 에 새 값을 넣었는데 K8s Secret 은 옛날 값을 유지. `VaultStaticSecret` 의 `destination.overwrite` 기본값(`false`) 때문에, **이미 존재하는 Secret 을 보면 VSO 가 손대지 않는** 안전한 default 가 자동화와 충돌한 사건. + +### 증상 + +- Vault KV 의 값을 갱신해도 `kubectl get secret -o yaml` 의 `data` 가 안 바뀜. +- Pod 재시작해도 새 값 반영 안 됨. + +### Root Cause + +- VSO 는 소유권 경합 방지 목적으로 `destination.overwrite: false` 가 기본. +- 이 기본은 "운영자가 수동으로 만든 Secret 을 VSO 가 무단 덮어쓰지 않는다" 는 안전 장치 — 단, 처음 부트스트랩 전에 stale Secret 이 남아 있으면 그것도 덮어쓰지 않음. + +### 해결 + +bootstrap 스크립트에 명시적 opt-in 환경변수를 둠. + +```bash +RESET_STALE_SECRETS=yes bash k8s/scripts/bin/bootstrap.sh dev +``` + +이 옵션이 있을 때만 VSO-managed K8s Secret 후보들을 선제 삭제 → VSO 가 새로 생성. + +수동 우회: + +```bash +kubectl delete secret <name> -n <ns> +# VSO reconcile (수 초~수십 초) 대기 +``` + +### 검증 + +```bash +kubectl get secret <name> -n <ns> -o yaml +# data: 새 값 +# metadata.ownerReferences: VaultStaticSecret 으로 설정됨 +``` + +### 교훈 + +- 안전한 default (`overwrite: false`) 는 자동화 / 운영 흐름과 자주 충돌한다. 깨려면 **명시적 opt-in 플래그** 로 깨야지, default 를 무작정 바꾸면 운영자 수동 자산이 날아간다. +- bootstrap 스크립트의 파괴적 옵션은 환경변수 이름에 의도가 드러나야 한다 (`RESET_STALE_SECRETS=yes` 처럼). + +--- + +## 5. ForwardAuth 로그인 E2E 검증 실패 + +### 한 줄 요약 + +oauth2-proxy / Keycloak / Traefik / auth-server 사이의 설정이 각각 조금씩 어긋나 있어, "로그인 화면은 뜨는가" 와 "로그인 후 API 가 인증된 요청으로 통과하는가" 가 단계별로 실패했다. 문제는 하나가 아니라 Traefik CRD 누락, TLS Secret 부재, Keycloak client secret 불일치, oauth2-proxy Authorization header 미전달, auth-server issuer 설정 미반영이 연쇄적으로 겹친 사건이었다. + +### 배경 + +목표 흐름은 다음과 같다. + +```text +Browser + -> https://project.com/* + -> Traefik Ingress + -> oauth2-proxy ForwardAuth (/oauth2/auth) + -> Keycloak OIDC login + -> oauth2-proxy callback (/oauth2/callback) + -> auth-server +``` + +Boundary 기준으로 나누면 다음과 같다. + +| Boundary | 정상 신호 | 실패 신호 | +|---|---|---| +| Browser local DNS/TLS | Chrome 이 `project.com` 을 Traefik IP 로 열고 self-signed 인증서를 통과 | public DNS 로 빠짐, `ERR_CERT_*`, HSTS/인증서 경고에서 진행 불가 | +| Traefik routing/TLS | host rule 이 잡히고 TLS Secret 으로 handshake 성공 | Traefik `404`, `unknown TLS options`, `secret ... does not exist`, SNI 실패 | +| Traefik ForwardAuth | 미인증 요청이 oauth2-proxy `/oauth2/auth` 로 위임 | backend 로 바로 감, 또는 항상 `Unauthorized` | +| Traefik error redirect | 미인증 요청이 `302 Location: keycloak...` 로 변환 | `Location` 은 있는데 status 가 `401` 이라 브라우저가 이동하지 않음 | +| oauth2-proxy -> Keycloak authorize | Keycloak 로그인 화면 `200` | authorize URL 생성 실패, 잘못된 redirect URI | +| Keycloak -> oauth2-proxy callback/token | callback 후 oauth2-proxy session cookie 발급 | `unauthorized_client`, invalid client credentials | +| oauth2-proxy -> auth-server header | `/oauth2/auth` 가 `Authorization: Bearer ...` 반환 | auth-server 가 `anonymous` 로 처리 | +| auth-server JWT validation | issuer/JWK 검증 통과 후 application response 반환 | issuer mismatch, JWK 조회 실패, `401` | +| auth-server application route | 실제 API/화면 응답 | 인증은 통과했지만 route 없음, 예: `404 PRES-005` | + +dev 환경에서는 실제 공인 DNS / ACME 인증서가 아직 준비되지 않았다. 그래서 CLI 검증은 아래처럼 DNS 와 TLS 검증을 임시 우회했다. + +```bash +curl -k \ + --resolve project.com:443:10.208.141.123 \ + --resolve keycloak.dev.example.com:443:10.208.141.123 \ + https://project.com/oauth2/start?rd=https://project.com/api/me +``` + +브라우저는 `curl --resolve` 와 `-k` 를 쓸 수 없으므로, 직접 웹사이트로 검증하려면 로컬 `/etc/hosts` 와 self-signed 인증서 예외가 필요하다. + +```text +10.208.141.123 project.com +10.208.141.123 keycloak.dev.example.com +``` + +### 증상 1: Ingress 가 404 또는 TLS handshake 실패 + +Boundary: `Traefik routing/TLS` + +처음에는 `https://project.com/api/me` 가 Traefik 기본 `404 page not found` 를 반환했고, Keycloak discovery 도 TLS 단계에서 실패했다. + +대표 증상: + +```text +HTTP/2 404 +404 page not found + +curl: (35) OpenSSL: tlsv1 unrecognized name +``` + +### Root Cause 1 + +`auth-server`, `oauth2-proxy`, `keycloak-public` Ingress 는 모두 아래 annotation 을 참조하고 있었다. + +```text +traefik.ingress.kubernetes.io/router.tls.options: kube-system-modern-tls@kubernetescrd +traefik.ingress.kubernetes.io/router.middlewares: kube-system-https-redirect@kubernetescrd,... +``` + +하지만 live cluster 에는 `modern-tls`, `https-redirect`, `security-headers` 가 없었다. Traefik 로그에는 다음 오류가 반복됐다. + +```text +unknown TLS options: kube-system-modern-tls@kubernetescrd +``` + +결과적으로 Traefik 가 해당 router 를 정상 구성하지 못했고, host/path 가 맞아도 요청이 backend 로 가지 않았다. + +### 해결 1 + +Traefik packaged manifest 를 직접 수정하지 않고, Git source-of-truth 인 overlay 를 적용했다. + +```bash +kubectl apply -k k8s/overlays/dev/platform/traefik +``` + +적용된 리소스: + +- `HelmChartConfig/traefik` +- `Middleware/https-redirect` +- `Middleware/security-headers` +- `TLSOption/modern-tls` + +검증: + +```bash +kubectl -n kube-system get tlsoption,middleware +kubectl -n kube-system logs deploy/traefik --tail=200 +``` + +### 증상 2: TLS Secret 이 없어 HTTPS 라우팅이 SNI 에서 실패 + +Boundary: `Traefik routing/TLS` 와 `cert-manager -> Traefik TLS Secret` + +Traefik CRD 를 적용한 뒤에도 HTTPS 요청은 `tlsv1 unrecognized name` 으로 실패했다. Traefik 로그에는 아래 메시지가 있었다. + +```text +Error configuring TLS: secret mnt/project-com-tls does not exist +Error configuring TLS: secret mnt/keycloak-dev-example-com-tls does not exist +``` + +### Root Cause 2 + +dev `Certificate` 리소스가 `letsencrypt-staging` 을 참조하고 있었다. 하지만 현재 dev 도메인(`project.com`, `keycloak.dev.example.com`) 은 외부 공인 DNS 가 Traefik 진입점으로 향하지 않는다. ACME HTTP-01 은 public DNS 와 80/443 도달성이 필요하므로 인증서 발급이 완료될 수 없었다. + +또한 `TLSOption` 의 `sniStrict: true` 때문에 TLS Secret 이 없는 host 는 handshake 단계에서 차단됐다. 보안상 의도한 동작이지만, dev 검증에는 별도 인증서가 필요했다. + +### 해결 2 + +dev 전용 `ClusterIssuer/dev-selfsigned` 를 추가하고, dev TLS `Certificate` 들이 이를 참조하도록 바꿨다. + +변경 파일: + +- `k8s/overlays/dev/platform/cert-manager-issuers/dev-selfsigned-clusterissuer.yaml` +- `k8s/overlays/dev/platform/cert-manager-issuers/kustomization.yaml` +- `k8s/overlays/dev/tls/project-com-certificate.yaml` +- `k8s/overlays/dev/tls/keycloak-dev-certificate.yaml` +- `k8s/overlays/dev/tls/registry-project-com-certificate.yaml` + +적용: + +```bash +kubectl apply -k k8s/overlays/dev/platform/cert-manager-issuers +kubectl apply -k k8s/overlays/dev/tls +kubectl -n mnt wait --for=condition=Ready certificate/project-com --timeout=120s +kubectl -n mnt wait --for=condition=Ready certificate/keycloak-dev-example-com --timeout=120s +``` + +검증 결과 Keycloak discovery 가 HTTPS 로 `200` 을 반환했다. + +```text +GET https://keycloak.dev.example.com/realms/platform/.well-known/openid-configuration +HTTP/2 200 +issuer: https://keycloak.dev.example.com/realms/platform +``` + +### 증상 3: 로그인 화면은 뜨지만 callback 에서 500 + +Boundary: `Keycloak -> oauth2-proxy callback/token` + +`/oauth2/start` 는 Keycloak authorize URL 로 `302` 되고, Keycloak 로그인 화면까지는 열렸다. 하지만 로그인 후 `/oauth2/callback` 에서 oauth2-proxy 가 `500 Internal Server Error` 를 반환했다. + +oauth2-proxy 로그: + +```text +Error redeeming code during OAuth2 callback: +token exchange failed: oauth2: "unauthorized_client" "Invalid client or Invalid client credentials" +``` + +### Root Cause 3 + +Vault / K8s Secret 의 `auth-server-ingress` client secret 과 Keycloak live realm 의 client secret 이 달랐다. + +이유: + +- Vault seed 스크립트가 `keycloak/clients/auth-server-ingress` 와 `oauth2-proxy/forward-auth` secret 을 생성했다. +- oauth2-proxy 는 VSO 가 만든 최신 K8s Secret 을 읽었다. +- 하지만 이미 import 된 Keycloak realm/client 는 새 secret 으로 다시 동기화되지 않았다. +- `KeycloakRealmImport` 는 source 에서 secret placeholder 를 보도록 수정했지만, 기존 import 결과가 자동으로 다시 적용되지 않았다. + +비교는 값을 출력하지 않고 hash 로 했다. + +```text +k8s_client_secret_sha == oauth2_secret_sha +keycloak_client_sha != oauth2_secret_sha +``` + +### 해결 3 + +Keycloak admin API 를 내부 port-forward 로만 열고, `auth-server-ingress` client representation 의 `secret` 을 K8s Secret 값과 동기화했다. + +```bash +kubectl -n mnt port-forward svc/keycloak 18080:80 +``` + +그 뒤 admin token 으로 client 를 조회하고 `PUT /admin/realms/platform/clients/{id}` 로 secret 을 반영했다. 반영 후 hash 가 일치했다. + +```text +desired_sha == keycloak_sha +update_status=204 +``` + +주의: Keycloak public ingress 는 의도적으로 `/admin/` 을 노출하지 않는다. admin API 작업은 port-forward, VPN, 또는 내부 운영 경로로만 수행한다. + +### 증상 4: 미인증 요청이 Keycloak 으로 자동 이동하지 않음 + +Boundary: `Traefik ForwardAuth` 와 `Traefik error redirect` + +미인증 상태에서 `https://project.com/` 또는 `https://project.com/api/me` 를 열면 oauth2-proxy 의 로그인 시작 응답이 본문에는 보였지만, HTTP status 는 여전히 `401` 이었다. 이 경우 브라우저는 `Location` header 가 있어도 자동으로 따라가지 않는다. + +대표 응답: + +```text +HTTP/2 401 +location: https://keycloak.dev.example.com/realms/platform/protocol/openid-connect/auth?... + +<a href="https://keycloak.dev.example.com/...">Found</a>. +``` + +### Root Cause 4 + +Traefik `errors` middleware 는 `/oauth2/start?rd={url}` 를 내부 호출해 응답 body/header 를 가져오지만, 기본 동작만으로는 원래 오류 status 를 유지할 수 있다. 그 결과 oauth2-proxy 가 `302 Location` 을 만들었더라도 최종 클라이언트 응답이 `401` 로 남아 브라우저 redirect 가 일어나지 않았다. + +또한 errors middleware 가 forwardAuth 의 `401` 을 감싸려면 middleware 순서가 중요하다. `oauth2-proxy-errors` 가 `oauth2-proxy-auth` 앞에 있어야 forwardAuth 실패 응답을 로그인 시작 응답으로 바꿀 수 있다. + +### 해결 4 + +`oauth2-proxy-errors` 에 `statusRewrites` 를 추가하고, auth-server Ingress middleware 순서를 조정했다. + +```yaml +spec: + errors: + status: + - "401-403" + statusRewrites: + "401": 302 + "403": 302 + service: + name: oauth2-proxy + port: 4180 + query: /oauth2/start?rd={url} +``` + +auth-server Ingress 순서: + +```text +kube-system-https-redirect@kubernetescrd, +mnt-oauth2-proxy-errors@kubernetescrd, +mnt-oauth2-proxy-auth@kubernetescrd, +kube-system-security-headers@kubernetescrd +``` + +검증: + +```bash +curl -k -D - \ + --resolve project.com:443:10.208.141.123 \ + https://project.com/ +``` + +정상 응답: + +```text +HTTP/2 302 +location: https://keycloak.dev.example.com/realms/platform/protocol/openid-connect/auth?... +``` + +### 증상 5: callback 은 성공하지만 auth-server 가 계속 401 + +Boundary: `oauth2-proxy -> auth-server header` + +Keycloak client secret 을 맞춘 뒤 oauth2-proxy callback 은 성공했고, `_oauth2_proxy` 세션 쿠키도 발급됐다. oauth2-proxy 로그에도 인증 성공이 찍혔다. + +```text +[AuthSuccess] Authenticated via OAuth2: +email:dev-login-check@project.local +groups:[role:platform-user ...] +``` + +하지만 `https://project.com/api/me` 는 계속 401 이었다. auth-server 로그는 사용자를 `anonymous` 로 보고 있었다. + +```text +Authentication required. actorId=anonymous method=GET requestPath=/api/me +``` + +### Root Cause 5 + +oauth2-proxy 의 `/oauth2/auth` 는 `X-Auth-Request-Access-Token` 은 반환했지만 `Authorization: Bearer ...` 헤더를 반환하지 않았다. auth-server 는 Spring Security Resource Server 이므로 JWT 를 `Authorization` 헤더에서 읽는다. 따라서 ForwardAuth 는 통과해도 backend 는 anonymous 요청으로 처리했다. + +### 해결 5 + +oauth2-proxy config 에 아래 설정을 추가했다. + +```hcl +set_authorization_header = true +``` + +변경 파일: + +- `k8s/components/forward-auth/oauth2-proxy-config.yaml` + +반영 후 oauth2-proxy 를 재시작했다. + +```bash +kubectl -n mnt rollout restart deployment/oauth2-proxy +kubectl -n mnt rollout status deployment/oauth2-proxy --timeout=180s +``` + +검증: + +```bash +curl -k -D - \ + -b /tmp/oauth2-authenticated-cookies.txt \ + --resolve project.com:443:10.208.141.123 \ + https://project.com/oauth2/auth +``` + +응답에 `Authorization: Bearer ...` 와 `X-Auth-Request-Access-Token` 이 함께 나타나면 정상이다. + +### 증상 6: Authorization 은 생겼지만 auth-server 가 issuer mismatch 로 실패 + +Boundary: `auth-server JWT validation` + +Authorization 헤더가 생긴 뒤 auth-server 는 더 이상 단순 anonymous 만 보지 않았다. 대신 JWT decoder 초기화에서 issuer mismatch 를 냈다. + +```text +The Issuer "https://keycloak.dev.example.com/realms/platform" +provided in the configuration did not match the requested issuer +"http://keycloak/realms/platform" +``` + +### Root Cause 6 + +auth-server 의 live Pod 가 예전 issuer 설정(`http://keycloak/realms/platform`) 을 들고 있었다. Git source 와 live ConfigMap 은 이미 외부 issuer 로 맞춰져 있었지만, Deployment 가 재시작되지 않아 Pod 환경변수에는 반영되지 않았다. + +정상 설정: + +```text +SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_ISSUER_URI=https://keycloak.dev.example.com/realms/platform +SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_JWK_SET_URI=http://keycloak/realms/platform/protocol/openid-connect/certs +``` + +설계 의도는 issuer claim 검증은 외부 issuer 로 맞추고, JWK 조회는 클러스터 내부 Service 로 수행하는 것이다. + +### 해결 6 + +auth-server Deployment 를 재시작했다. + +```bash +kubectl -n mnt rollout restart deployment/auth-server +kubectl -n mnt rollout status deployment/auth-server --timeout=180s +``` + +### 최종 검증 결과 + +새 로그인 흐름으로 다음이 확인됐다. + +1. `/oauth2/start` → Keycloak authorize URL `302` +2. Keycloak 로그인 화면 `200` +3. 로그인 폼 제출 → authorization code 발급 +4. `/oauth2/callback` → oauth2-proxy session cookie 발급 +5. oauth2-proxy `/oauth2/auth` → `202` +6. auth response header: + - `Authorization: Bearer ...` + - `X-Auth-Request-Access-Token` + - `X-Auth-Request-Email` + - `X-Auth-Request-User` + - `X-Auth-Request-Preferred-Username` +7. 미인증 요청은 `302` 로 Keycloak 로그인 화면으로 이동 +8. `/api/me` 요청은 ForwardAuth 를 통과해 auth-server 까지 도달 + +최종 `/api/me` 응답은 auth-server 의 애플리케이션 `404 PRES-005` 였다. + +```json +{ + "success": false, + "code": "PRES-005", + "message": "요청한 리소스를 찾을 수 없습니다." +} +``` + +이는 ForwardAuth 실패가 아니라 auth-server 에 해당 route 가 없다는 의미다. 인증 계층 검증 관점에서는 `401` 이 사라지고 auth-server business response 가 나온 시점이 통과 기준이다. + +### 브라우저로 직접 검증하는 방법 + +curl 로는 DNS 와 TLS 를 아래 옵션으로 우회한다. + +```bash +curl -k \ + --resolve project.com:443:10.208.141.123 \ + --resolve keycloak.dev.example.com:443:10.208.141.123 \ + https://project.com/oauth2/start?rd=https://project.com/api/me +``` + +브라우저는 같은 우회를 옵션으로 줄 수 없으므로 로컬 머신에서 다음을 준비한다. + +1. `/etc/hosts` 에 ingress IP 매핑: + + ```text + 10.208.141.123 project.com + 10.208.141.123 keycloak.dev.example.com + ``` + +2. `https://project.com/oauth2/start?rd=https://project.com/api/me` 접속 +3. dev self-signed 인증서 경고 허용 또는 인증서 trust 등록 +4. Keycloak 로그인 +5. callback 후 `project.com` 으로 돌아오는지 확인 + +### 교훈 + +- ForwardAuth E2E 는 하나의 설정만 맞아서는 동작하지 않는다. Traefik CRD, TLS Secret, oauth2-proxy secret, Keycloak client secret, redirect status, backend issuer/JWK 설정이 모두 같은 세계관이어야 한다. +- "로그인 화면이 뜬다" 는 검증의 중간 지점일 뿐이다. 반드시 callback, token exchange, session cookie, `/oauth2/auth 202`, backend 도달까지 나눠 봐야 한다. +- dev 에서 ACME 가 안 되는 상황은 정상일 수 있다. public DNS 가 없으면 `dev-selfsigned` 로 검증하고, staging/prod 에서 ACME issuer 로 전환한다. +- Keycloak client secret 은 Vault/K8s/oauth2-proxy/Keycloak live realm 네 곳이 한 값으로 수렴해야 한다. +- Spring Resource Server 는 issuer claim 을 엄격히 검증한다. 내부 Service URL 과 외부 issuer URL 을 섞을 때는 `issuer-uri` 와 `jwk-set-uri` 의 역할을 분리해야 한다. + +--- + +## 6. namespace 가 `Terminating` 에 걸림 + +### 한 줄 요약 + +VSO controller 가 먼저 사라진 뒤 CRD finalizer / PVC protection finalizer / 일부 namespaced 리소스 finalizer 가 풀리지 못해 namespace 가 `Terminating` 에서 무한 대기. `teardown.sh` 가 finalizer 를 단계적으로 정리하고, 마지막 수단으로 `/finalize` API 를 직접 호출해 풀어준다. + +### 증상 + +``` +NAME STATUS AGE +mnt Terminating 3h +``` + +`kubectl get all,pvc,vaultstaticsecret -n mnt` 에 잔존 리소스 있음. + +### Root Cause + +namespace 삭제는 그 안의 모든 리소스 finalizer 가 풀려야 끝난다. 멈추는 패턴은 보통 셋: + +1. VSO controller 는 이미 삭제됐는데 `VaultStaticSecret` / `VaultAuth` / `VaultConnection` 의 CRD finalizer 가 남음 → 풀어줄 컨트롤러 부재 +2. PVC protection finalizer (`kubernetes.io/pvc-protection`) 가 PV 와의 정리 순서 때문에 남음 +3. 다른 namespaced 리소스 finalizer 도 컨트롤러 부재로 cleanup 안 됨 + +### 해결 + +`teardown.sh` 가 단계적으로 처리: + +| Phase | 작업 | +|:---:|---| +| 1 | VSO CRD 삭제 → 60s timeout 시 `VaultStaticSecret` / `VaultAuth` / `VaultConnection` finalizer 강제 해제 | +| 2 | PVC 보호 finalizer 제거 | +| 3 | 전체 namespaced 리소스 finalizer 일괄 제거 | +| 4 | `kubectl delete namespace` 60s 대기 → 실패 시 namespace `/finalize` API 직접 호출 | +| 5 | cluster-scoped 리소스 (`vault-tokenreview-binding` 등) 정리 | + +### 검증 + +```bash +kubectl get ns mnt +# Error from server (NotFound): namespaces "mnt" not found +``` + +### 교훈 + +- `Terminating` 무한대기는 **거의 항상 finalizer 누락**. 어떤 컨트롤러가 풀어줘야 하는지 / 그 컨트롤러가 살아있는지부터 확인. +- `/finalize` API 직접 호출은 마지막 수단 — orphan PV / PVC 바인딩이 남을 수 있어, 이후 클러스터 정리에서 별도로 챙겨야 함. +- 정상적인 teardown 순서는 "역의존 순" — 컨트롤러를 마지막에 죽이기. 자동 스크립트가 이를 강제하지 않으면 운영자 손에 버그가 옮겨붙는다. + +--- + +## 7. PodSecurity 위반 경고 (admission) + +### 한 줄 요약 + +namespace 에 `pod-security.kubernetes.io/enforce: restricted` 라벨이 붙어 있어 admission 단에서 securityContext 누락이 모두 거부됨. 모든 워크로드 매니페스트에 Restricted 필드 체크리스트를 적용해서 해결. + +### 증상 + +``` +Warning: would violate PodSecurity "restricted:latest": + allowPrivilegeEscalation != false (...) + unrestricted capabilities (...) + runAsNonRoot != true (...) + seccompProfile (...) +``` + +`kubectl apply` 또는 deploy 시점에 Pod 생성이 거부 / 경고. + +### Root Cause + +PSS Restricted 는 **default-deny** 에 가깝다. Pod / container 둘 다에서 다음 필드를 명시해야 통과한다. + +| Pod | Container | +|---|---| +| `runAsNonRoot: true` | `runAsNonRoot: true` | +| `seccompProfile.type: RuntimeDefault` | `allowPrivilegeEscalation: false` | +| | `capabilities.drop: ["ALL"]` | +| | `seccompProfile.type: RuntimeDefault` | +| `runAsUser` / `runAsGroup` non-zero | (image 가 root 로 빌드됐으면 별도 처리) | + +### 해결 + +모든 Deployment / StatefulSet / Job 매니페스트에 일관된 securityContext 블록 적용. 예시: + +```yaml +spec: + template: + spec: + securityContext: + runAsNonRoot: true + runAsUser: 1000 + runAsGroup: 1000 + fsGroup: 1000 + seccompProfile: + type: RuntimeDefault + containers: + - name: app + securityContext: + allowPrivilegeEscalation: false + runAsNonRoot: true + capabilities: + drop: ["ALL"] + seccompProfile: + type: RuntimeDefault +``` + +이 패턴은 [`docs/security-hardening.md`](./security-hardening.md) 의 체크리스트에 정리되어 있고, [`k8s/scripts/ci/validate.sh`](../k8s/scripts/ci/validate.sh) 가 `kube-linter` 로 회귀를 막는다. + +### 검증 + +```bash +bash k8s/scripts/ci/validate.sh +# build=ok schema=ok lint=ok + +kubectl apply -k k8s/overlays/dev +# Warning 없음 +``` + +### 교훈 + +- PSS Restricted 는 **사후 디버깅이 비싸다** — 매니페스트 작성 시점에 체크리스트로 박는 게 가장 싸다. +- Pod-level + Container-level 양쪽 모두에서 명시해야 한다 (어느 한쪽만 있으면 다른 쪽은 default 로 평가되어 거부될 수 있음). +- 회귀 방지는 `kube-linter` / `kubeconform` / `kustomize build` 3단 검증을 CI 단계로 끌어올리는 게 최소. + +--- + +## 8. VSO 가 Vault 로그인 실패 + +### 한 줄 요약 + +VSO 가 자기 ServiceAccount JWT 로 Vault 의 kubernetes auth method 에 로그인하려는데, Vault → kube-apiserver 의 `TokenReview` 호출 권한 (`vault-tokenreview-binding` ClusterRoleBinding) 또는 Vault 쪽 config (`token_reviewer_jwt` / `kubernetes_ca_cert`) 가 빠져 인증이 거부된 사건. + +### 증상 + +VSO Pod logs: + +``` +permission denied (vault.errors.PermissionDenied) +authentication failed: invalid token (...) +``` + +K8s Secret 이 sync 되지 않고 빈 상태. + +### Root Cause + +K8s 인증의 의존 그래프는 두 단: + +1. **Vault → kube-apiserver `TokenReview` 호출 권한** + - 이건 `system:auth-delegator` ClusterRole 을 Vault 의 ServiceAccount 에 묶는 ClusterRoleBinding (`vault-tokenreview-binding`) 으로 부여. +2. **Vault 자체의 kubernetes auth config** + - `vault write auth/kubernetes/config` 에 `kubernetes_host` + `kubernetes_ca_cert` + `token_reviewer_jwt` (Vault SA 의 JWT) 설정. + +둘 중 하나만 빠져도 로그인 실패. + +### 해결 + +```bash +# 1. ClusterRoleBinding 적용 +kubectl apply -k k8s/overlays/<env>/vault/ + +# 2. vault auth/kubernetes/config 설정 (idempotent) +REPO_ROOT="$(pwd)" ENV_NAME=<env> bash k8s/scripts/tasks/vault-init.sh +``` + +`vault-init.sh` 는 다음을 자동으로 한다: + +```bash +vault write auth/kubernetes/config \ + kubernetes_host="https://kubernetes.default.svc" \ + kubernetes_ca_cert=@/var/run/secrets/kubernetes.io/serviceaccount/ca.crt \ + token_reviewer_jwt=@/var/run/secrets/kubernetes.io/serviceaccount/token +``` + +### 검증 + +```bash +kubectl get clusterrolebinding vault-tokenreview-binding +# 존재해야 함 + +kubectl logs -n vault-secrets-operator-system -l app.kubernetes.io/name=vault-secrets-operator +# successfully authenticated to Vault + +kubectl get secret <vso-managed-secret> -o yaml +# data 필드 채워짐 +``` + +### 교훈 + +- "Vault 로그인 실패" 는 거의 항상 **두 권한 중 하나의 누락**: + 1. Vault SA 가 kube-apiserver 의 TokenReview 를 호출할 수 있나? (RBAC) + 2. Vault config 에 SA token + CA cert 가 있나? (Vault 측 설정) +- 두 단을 한 idempotent 스크립트(`vault-init.sh`) 로 묶어두면 재현 / 복구 / 환경 이전이 단순해진다. + +--- + +## 같이 보기 + +- [`guide.md` 15장](../guide.md#15-트러블슈팅) — 운영자 절차서 톤의 동일 사건 정리 +- [`docs/operations.md`](./operations.md) — bootstrap / teardown / validate 의 설계 의도 +- [`docs/security-hardening.md`](./security-hardening.md) — PSS Restricted 체크리스트, NetworkPolicy +- [`docs/vault-vso.md`](./vault-vso.md) — VSO 운영 모델, `destination.overwrite` 정책 근거 diff --git a/docs/validation-report.md b/docs/validation-report.md new file mode 100644 index 0000000..b049dfc --- /dev/null +++ b/docs/validation-report.md @@ -0,0 +1,62 @@ +# Docs validation report + +Generated: 2026-04-20T08:51:44Z +Tools: kubeconform v0.6.7, kube-linter v0.7.4, yq v4.44.3 +CRD schemas: datreeio/CRDs-catalog (remote fetch) + +## Summary + +| Metric | Count | +|---|---| +| Files scanned | 37 | +| YAML blocks extracted | 146 | +| K8s-resource blocks | 109 | +| YAML syntax errors | 0 | +| Schema invalid/errors | 0 | +| kube-linter findings | 0 | + +## Per-file + +| File | Blocks | K8s | Syntax | Schema | Lint | +|---|---|---|---|---|---| +| docs/examples/infra/architecture-environments.md | 4 | 3 | 0 | 0 | 0 | +| docs/examples/infra/backup-restore.md | 9 | 7 | 0 | 0 | 0 | +| docs/examples/infra/config-and-secrets.md | 9 | 8 | 0 | 0 | 0 | +| docs/examples/infra/db-and-migration.md | 6 | 4 | 0 | 0 | 0 | +| docs/examples/infra/flyway.md | 5 | 4 | 0 | 0 | 0 | +| docs/examples/infra/k3s-specific.md | 9 | 2 | 0 | 0 | 0 | +| docs/examples/infra/keycloak.md | 7 | 5 | 0 | 0 | 0 | +| docs/examples/infra/kustomize.md | 8 | 8 | 0 | 0 | 0 | +| docs/examples/infra/minio.md | 7 | 6 | 0 | 0 | 0 | +| docs/examples/infra/network-ingress-tls.md | 8 | 8 | 0 | 0 | 0 | +| docs/examples/infra/observability-health.md | 7 | 7 | 0 | 0 | 0 | +| docs/examples/infra/operations-runbook-upgrade-rollback.md | 5 | 5 | 0 | 0 | 0 | +| docs/examples/infra/resources-probes-availability.md | 7 | 7 | 0 | 0 | 0 | +| docs/examples/infra/scripts.md | 0 | 0 | 0 | 0 | 0 | +| docs/examples/infra/security-hardening.md | 6 | 6 | 0 | 0 | 0 | +| docs/examples/infra/storage-pvc.md | 13 | 11 | 0 | 0 | 0 | +| docs/examples/infra/vault.md | 9 | 7 | 0 | 0 | 0 | +| docs/examples/infra/workload-selection.md | 6 | 6 | 0 | 0 | 0 | +| docs/standards/infra/architecture-environments.md | 0 | 0 | 0 | 0 | 0 | +| docs/standards/infra/backup-restore.md | 0 | 0 | 0 | 0 | 0 | +| docs/standards/infra/config-and-secrets.md | 1 | 1 | 0 | 0 | 0 | +| docs/standards/infra/db-and-migration.md | 2 | 0 | 0 | 0 | 0 | +| docs/standards/infra/flyway.md | 2 | 0 | 0 | 0 | 0 | +| docs/standards/infra/k3s-specific.md | 1 | 1 | 0 | 0 | 0 | +| docs/standards/infra/keycloak.md | 0 | 0 | 0 | 0 | 0 | +| docs/standards/infra/kustomize.md | 5 | 0 | 0 | 0 | 0 | +| docs/standards/infra/minio.md | 0 | 0 | 0 | 0 | 0 | +| docs/standards/infra/network-ingress-tls.md | 1 | 0 | 0 | 0 | 0 | +| docs/standards/infra/observability-health.md | 0 | 0 | 0 | 0 | 0 | +| docs/standards/infra/operations-runbook-upgrade-rollback.md | 1 | 1 | 0 | 0 | 0 | +| docs/standards/infra/resources-probes-availability.md | 1 | 0 | 0 | 0 | 0 | +| docs/standards/infra/scripts.md | 0 | 0 | 0 | 0 | 0 | +| docs/standards/infra/security-hardening.md | 1 | 0 | 0 | 0 | 0 | +| docs/standards/infra/storage-pvc.md | 0 | 0 | 0 | 0 | 0 | +| docs/standards/infra/STYLE.md | 5 | 2 | 0 | 0 | 0 | +| docs/standards/infra/vault.md | 1 | 0 | 0 | 0 | 0 | +| docs/standards/infra/workload-selection.md | 0 | 0 | 0 | 0 | 0 | + +## Error details + +_No errors found._ diff --git a/docs/vault-vso.md b/docs/vault-vso.md new file mode 100644 index 0000000..f7e242f --- /dev/null +++ b/docs/vault-vso.md @@ -0,0 +1,98 @@ +# Vault / VSO 상세 + +README 의 Vault·VSO 핵심 섹션을 보충한다. Kubernetes auth 초기화 명령, policy/role 매핑, VaultStaticSecret 카탈로그, dockerconfigjson `.auth` 이슈를 모은다. + +## Vault 기본 정보 + +| 항목 | 값 | +|---|---| +| 이미지 | `hashicorp/vault:1.17.2` | +| 배포 | StatefulSet (replicas 1, file backend) | +| 실행 | `vault server -config=/vault/config/vault.hcl` | +| 포트 | 8200 (http) / 8201 (cluster) — 내부 ClusterIP, NodePort 없음 | +| 저장 | PVC 5Gi (dev overlay 1Gi patch) | +| UI 접근 | `kubectl -n mnt port-forward svc/vault 8200:8200` (외부 노출 금지) | + +## 설계 결정 + +- **file backend (학습 환경 전용)**: 단일 노드 + 학습 목적으로 `storage "file"`. HA 불가. prod 승격 시 `storage "raft"` + KMS 기반 auto-unseal 로 전환. +- **`disable_mlock = true`**: 컨테이너에 `IPC_LOCK` capability 를 부여하지 않고 PSS Restricted 프로필을 유지하기 위함. 대신 swap 이 꺼진 노드에서 실행해야 한다. +- **`tls_disable = 1`**: 단일 namespace 내부 통신만 발생하고 cert-manager 전에 부트스트랩이 끝나야 해서 현재는 비활성화. 클러스터 밖 노출 시 cert-manager 발급 인증서로 TLS 활성화 필수. +- **`api_addr: http://vault:8200` + `cluster_addr: http://vault:8201`**: 짧은 Service 이름. 모든 소비자가 같은 `mnt` namespace 에 있어 FQDN 불필요. + +## RBAC + +ClusterRoleBinding `vault-tokenreview-binding` ← `system:auth-delegator`. Vault 의 Kubernetes auth method 는 클라이언트(VSO 등)가 제출한 ServiceAccount JWT 를 `TokenReview` + `SubjectAccessReview` API 로 검증한다. 이 ClusterRoleBinding 이 없으면 VSO 로그인이 `permission denied` 로 실패한다. + +Vault Pod 의 ServiceAccount 는 `automountServiceAccountToken: true` (기본). Vault 는 `/var/run/secrets/kubernetes.io/serviceaccount/{token,ca.crt}` 를 읽어 `auth/kubernetes/config` 의 `token_reviewer_jwt` / `kubernetes_ca_cert` 를 채운다. + +## Kubernetes auth 초기 설정 + +`tasks/vault-init.sh` 가 수행: + +```bash +vault auth enable kubernetes # idempotent 체크 +vault write auth/kubernetes/config \ + kubernetes_host="https://kubernetes.default.svc.cluster.local:443" \ + kubernetes_ca_cert=@/var/run/secrets/kubernetes.io/serviceaccount/ca.crt \ + token_reviewer_jwt=@/var/run/secrets/kubernetes.io/serviceaccount/token + +vault secrets enable -path=secret kv-v2 # idempotent 체크 + +# policy 2개 (역할별 least-privilege) +vault policy write vso-auth-platform - \ + # identity-postgres/* + auth-server/* + keycloak/* read +vault policy write vso-storage - \ + # minio/* read + +# role 2개 (같은 SA, 다른 policy) +vault write auth/kubernetes/role/vso-auth-platform \ + policies=vso-auth-platform bound_sa=vault-secrets-operator/mnt ttl=1h +vault write auth/kubernetes/role/vso-storage \ + policies=vso-storage bound_sa=vault-secrets-operator/mnt ttl=1h +``` + +## VaultAuth / VaultStaticSecret 매핑 + +policy 분리에 따라 VaultAuth CR 도 2 개. 각 VaultStaticSecret 은 자기 도메인의 VaultAuth 를 참조한다: + +| VaultAuth CR | Vault role | 참조 VaultStaticSecret | +|---|---|---| +| `vault-auth-auth-platform` | `vso-auth-platform` | `identity-postgres-superuser`, `keycloak-db-creds`, `auth-server-db-creds`, `keycloak-bootstrap-admin` | +| `vault-auth-storage` | `vso-storage` | `minio-tenant-env` | + +VSO Operator SA(`vault-secrets-operator`) 는 한 개이지만 Vault 쪽에서 role 별 policy 가 분리되어 있다. auth-platform 토큰이 유출돼도 MinIO secret 은 보호된다. + +## VSO 가 관리하는 Secret 카탈로그 + +Registry 는 auth 없이 운영(NetworkPolicy 로 `mnt` 내부 전용 보호)이라 base 에는 VaultStaticSecret 이 없다. dev overlay 에서 BasicAuth / pull credential 두 개를 추가한다. + +| VaultStaticSecret (dev overlay) | Vault 경로 | K8s Secret | 소비 방식 | +|---|---|---|---| +| `identity-postgres-superuser` | `secret/identity-postgres/superuser` | `identity-postgres-superuser` | file mount (`/run/secrets/superuser/`) → `POSTGRES_USER_FILE`, `POSTGRES_PASSWORD_FILE` | +| `keycloak-db-creds` | `secret/keycloak/db` | `keycloak-db` | file mount — postgres initdb + keycloak `KC_DB_PASSWORD_FILE` | +| `auth-server-db-creds` | `secret/auth-server/db` | `auth-server-db` | file mount — `SPRING_CONFIG_IMPORT=configtree:/etc/secrets/` + Flyway sh wrapper | +| `keycloak-bootstrap-admin` | `secret/keycloak/bootstrap-admin` | `keycloak-bootstrap-admin` | file mount — `KC_BOOTSTRAP_ADMIN_{USERNAME,PASSWORD}_FILE` | +| `minio-tenant-env` | `secret/minio/tenant-env` | `minio-tenant-env` | MinIO Operator `spec.configuration.name` (env file) | +| `docker-registry-basic-auth` (dev) | `secret/docker-registry/basic-auth` | `docker-registry-basic-auth` | Traefik Middleware basicAuth | +| `docker-registry-pull-credentials` (dev) | `secret/docker-registry/pull-cred` | `docker-registry-pull-credentials` | imagePullSecret (`.dockerconfigjson`) | + +모든 VaultStaticSecret 은 `destination.overwrite` 기본값(`false`) 사용. 기존 Secret 이 수동으로 존재하면 VSO 가 덮어쓰지 않는다 (소유권 경합 방지). + +> **Vault 값 교체 후 즉시 반영**: `kubectl -n mnt delete secret <name>` 으로 기존 Secret 을 지우면 VSO 가 다음 reconcile 에 새 값으로 재생성한다. + +`refreshAfter: 1h` — Vault 값 변경 시 1 시간 내 K8s Secret 에 자동 반영. + +## dockerconfigjson `.auth` 필드 + +Docker 공식 config 스키마는 `.auth = base64("<username>:<password>")` 형태다. 기존에는 password 만 base64 하던 버그가 있었고 현재는 다음으로 수정되어 있다: + +``` +{{ printf "%s:%s" username password | b64enc }} +``` + +Docker daemon 이 Registry 에 로그인할 때 이 필드를 디코드하므로 정확한 포맷이 필수. + +## VaultConnection address + +base 는 `http://vault:8200` (짧은 이름) 만 둔다. VSO Operator Pod 가 같은 `mnt` namespace 에 있으면 Kubernetes DNS 가 짧은 이름을 해결한다. 다른 namespace 에서 운영할 때는 overlay 에서 FQDN(`http://vault.mnt.svc.cluster.local:8200`) 으로 patch. diff --git a/guide.md b/guide.md new file mode 100755 index 0000000..cd4b4cb --- /dev/null +++ b/guide.md @@ -0,0 +1,901 @@ +# Project-Infra 운영 가이드 + +아키텍처 · 폴더 구조 · 설계 결정은 [README.md](README.md) 를 먼저 읽는다. 본 문서는 **실제 배포·운영 절차** 에 집중한다. 보안 정책 / 거버넌스 (etcd 암호화, Vault 토큰 관리, bash history 보호) 는 [docs/security-hardening.md](docs/security-hardening.md) 참고. + +--- + +## 목차 + +1. [사전 준비](#1-사전-준비) +2. [최초 부트스트랩 — 자동](#2-최초-부트스트랩--자동) +3. [최초 부트스트랩 — 수동 (단계별)](#3-최초-부트스트랩--수동-단계별) +4. [Traefik / Ingress 운영](#4-traefik--ingress-운영) +5. [TLS / cert-manager 적용](#5-tls--cert-manager-적용) +6. [Keycloak Operator / RealmImport 적용](#6-keycloak-operator--realmimport-적용) +7. [Vault 시크릿 관리](#7-vault-시크릿-관리) +8. [Docker Registry 사용법](#8-docker-registry-사용법) +9. [앱에서 시크릿 사용하기](#9-앱에서-시크릿-사용하기) +10. [마이그레이션 실행 (Flyway)](#10-마이그레이션-실행-flyway) +11. [Vault UI 접근](#11-vault-ui-접근) +12. [환경별 배포](#12-환경별-배포) +13. [검증 / 린트 / 스키마 체크](#13-검증--린트--스키마-체크) +14. [정리 / 롤백 (teardown)](#14-정리--롤백-teardown) +15. [트러블슈팅](#15-트러블슈팅) + +--- + +## 1. 사전 준비 + +> **예상 소요**: 첫 셋업 30 분 (CLI 설치 포함). 두 번째부터는 0. + +### 필요한 CLI + +| 도구 | 용도 | +|---|---| +| `kubectl` | 클러스터 조작 | +| `helm` | VSO Operator 설치 | +| `jq` | JSON 파싱 (scripts 내부) | +| `kustomize` | (선택) 로컬 렌더 | +| `kubeconform` | (선택) 스키마 검증 | +| `kube-linter` | (선택) 안티패턴 린트 | +| `yq` | (선택) YAML 가공 | + +> **참고**: `validate.sh` 는 `~/bin` 에 설치된 도구도 자동으로 PATH 에 추가한다. + +### 필요한 환경 변수 + +| 변수 | 의미 | +|---|---| +| `CONFIRM=yes` | (teardown 전용) 대화형 확인 자동 yes 처리 | +| `RESET_STALE_SECRETS=yes` | (bootstrap 전용) 기존 VSO-managed Secret 삭제 후 재생성 | +| `AUTO_GENERATE=yes` | (vault-seed-apps 전용) 비대화 + env 없음 시 랜덤 비밀번호 생성 | +| `POSTGRES_SUPERUSER_PASSWORD` | (bootstrap Phase 6 비대화) Postgres superuser 비밀번호 | +| `KEYCLOAK_DB_PASSWORD` | (bootstrap Phase 6 비대화) Keycloak DB 비밀번호 | +| `AUTH_SERVER_DB_PASSWORD` | (bootstrap Phase 6 비대화) auth-server DB 비밀번호 | +| `KEYCLOAK_ADMIN_PASSWORD` | (bootstrap Phase 6 비대화) Keycloak 초기 관리자 비밀번호 | +| `MINIO_ROOT_PASSWORD` | (bootstrap Phase 6 비대화) MinIO Tenant 루트 비밀번호 | + +> **주의**: env var 사용 시 [bash history 보호](docs/security-hardening.md#3-bash-history-에-비밀번호-남기지-않기) 참고. `HISTFILE=/dev/null` 접두 권장. + +### 클러스터 전제 + +- Kubernetes 1.25+ (Pod Security Admission 사용) +- `local-path` StorageClass (K3s 기본) 또는 동등한 RWO 프로비저너 +- `kubernetes.io/metadata.name` namespace 라벨이 자동으로 붙는 1.22+ 환경 +- dev 클러스터의 K3s 기본 Traefik 이 `kube-system` namespace 에 존재해야 함 + +--- + +## 2. 최초 부트스트랩 — 자동 + +> **예상 소요**: 10~15 분 (이미지 pull 시간 포함). Phase 6 의 시크릿 입력이 가장 오래 걸림. + +### 대화형 실행 (권장) + +Phase 6 에서 앱 시크릿 5 개 비밀번호를 무음 입력. bash history 에 남지 않는다. + +```bash +bash k8s/scripts/bin/bootstrap.sh dev +# Phase 6 진행 중: +# Postgres superuser 비밀번호: ******* +# Postgres superuser 비밀번호 한 번 더: ******* +# Keycloak DB 비밀번호: ******* +# ... (5 개 시크릿, 각각 확인 재입력 포함) +``` + +### 비대화 (CI) 실행 + +```bash +HISTFILE=/dev/null \ +POSTGRES_SUPERUSER_PASSWORD='...' \ +KEYCLOAK_DB_PASSWORD='...' \ +AUTH_SERVER_DB_PASSWORD='...' \ +KEYCLOAK_ADMIN_PASSWORD='...' \ +MINIO_ROOT_PASSWORD='...' \ +bash k8s/scripts/bin/bootstrap.sh dev + +# 또는 비대화 + 랜덤 생성 (운영자가 값을 몰라도 됨, Vault 에서 나중에 조회): +AUTO_GENERATE=yes bash k8s/scripts/bin/bootstrap.sh dev +``` + +### 실행 단계 + +`bin/bootstrap.sh` 가 9 단계 (Phase 0~8) 를 순서대로 실행한다: + +| Phase | 내용 | +|:---:|---| +| 0 | MinIO Operator 설치 (`tasks/minio-operator-install.sh`) — Tenant CRD 선행 등록 (별도 namespace `minio-operator`) | +| 1 | Namespace + PSS 라벨 (`kubectl apply -k k8s/base/managing/namespace`) — 먼저 적용해 의존성 안정화 | +| 2 | 기존 Secret 점검 — VSO-managed K8s Secret 5 개 중 이미 존재하는 것 탐지. `RESET_STALE_SECRETS=yes` 면 삭제 | +| 3 | 인프라 리소스 배포 (`kubectl apply -k k8s/overlays/dev/`) — vault + registry + 앱 워크로드 | +| 4 | vault-0 Running 대기 — Ready 가 아니라 **Running**. Vault readiness probe 는 초기화+unseal 후에만 통과하므로 | +| 5 | Vault 초기화 (`tasks/vault-init.sh`) — init / unseal / KV v2 / k8s auth / policy × 2 / role × 2 | +| 6 | 앱 시크릿 seed (`tasks/vault-seed-apps.sh`) — 5 개 시크릿 대화형 입력 (이미 있으면 skip) | +| 7 | VSO Helm (`tasks/vso-install.sh`) — 기존 dirty 릴리즈 자동 uninstall + `helm upgrade --install --wait` | +| 8 | VSO CRDs (`kubectl apply -k k8s/overlays/dev/vso/`) | + +> **참고**: 스크립트는 idempotent 다. 이미 진행된 단계는 자동 스킵된다. + +> **주의**: 현재 `bootstrap.sh` 는 `mnt` namespace 자원까지만 자동 배포한다. `k8s/overlays/dev/platform/traefik/` 와 `k8s/overlays/dev/tls/` 는 namespace 가 다르거나 optional dependency 가 있어 운영자가 별도로 적용한다 (§4, §5). +> +> `k8s/overlays/dev/platform/cert-manager/`, `k8s/overlays/dev/platform/keycloak-operator/`, `k8s/overlays/dev/keycloak-realm/` 은 CRD / 외부 DNS / 인증 흐름 의존성이 있어 자동 부트스트랩 전에 선행/별도 확인이 필요하다 (§5, §6). + +### 생성되는 파일 + +- `vault-init-keys.json` — **unseal keys (5 개) + root token**. 권한 0600 으로 저장. + +> **주의**: 이 파일은 **반드시 오프라인 금고 / 외부 KMS 로 이동** 하고 원본은 삭제한다. `.gitignore` 에 등록되어 있으나 실수로도 커밋하지 말 것. + +### 완료 확인 + +```bash +kubectl -n mnt get pods +kubectl -n mnt get secrets | grep -E 'identity-postgres-superuser|keycloak-db|auth-server-db|keycloak-bootstrap-admin|minio-tenant-env' +kubectl -n mnt get vaultstaticsecret +``` + +기대 상태: 모든 Pod `Running 1/1`, K8s Secret 5 개 존재, VaultStaticSecret `Status: Synced`. + +--- + +## 3. 최초 부트스트랩 — 수동 (단계별) + +> **예상 소요**: 자동과 동일하나 학습 시 +20~30 분. + +자동 스크립트가 중간에 실패했을 때, 또는 학습 목적으로 단계별 진행이 필요할 때. + +### 3-1. MinIO Operator 설치 + +```bash +REPO_ROOT="$(pwd)" bash k8s/scripts/tasks/minio-operator-install.sh +``` + +### 3-2. 인프라 배포 + +```bash +kubectl apply -k k8s/base/managing/namespace # namespace 선행 +kubectl apply -k k8s/overlays/dev/ +``` + +`mnt` namespace 와 Vault / Registry / 앱 워크로드가 선언된다. + +> **참고**: Registry 는 MinIO S3 자격증명 Secret 이 주입되기 전까지 대기할 수 있다. Postgres / Keycloak / auth-server 도 Vault secret 이 주입되기 전까지 `ContainerCreating` 으로 대기한다 (정상). + +### 3-3. Vault Pod Running 대기 + +> **참고**: Vault 는 초기화 전에는 Ready 가 될 수 없으므로 Running 까지만 기다린다 (§15.10 참고). + +```bash +kubectl -n mnt wait --for=jsonpath='{.status.phase}'=Running pod/vault-0 --timeout=120s +``` + +### 3-4. Vault 초기화 + +```bash +REPO_ROOT="$(pwd)" bash k8s/scripts/tasks/vault-init.sh +``` + +이 스크립트가 수행하는 것: +- `vault operator init -key-shares=5 -key-threshold=3` (이미 초기화되었으면 스킵) +- `vault-init-keys.json` 생성 (권한 0600) +- Sealed 상태면 자동 unseal +- root token 으로 로그인 (stdin 파이프 — stdout 에 안 찍힘) +- `secret/` 에 KV v2 활성화 (idempotent) +- `kubernetes` auth method 활성화 + `kubernetes_ca_cert` + `token_reviewer_jwt` 설정 (idempotent) +- `vso-auth-platform` / `vso-storage` policy × 2 작성 (항상 재적용) +- `vso-auth-platform` / `vso-storage` k8s auth role × 2 작성 (항상 재적용) + +### 3-5. VSO Helm 설치 + +```bash +REPO_ROOT="$(pwd)" bash k8s/scripts/tasks/vso-install.sh +``` + +`helm upgrade --install --wait --timeout 5m` 으로 실행. 시작 시 기존 릴리즈가 `failed`/`pending*`/`uninstalling` 상태면 자동 uninstall 후 재설치 (§15.11). `--values` 는 `k8s/base/plugins/vso/helm/values.yaml`. + +### 3-6. VSO CRDs 적용 + +```bash +kubectl apply -k k8s/overlays/dev/vso/ +``` + +`VaultConnection` / `VaultAuth` × 2 가 등록된다. `VaultStaticSecret` 은 dev overlay 각 서브디렉토리 (`database/`, `keycloak/`, `storage/`) 에서 이미 함께 적용됨. VSO Operator 가 Vault KV 를 읽어 K8s Secret 을 합성. + +> **주의**: 해당 Vault KV 경로에 값이 실제로 있어야 성공. 아직 없으면 VSO 가 permission denied 또는 not found 로 남음. §7 의 수동 주입 후 자동 재시도. + +--- + +## 4. Traefik / Ingress 운영 + +> **예상 소요**: 30 초~1 분 (apply 만). + +### 왜 별도 overlay 인가 + +`k8s/overlays/dev/kustomization.yaml` 은 `namespace: mnt` 를 전역으로 주입한다. 반면 K3s 기본 Traefik 은 실제로 `kube-system` 에 존재한다. 그래서 Traefik 운영 리소스는 같은 kustomization 안에 섞지 않고 `k8s/overlays/dev/platform/traefik/` 로 분리했다. + +### 적용 대상 + +| overlay | namespace | 설명 | +|---|---|---| +| `k8s/overlays/dev/platform/traefik` | `kube-system` | `HelmChartConfig` + `Middleware` + `TLSOption` | +| `k8s/overlays/dev/` | `mnt` | `auth-server`, `keycloak` 의 app Ingress 및 app NetworkPolicy | + +### Traefik 운영 overlay 적용 + +```bash +kubectl apply -k k8s/overlays/dev/platform/traefik +``` + +포함되는 것: + +- `HelmChartConfig/traefik` — `replicas=2`, `ingressClass=traefik`, HTTP→HTTPS redirect, metrics 활성화 +- `Middleware/security-headers` — HSTS, `X-Content-Type-Options`, frame deny 등 공용 헤더 +- `TLSOption/modern-tls` — TLS 1.2+, strict SNI, 허용 cipher suite + +### 앱 Ingress 현재 상태 + +| 리소스 | host | 공개 범위 | +|---|---|---| +| `auth-server` | `project.com` | `/` | +| `keycloak-public` | `keycloak.dev.example.com` | `/realms/`, `/resources/`, `/.well-known/`, `/js/` | + +app Pod 는 기본 deny 상태이므로, Traefik 에서 들어오는 8080/TCP 만 NetworkPolicy 로 별도 허용한다. + +### ForwardAuth variant 적용 + +repo 에는 component `k8s/components/forward-auth/` 가 준비되어 있고, 현재 `k8s/overlays/dev/` 가 이 component 를 직접 포함한다. 따라서 dev overlay 를 적용하면 oauth2-proxy 와 `auth-server` 보호 middleware 도 함께 렌더링된다. + +적용 전제: + +1. `k8s/overlays/dev/keycloak-realm/` 또는 동등한 방법으로 `platform` realm + `auth-server-ingress` client 준비 (§6) +2. redirect URI 를 `https://project.com/oauth2/callback` 로 등록 +3. Vault path `secret/oauth2-proxy/forward-auth` 에 아래 key 저장 (§7) + - `client-secret` + - `cookie-secret` +4. `project.com`, `keycloak.dev.example.com` 이 실제 Traefik 진입점으로 해석 + +적용: + +```bash +kubectl apply -k k8s/overlays/dev +``` + +이 overlay 가 추가하는 것: + +- `oauth2-proxy` Deployment / Service +- `project.com/oauth2/` 경로용 Ingress +- `oauth2-proxy-auth` Traefik `Middleware` +- `auth-server` Ingress patch — `project.com/` 요청은 oauth2-proxy ForwardAuth 를 먼저 통과해야 함 + +> **주의**: 현재 dev 용 oauth2-proxy 설정은 `ssl_insecure_skip_verify=false` 이다. 따라서 `keycloak.dev.example.com` 인증서 체인이 정상이어야 로그인 흐름이 끝까지 진행된다. + +> **참고**: dev 운영 완료까지 남은 항목 (DNS / ACME 인증서 / realm 적용 / negative test 등) 은 [README Limitations](README.md#limitations-honest-scope) 참고. + +--- + +## 5. TLS / cert-manager 적용 + +> **예상 소요**: 설치 3~5 분 + 외부 DNS 의존 (실제 발급은 DNS 가 Traefik 진입점을 가리켜야 가능). + +cert-manager 는 repo source of truth 로 편입되어 있다. dev 기준 설치 overlay 는 `k8s/overlays/dev/platform/cert-manager/` 이며, 공식 static install `v1.20.2` 를 적용한다. `ClusterIssuer` 는 CRD 등록 이후 `k8s/overlays/dev/platform/cert-manager-issuers/` 로 별도 적용한다. + +### 적용 + +```bash +kubectl apply -k k8s/overlays/dev/platform/cert-manager +kubectl -n cert-manager rollout status deploy/cert-manager --timeout=180s +kubectl -n cert-manager rollout status deploy/cert-manager-webhook --timeout=180s +kubectl -n cert-manager rollout status deploy/cert-manager-cainjector --timeout=180s +kubectl apply -k k8s/overlays/dev/platform/cert-manager-issuers +``` + +> **주의**: +> - `letsencrypt-prod-clusterissuer.yaml` / `letsencrypt-staging-clusterissuer.yaml` 의 `admin@project.com` 은 실제 수신 가능한 운영 메일로 교체한다. +> - HTTP-01 은 `project.com`, `keycloak.dev.example.com` 이 Traefik 외부 진입점으로 해석되고 80/443 이 도달 가능해야 성공한다. + +### 준비된 Certificate 리소스 + +| 파일 | secretName | host | +|---|---|---| +| `k8s/overlays/dev/tls/project-com-certificate.yaml` | `project-com-tls` | `project.com` | +| `k8s/overlays/dev/tls/keycloak-dev-certificate.yaml` | `keycloak-dev-example-com-tls` | `keycloak.dev.example.com` | + +### Certificate 적용 + +전제: + +- `cert-manager` CRD 설치 완료 +- `ClusterIssuer/letsencrypt-prod` 또는 동등한 issuer 준비 +- DNS 가 실제 Traefik 진입점으로 향함 + +```bash +kubectl apply -k k8s/overlays/dev/tls +``` + +### 완료 확인 + +```bash +kubectl -n mnt get certificate +kubectl -n mnt describe certificate project-com # Status.Conditions.Ready=True +``` + +> **권장**: 인증서 발급 상태를 먼저 확인한 뒤 ForwardAuth E2E 검증을 진행한다 (§4). + +--- + +## 6. Keycloak Operator / RealmImport 적용 + +> **예상 소요**: 5~10 분. + +Keycloak 은 권장 흐름에 맞춰 Operator 기반으로 전환한다. dev 제약상 실제 Keycloak 인스턴스와 realm/client 는 `mnt` 에 두며, Keycloak Operator 도 `mnt` 에 설치해 해당 namespace 를 watch 하게 한다. + +> **참고**: `mnt` 는 default-deny egress namespace 이므로, `k8s/overlays/dev/platform/keycloak-operator/networkpolicy.yaml` 이 Operator Pod 에서 Kubernetes API 로 나가는 443/6443 만 허용한다. 이 정책이 없으면 Operator informer 가 API server 에 연결하지 못해 CrashLoopBackOff 로 떨어진다. + +### 왜 필요한가 + +- `oauth2-proxy` 는 `auth-server-ingress` client 를 전제로 동작한다 +- client / redirect URI 같은 OIDC 계약은 Git 에서 관리되어야 drift 가 줄어든다 +- 표준도 Keycloak realm 을 `KeycloakRealmImport` 로 선언형 관리하라고 권장한다 + +### 적용 순서 + +**1. Keycloak Operator CRD / controller 적용** + +```bash +kubectl apply -k k8s/overlays/dev/platform/keycloak-operator +kubectl -n mnt rollout status deploy/keycloak-operator --timeout=180s +``` + +**2. 기존 수제 Keycloak 리소스 정리** + +기존 `Deployment` 기반 Keycloak 과 Operator 기반 Keycloak 이 같은 `Service/keycloak` 이름을 쓰므로, 전환 시 기존 수제 리소스를 정리한다. + +```bash +kubectl -n mnt delete deployment/keycloak service/keycloak configmap/keycloak-config serviceaccount/keycloak-sa --ignore-not-found +``` + +**3. dev overlay 적용** + +`k8s/overlays/dev/keycloak/` 는 이제 `Keycloak` CR, VSO secret 변환, Ingress, NetworkPolicy 를 포함한다. + +```bash +kubectl apply -k k8s/overlays/dev +kubectl -n mnt get keycloak keycloak +kubectl -n mnt get pods -l app.kubernetes.io/instance=keycloak +``` + +**4. RealmImport 적용** + +```bash +kubectl apply -k k8s/overlays/dev/keycloak-realm +kubectl -n mnt get keycloakrealmimport platform-realm +``` + +### client secret 처리 + +> **주의**: `auth-server-ingress` 같은 confidential client 의 secret 값 자체는 Git 에 넣지 않는다. realm/client shape 는 Git 에 두고, secret 값은 생성 후 Vault path `secret/oauth2-proxy/forward-auth` 로 넣어 oauth2-proxy 가 소비하게 한다 (§7). + +--- + +## 7. Vault 시크릿 관리 + +> **예상 소요**: 회당 1~2 분 (port-forward + put). + +### 애플리케이션 시크릿 저장 — 자동화됨 + +5 개 시크릿은 `bin/bootstrap.sh` Phase 6 에서 자동으로 seed 된다. 또는 단독 실행: + +```bash +REPO_ROOT="$(pwd)" bash k8s/scripts/tasks/vault-seed-apps.sh +``` + +동작: +- **이미 있는 경로는 skip** — 운영자가 회전한 값을 덮어쓰지 않음 +- **대화형 입력** (TTY) — `read -r -s` 로 무음 입력 + 확인 재입력. bash history 에 남지 않음 +- **비대화 + env 지정** — 해당 env var 를 사용 (`HISTFILE=/dev/null` 접두 권장) +- **비대화 + env 없음 + `AUTO_GENERATE=yes`** — `openssl rand` 로 랜덤 24 자 생성 + +> **참고**: Vault CLI 의 `vault kv put <path> -` 모드로 **JSON stdin 전달** 이라 비밀번호가 argv / process table 어디에도 노출되지 않는다. + +### 시크릿 경로 및 키 + +| 경로 | 키 | 용도 | +|---|---|---| +| `secret/identity-postgres/superuser` | `username` (기본 postgres), `password` | Postgres 슈퍼유저 (`POSTGRES_USER_FILE` / `POSTGRES_PASSWORD_FILE`) | +| `secret/keycloak/db` | `password` | Keycloak 의 DB 비밀번호 + initdb 가 생성하는 keycloak DB role | +| `secret/auth-server/db` | `SPRING_DATASOURCE_USERNAME` (기본 auth_server), `SPRING_DATASOURCE_PASSWORD` | Spring Boot configtree + Flyway | +| `secret/keycloak/bootstrap-admin` | `KEYCLOAK_ADMIN` (기본 admin), `KEYCLOAK_ADMIN_PASSWORD` | Keycloak 초기 관리자 계정 | +| `secret/minio/tenant-env` | `config.env` (env-file 포맷 단일 키) | MinIO Operator Tenant 루트 자격증명 | +| `secret/oauth2-proxy/forward-auth` | `client_secret`, `cookie_secret` | dev overlay 의 oauth2-proxy confidential client / session cookie | +| `secret/docker-registry/basic-auth` | `username`, `password`, `users` (htpasswd 한 줄) | Traefik Middleware 가 외부 push 시 검증 | + +### 값 조회 + +```bash +kubectl -n mnt port-forward svc/vault 8200:8200 & +export VAULT_ADDR=http://127.0.0.1:8200 +vault login -method=userpass username=alice # userpass admin 권장 — root token 사용 중단 + +vault kv get secret/keycloak/bootstrap-admin +# 특정 field 만: +vault kv get -field=KEYCLOAK_ADMIN_PASSWORD secret/keycloak/bootstrap-admin +``` + +> **참고**: userpass admin 셋업은 [docs/security-hardening.md §2](docs/security-hardening.md#2-vault-운영자-토큰-관리) 참고. + +### 값 변경 (비밀번호 교체) + +```bash +# 새 값으로 덮어쓰기 (vault kv put) — seed-apps.sh 의 skip 로직 우회 +vault kv put secret/keycloak/db password='<새-pw>' + +# 60s ~ 1h 내 VSO 가 자동으로 K8s Secret 갱신. 즉시 반영 원하면: +kubectl -n mnt delete secret keycloak-db +# VSO 가 Vault KV 를 읽어 재생성 +``` + +> **Tip**: 강제 즉시 반영의 다른 방법 — `kubectl -n mnt annotate vaultstaticsecret <name> refresh=$(date +%s) --overwrite`. + +> **주의 — MinIO 비밀번호에 `"` 금지**: MinIO 의 `config.env` 는 env-file 포맷 (`export KEY="value"`) 이라 값에 `"` 가 들어가면 파싱 깨짐. `vault-seed-apps.sh` 가 프롬프트에서 거부하며 재입력 요구한다. + +### root token 회전 + +운영 정책 / userpass admin 셋업 절차는 [docs/security-hardening.md §2](docs/security-hardening.md#2-vault-운영자-토큰-관리) 참고. + +--- + +## 8. Docker Registry 사용법 + +> **예상 소요**: push/pull 회당 < 1 분 (이미지 크기 의존). + +### Push + +```bash +docker login registry.project.com +# username: <DOCKER_REGISTRY_PUSH_USERNAME, 기본 registry-push> +# password: <Vault 에 저장한 값> + +docker tag my-app:0.1.0 registry.project.com/my-app:0.1.0 +docker push registry.project.com/my-app:0.1.0 +``` + +외부 push 는 `registry.project.com` Ingress 로 들어오며 Traefik BasicAuth 를 통과해야 한다. BasicAuth 의 htpasswd `users` 값은 Vault path `secret/docker-registry/basic-auth` 에 저장되고 VSO 가 `docker-registry-basic-auth` Secret 으로 동기화한다. + +> **주의**: 실제 워크로드 이미지는 `registry.project.com/...` 주소를 사용한다. image pull 은 Pod 내부가 아니라 노드의 kubelet/containerd 가 수행하므로, `docker-registry.mnt.svc.cluster.local` 같은 ClusterIP DNS 를 `image:` 에 쓰는 방식은 피한다. + +### 내부 Service 직접 접근 (debugging) + +내부 Service 는 registry Pod 자체 확인이나 클러스터 내부 HTTP 접근이 필요할 때만 사용한다. + +```bash +docker tag my-app:0.1.0 docker-registry.mnt.svc.cluster.local:5000/my-app:0.1.0 +docker push docker-registry.mnt.svc.cluster.local:5000/my-app:0.1.0 +``` + +### Pull (Pod) + +```yaml +apiVersion: apps/v1 +kind: Deployment +spec: + template: + spec: + serviceAccountName: auth-server-sa + containers: + - name: my-app + image: registry.project.com/my-app:0.1.0 +``` + +`auth-server-sa` / `test-server-*-sa` 는 dev overlay 에서 `docker-registry-pull-credentials` 를 `imagePullSecrets` 로 참조한다. + +### 완료 확인 + +```bash +curl -fsS -u <push-user>:<push-pw> https://registry.project.com/v2/my-app/tags/list +# 정상 응답: {"name":"my-app","tags":["0.1.0"]} + +kubectl -n mnt get secret docker-registry-pull-credentials -o jsonpath='{.type}{"\n"}' +# 정상 응답: kubernetes.io/dockerconfigjson +``` + +> **참고**: dev 환경에서 외부 DNS / TLS 가 아직 준비 전이면 노드의 containerd 에 이미지를 직접 import 하는 임시 우회가 필요할 수 있다. 정상 운영 (DNS + cert-manager 인증서 발급 완료) 에서는 위 push/pull 만으로 충분하다. + +--- + +## 9. 앱에서 시크릿 사용하기 + +### envFrom (권장) + +```yaml +spec: + containers: + - name: app + envFrom: + - secretRef: + name: auth-server-db # VSO 가 dev overlay 에서 합성 +``` + +### 개별 key + +```yaml +env: + - name: POSTGRES_PASSWORD + valueFrom: + secretKeyRef: + name: identity-postgres-superuser + key: password +``` + +### volume mount + +```yaml +volumes: + - name: db-creds + secret: + secretName: auth-server-db +containers: + - volumeMounts: + - name: db-creds + mountPath: /etc/secrets + readOnly: true +``` + +--- + +## 10. 마이그레이션 실행 (Flyway) + +> **예상 소요**: 30 초~3 분 (마이그레이션 갯수 의존). + +base 에 정의된 `migration-flyway` Job 은 기본적으로는 배포되지 않는다. dev overlay 가 ArgoCD PreSync / sync-wave=-1 annotation 을 patch 하므로, GitOps 로 배포할 때는 ArgoCD 가 앱보다 먼저 Job 을 실행한다. + +> **주의**: `kubectl apply -k` 로 수동 배포 시에는 Job 이 **앱과 동시에** 생성되므로 race 가능. 아래 순서대로 실행한다. + +```bash +# migration 먼저 +kubectl apply -k k8s/overlays/dev/auth/ -l app.kubernetes.io/component=migration + +# 완료 대기 +kubectl -n mnt wait --for=condition=complete job/migration-flyway --timeout=300s + +# 앱 배포 +kubectl apply -k k8s/overlays/dev/ +``` + +ArgoCD 를 쓰면 이 순서가 sync-wave 로 자동화된다. + +### 재실행 + +Flyway Job 은 `backoffLimit: 0` 으로 한 번만 실행된다. 재실행하려면: + +```bash +kubectl -n mnt delete job migration-flyway +kubectl apply -k k8s/overlays/dev/auth/ +``` + +--- + +## 11. Vault UI 접근 + +> **참고**: `service-ui` NodePort 는 보안상 제거되었다. 관리자는 port-forward 로만 접근한다. + +```bash +kubectl -n mnt port-forward svc/vault 8200:8200 +# 브라우저에서 http://127.0.0.1:8200/ui +# Token 입력: $(jq -r .root_token vault-init-keys.json) +``` + +> **권장**: root token 은 최초 설정 / 비상 복구 외에는 사용하지 않는다. 평시 접근은 개인별 userpass / OIDC auth 로 분리한다 — [docs/security-hardening.md §2](docs/security-hardening.md#2-vault-운영자-토큰-관리) 참고. + +--- + +## 12. 환경별 배포 + +현재 `dev` 만 구성되어 있다. + +```bash +bash k8s/scripts/bin/bootstrap.sh dev +``` + +> **참고**: staging / prod overlay 는 비어 있으며, 추후 다음 요소를 추가한다 — Vault storage `file` → `raft` 전환, Postgres backup CronJob, cert-manager ClusterIssuer + Certificate, 환경별 hostname (Keycloak / 공개 Ingress), `persistentVolumeClaimRetentionPolicy` 를 prod 는 `Retain` 유지 (dev 는 overlay 에서 `Delete` 로 patch). + +환경별 차등표는 [docs/operations.md](docs/operations.md#환경별-배포) 참고. + +--- + +## 13. 검증 / 린트 / 스키마 체크 + +> **예상 소요**: 1~3 분 (kubeconform 원격 스키마 조회). + +```bash +bash k8s/scripts/ci/validate.sh +``` + +출력: + +``` +k8s/overlays/dev build=ok schema=ok lint=ok +k8s/overlays/dev/vso build=ok schema=ok lint=ok +모든 overlay 통과 +``` + +- **build**: `kustomize build` (환경 중립성 / patch / labels) +- **schema**: `kubeconform -strict -ignore-missing-schemas` (K8s OpenAPI + Datree CRD catalog 원격 조회) +- **lint**: `kube-linter lint --config .kube-linter.yaml` (securityContext / 리소스 요구사항 / PSS / image tag 등) + +> **참고**: `kustomization.yaml` 이 없는 overlay (`staging`, `prod`) 는 자동 스킵된다. + +CI 파이프라인에서 이 스크립트를 PR 게이트로 사용한다. 실패 시 `build=fail|schema=fail|lint=fail` 로 표기되고 상세 에러가 stderr 에 출력된다. + +--- + +## 14. 정리 / 롤백 (teardown) + +> **예상 소요**: 2~5 분 (PVC 보호 finalizer 정리 + namespace 종료). + +```bash +# 대화형 (y/N 확인) +bash k8s/scripts/bin/teardown.sh dev + +# 비대화 (CI) +CONFIRM=yes bash k8s/scripts/bin/teardown.sh dev + +# MinIO Operator 까지 제거 (기본은 유지) +TEARDOWN_MINIO_OPERATOR=yes bash k8s/scripts/bin/teardown.sh dev +``` + +체계적 7 단계: + +| 단계 | 내용 | +|:---:|---| +| 1 | Precheck — namespace 존재 여부 + phase 확인. 일부 단계는 없으면 skip | +| 2 | VSO CRD 삭제 — `kubectl delete -k overlays/<env>/vso/` 60s timeout. 타임아웃 시 `VaultStaticSecret / VaultAuth / VaultConnection` finalizer 강제 해제 | +| 3 | VSO Helm uninstall — `helm uninstall --wait 5m` (Operator 제거) | +| 4 | 인프라 overlay 삭제 — `kubectl delete -k overlays/<env>/` 120s timeout | +| 5 | namespace 잔존 리소스 finalizer 정리 — PVC 보호 finalizer + VSO CRD + 전체 namespaced 리소스 일괄 finalizer 제거 | +| 6 | namespace 삭제 + Terminating 감지 — `kubectl delete namespace` 60s 대기 → 실패 시 `/finalize` API 호출로 강제 종료 | +| 7 | Cluster-scoped 정리 — `vault-tokenreview-binding` ClusterRoleBinding 제거. `TEARDOWN_MINIO_OPERATOR=yes` 면 MinIO Operator 도 함께 | + +> **참고**: controller 없이 남은 CRD finalizer, PVC 보호 finalizer, 전체 namespaced 리소스 finalizer 를 단계별로 선제 해제해서 namespace 가 Terminating 에 걸리지 않도록 처리. 이미 Terminating 에 걸려 있어도 단계 6 에서 `/finalize` API 직접 호출로 강제 종료. + +> **주의**: teardown 후에도 `vault-init-keys.json` 은 보존된다. 완전 초기화하려면 수동으로 삭제한다. + +### 강제 종료의 부작용 + +> **주의**: 단계 6 의 `/finalize` 는 orphan 리소스 (PV / PVC 바인딩) 를 남길 수 있다. + +```bash +# teardown 후 orphan PV 검사 +kubectl get pv | grep -E 'Released|Failed' + +# 필요시 수동 삭제 +kubectl delete pv <name> +``` + +--- + +## 15. 트러블슈팅 + +> **참고**: 보안 정책 / 거버넌스 (etcd 암호화 / Vault root token / bash history) 는 [docs/security-hardening.md](docs/security-hardening.md) 참고. + +### 15.1 Registry Pod 가 계속 `ContainerCreating` + +Secret `docker-registry-minio` 또는 `docker-registry-basic-auth` 가 아직 생성되지 않은 상태일 수 있다. VSO 가 Vault KV 를 읽어서 만든다. + +```bash +kubectl -n mnt describe vaultstaticsecret docker-registry-minio +kubectl -n mnt describe vaultstaticsecret docker-registry-basic-auth +kubectl -n mnt logs -l app.kubernetes.io/name=vault-secrets-operator --tail=100 +``` + +자주 보는 에러: +- `permission denied` → Vault policy 또는 role 설정 오류. `tasks/vault-init.sh` 재실행. +- `no matching vault path` → Vault KV 에 값이 저장되지 않음. `tasks/vault-seed-apps.sh` 재실행. + +### 15.2 VSO 가 Vault 에 로그인 실패 + +```bash +kubectl -n mnt logs -l app.kubernetes.io/name=vault-secrets-operator --tail=200 | grep -i error +``` + +체크 항목: +- Vault ClusterRoleBinding `vault-tokenreview-binding` 존재? `kubectl get clusterrolebinding vault-tokenreview-binding` +- Vault ServiceAccount 에 token 자동 마운트 되어 있음? (기본값 true) +- `vault auth/kubernetes/config` 에 `kubernetes_ca_cert` + `token_reviewer_jwt` 설정됨? → 없으면 `tasks/vault-init.sh` 재실행 +- VaultConnection address 가 `http://vault:8200` 이고 같은 namespace 에 실제 `vault` Service 존재? + +### 15.3 `vault operator init` 실패 — 이미 초기화됨 + +> **참고**: 정상. `tasks/vault-init.sh` 는 idempotent 하게 이 경우를 스킵하고 unseal 만 다시 수행한다. `kubectl exec vault-0 -- vault status` 로 상태 확인. + +### 15.4 부트스트랩 중단 → 재시작 + +```bash +bash k8s/scripts/bin/bootstrap.sh dev +``` + +각 단계가 idempotent 이므로 그대로 다시 실행해도 된다. 이미 완료된 단계는 스킵된다. + +> **참고**: Phase 6 의 시크릿 입력 시, Vault KV 에 이미 있으면 스킵되므로 비밀번호는 사용되지 않음. + +### 15.5 Vault UI 가 안 열림 + +NodePort 는 제거되었다. port-forward 를 사용한다: + +```bash +kubectl -n mnt port-forward svc/vault 8200:8200 +``` + +`http://127.0.0.1:8200/ui` 로 접근. + +### 15.6 NetworkPolicy 로 트래픽 차단 의심 + +```bash +# 모든 NetworkPolicy 확인 +kubectl -n mnt get networkpolicy + +# 특정 Pod 에 적용된 정책 확인 +kubectl -n mnt describe pod <pod-name> | grep -A3 Labels +kubectl -n mnt get networkpolicy -o yaml | grep -A2 podSelector +``` + +임시 허용 (디버깅): + +```bash +kubectl -n mnt delete networkpolicy default-deny-all +``` + +> **주의**: 진단 완료 후 반드시 복구 — `kubectl apply -k k8s/overlays/dev/`. + +### 15.7 PodSecurity 위반 경고 + +`kubectl apply` 중 `Warning: would violate PodSecurity "restricted:latest": ...` 메시지가 뜨면 **어떤 Pod 의 어떤 필드** 가 위반인지 확인: + +```bash +# 최근 이벤트 +kubectl -n mnt get events --sort-by='.lastTimestamp' \ + | grep -i 'podsecurity\|FailedCreate' + +# 경고 메시지는 apply 시 stderr 로도 나옴 +kubectl apply -k k8s/overlays/dev/ 2>&1 | grep -i warning +``` + +자주 걸리는 항목 체크리스트: + +- `runAsNonRoot: true` 누락 또는 `runAsUser: 0` +- `allowPrivilegeEscalation: false` 누락 +- `capabilities.drop: [ALL]` 누락 +- `seccompProfile.type: RuntimeDefault` 누락 +- `readOnlyRootFilesystem: true` 누락 (선택이지만 권장) +- hostPath / hostNetwork / hostPID / hostIPC 사용 +- hostPorts 사용 + +> **참고**: base 의 모든 워크로드는 이미 Restricted 통과. 경고가 뜨는 건 보통 다음 두 가지 — VSO Operator Helm chart, MinIO Operator Helm chart. 둘 다 자기 namespace 에서 돌아가므로 `mnt` 의 PSS 와 무관. `mnt` 안의 Pod 에서 경고가 나면 매니페스트를 수정해야 함. + +### 15.8 기존 K8s Secret 이 남아있을 때 + +VSO 는 `destination.overwrite: false` 기본값이라 **이미 존재하는 Secret 을 덮어쓰지 않는다**. Vault KV 에 새 값을 넣어도 K8s Secret 은 옛날 값을 유지. + +확인: + +```bash +kubectl -n mnt get secret -l 'kubernetes.io/managed-by!=Helm' \ + -o custom-columns=NAME:.metadata.name,AGE:.metadata.creationTimestamp +``` + +해결 1 — 개별 삭제 후 VSO 재생성: + +```bash +kubectl -n mnt delete secret docker-registry-minio docker-registry-basic-auth +# VSO 가 1-2 분 내 Vault KV 에서 읽어 재생성 +kubectl -n mnt get vaultstaticsecret +``` + +해결 2 — bootstrap 재실행 시 자동 정리: + +```bash +RESET_STALE_SECRETS=yes bash k8s/scripts/bin/bootstrap.sh dev +# Phase 2 에서 VSO-managed Secret 7 개 전부 삭제 → Phase 7/8 에서 VSO 재생성 +``` + +> **주의**: 이 옵션은 **destructive**. 운영자가 명시적으로 지정했을 때만 동작. + +### 15.9 namespace 가 Terminating 에 걸림 + +`mnt` namespace 가 `Terminating` 에서 오래 멈추면 보통 다음 셋 중 하나다. + +- controller 가 이미 사라졌는데 CRD finalizer 가 남아 있음 +- PVC protection finalizer 가 남아 있음 +- namespaced 리소스 일부가 finalizer 때문에 삭제 완료를 못 함 + +> **참고**: 현재 `teardown.sh` 는 이 상황을 고려해 단계적으로 정리한다 — `VaultStaticSecret / VaultAuth / VaultConnection` finalizer 제거 → PVC 보호 finalizer 제거 → 남은 namespaced 리소스 finalizer 일괄 제거 → 마지막에 namespace `/finalize` 호출. + +수동 확인: + +```bash +kubectl get namespace mnt -o yaml +kubectl api-resources --verbs=list --namespaced -o name | xargs -n 1 kubectl -n mnt get --ignore-not-found +``` + +이미 teardown 을 사용 중이라면 대부분은 스크립트가 자동 처리한다. 수동 개입은 정말 스크립트가 실패했을 때만 한다. + +### 15.10 vault-0 이 `0/1 Running` 에서 멈춤 + +**현상**: + +``` +NAME READY STATUS RESTARTS AGE +vault-0 0/1 Running 0 2m +``` + +계속 `0/1 Running`. `kubectl wait --for=condition=Ready` 가 timeout 으로 실패. + +**원인 — 의도된 동작**: + +Vault 의 readiness probe 는 `/v1/sys/health?sealedcode=503&uninitcode=503` 를 사용한다. 즉: +- **uninitialized** → HTTP 503 → readiness fail +- **sealed** → HTTP 503 → readiness fail +- **initialized + unsealed** → HTTP 200 → Ready + +이건 sealed Vault 가 Service Endpoints 에서 제외되어 트래픽이 흘러가지 않도록 하는 **보안 설계**. 초기화 전에는 구조상 Ready 가 될 수 없다. + +**해결 — `vault-init.sh` 실행**: + +```bash +# Pod 이 Running 이면 exec 가능 → 초기화 실행 가능 +REPO_ROOT="$(pwd)" bash k8s/scripts/tasks/vault-init.sh +``` + +수행되는 것: +1. `vault operator init` — unseal keys + root token 생성 +2. unseal 5 shares 중 3 개로 자동 unseal +3. root login → KV v2 + k8s auth + policy × 2 + role × 2 + +`vault-init.sh` 가 끝나고 몇 초 뒤 Pod 이 자동으로 Ready 로 전환: + +```bash +kubectl -n mnt get pod vault-0 +# vault-0 1/1 Running +``` + +**bootstrap.sh 가 Phase 4 에서 Ready 대기로 실패했을 때 — 재개**: + +```bash +# Phase 4 까지는 apply + Pod Running 완료 상태 +# 남은 Phase 5~8 만 수동 실행 +REPO_ROOT="$(pwd)" bash k8s/scripts/tasks/vault-init.sh # Phase 5 +REPO_ROOT="$(pwd)" bash k8s/scripts/tasks/vault-seed-apps.sh # Phase 6 +REPO_ROOT="$(pwd)" bash k8s/scripts/tasks/vso-install.sh # Phase 7 +kubectl apply -k k8s/overlays/dev/vso/ # Phase 8 +``` + +또는 bootstrap.sh 를 그냥 다시 실행해도 된다 (idempotent). + +> **참고 — 다른 Pod 들이 `ContainerCreating` 상태**: `auth-server`, `keycloak`, `identity-postgres`, `docker-registry`, `migration-flyway` 가 `ContainerCreating` 에 머무는 건 **VSO 가 만드는 K8s Secret 이 아직 없어서** volume mount 가 대기 중인 것. Vault 초기화 + 앱 secret 주입 (§7) + VSO sync 가 끝나면 차례로 Running 으로 전환된다. 정상 동작. + +> **참고 — `test-server-*` 가 `ImagePullBackOff`**: `registry.example.com/test-platform/test-server-*:0.1.0` 은 **예시 이미지** 로, 실제 레지스트리에 존재하지 않는다. 사용자가 실제 이미지를 빌드해서 내부 Registry 에 푸시해야 한다. 무시해도 된다. + +### 15.11 `helm upgrade` 가 `has no deployed releases` 로 실패 + +**현상** — bootstrap Phase 7 (VSO Helm) 에서: + +``` +Error: UPGRADE FAILED: "vault-secrets-operator" has no deployed releases +``` + +**원인**: + +이전 `helm upgrade --install` 시도가 `--atomic` 때문에 rollback 되며 릴리즈가 `failed` 또는 `uninstalled` 상태로 남음. Helm 이 metadata 는 보존하는데 실제 배포물은 없는 상태. 이 상태에선 `upgrade --install` 이 **upgrade 로 분기하려다 "deployed release 없음" 으로 실패**. + +**해결**: + +> **참고**: `tasks/vso-install.sh` 는 이제 실행 시 릴리즈 상태를 먼저 검사해서 `failed`/`pending*`/`uninstalling`/`uninstalled` 면 자동으로 `helm uninstall` 을 먼저 수행한다. 또한 `--atomic` 플래그를 제거했다 (실패 시 재실행으로 복구가 더 안전). + +구버전 스크립트로 이미 이 상태에 빠졌다면 수동 정리: + +```bash +helm -n mnt uninstall vault-secrets-operator +# (Error: uninstall: Release not loaded: ... 이 떠도 무시) + +bash k8s/scripts/bin/bootstrap.sh dev +# Phase 7 부터 깔끔하게 재개됨 +``` diff --git a/k8s/AGENTS.md b/k8s/AGENTS.md new file mode 100644 index 0000000..6e1ffdd --- /dev/null +++ b/k8s/AGENTS.md @@ -0,0 +1,26 @@ +# k8s AGENTS + +Role: +- own Kubernetes/K3s source-of-truth manifests and Kustomize composition +- keep base resources, environment overlays, helper scripts, and Vault Secrets Operator assets separate +- make render / validate / diff / apply possible from Git without relying on live cluster state + +Scope: +- `base/`: environment-neutral bases +- `overlays/<env>/`: environment-specific composition +- `scripts/`: helper automation +- `vso/`: Vault Secrets Operator assets + +Read first: +- `/docs/standards/infra/architecture-environments.md` +- `/docs/standards/infra/kustomize.md` +- `/docs/standards/infra/k3s-specific.md` +- `/docs/standards/infra/operations-runbook-upgrade-rollback.md` + +Rules: +- prefer `kubectl kustomize`, `kubectl diff -k`, and `kubectl apply -k` +- do not use server-local manifests as source of truth +- do not place environment differences in `base/` +- do not place base resource definitions directly in overlays unless the resource is environment-only by design +- do not commit production secret values +- keep scripts as helpers; manifests remain declarative source diff --git a/k8s/README.md b/k8s/README.md new file mode 100644 index 0000000..d6bb049 --- /dev/null +++ b/k8s/README.md @@ -0,0 +1,58 @@ +# Kubernetes Packaging + +This tree is organized for many independently owned workloads. + +Entry points: +- `overlays/<env>/` renders the whole environment skeleton. +- `overlays/<env>/<domain>/` renders one namespace/domain boundary. +- `overlays/<env>/managing/vault-secrets-operator/` is applied after the VSO CRDs are installed. +- `scripts/env/` contains environment bootstrap and teardown orchestration. +- `scripts/` contains imperative bootstrap and break-glass operations only. +- `vso/` contains Vault Secrets Operator installation lifecycle files. + +Scale rules: +- Do not place hundreds of workloads directly under one package. +- Add workloads under `base/app/units/<unit>/<domain>/<workload-kind>/<workload>/`. +- Add matching environment overrides under `overlays/<env>/app/units/<unit>/<domain>/<workload-kind>/<workload>/` only when that workload has environment-specific differences. +- A workload package owns its Kubernetes object files together, such as `deployment.yaml`, `service.yaml`, `configmap.yaml`, `cronjob.yaml`, `statefulset.yaml`, or `pvc.yaml`. +- Do not create kind-based package roots such as `deployments/`, `services/`, or `configmaps/`. + +Example for a 1000-workload organization: + +```text +base/app/units/ +├── commerce/ +│ ├── checkout/ +│ │ ├── services/order-api/ +│ │ ├── services/payment-api/ +│ │ ├── workers/payment-settlement-worker/ +│ │ ├── schedulers/cart-expiry-scheduler/ +│ │ └── stateful/orders-postgres/ +│ └── catalog/ +│ ├── services/catalog-api/ +│ ├── workers/search-index-worker/ +│ └── jobs/catalog-backfill-job/ +├── identity/ +│ ├── auth/ +│ │ ├── services/auth-api/ +│ │ ├── services/token-api/ +│ │ └── schedulers/token-cleanup-scheduler/ +│ └── profile/ +│ ├── services/profile-api/ +│ └── workers/profile-event-worker/ +├── media/ +│ ├── playback/ +│ │ ├── services/playback-api/ +│ │ └── workers/session-event-worker/ +│ └── recommendation/ +│ ├── services/recommendation-api/ +│ ├── workers/model-feature-worker/ +│ └── jobs/model-refresh-job/ +└── data/ + ├── ingestion/ + │ ├── workers/event-ingest-worker/ + │ └── stateful/ingest-kafka/ + └── analytics/ + ├── schedulers/daily-report-scheduler/ + └── jobs/monthly-rollup-job/ +``` diff --git a/k8s/base/AGENTS.md b/k8s/base/AGENTS.md new file mode 100644 index 0000000..6bbff16 --- /dev/null +++ b/k8s/base/AGENTS.md @@ -0,0 +1,38 @@ +# k8s/base AGENTS + +Role: +- own environment-neutral Kustomize base resources +- define reusable workload, namespace, service, storage, policy, and platform/plugin shapes +- keep environment-specific values out of base + +Scope: +- `app/`: application-facing base units +- `managing/`: management and operational base units +- `plugins/`: platform/plugin base resources + +Allowed: +- shared labels/selectors +- common workload shape +- common probe/resource shape +- common service/storage/policy shape +- unit composition through nested `kustomization.yaml` + +Forbidden: +- environment-specific hostnames +- production-only replicas/resources +- environment-specific secret values +- direct references to a specific cluster context +- overlay-only patches masquerading as base manifests + +Read first: +- `/docs/standards/infra/kustomize.md` +- `/docs/standards/infra/architecture-environments.md` +- `/docs/standards/infra/workload-selection.md` +- `/docs/standards/infra/config-and-secrets.md` +- `/docs/standards/infra/security-hardening.md` + +Rules: +- base must be reusable by dev, staging, and prod overlays +- base may define default shape, but overlays own environment differences +- unit ownership should be visible in path names +- large fleets should stay navigable by role first, then domain/unit diff --git a/k8s/base/README.md b/k8s/base/README.md new file mode 100644 index 0000000..5b2a3c3 --- /dev/null +++ b/k8s/base/README.md @@ -0,0 +1,13 @@ +# Base Packages + +Base packages contain shared Kubernetes definitions. + +Boundaries: +- `managing/` owns infrastructure management resources in the `mnt` namespace. +- `app/` owns application namespace resources and large-scale workload package contracts. +- `plugins/` owns plugin namespace resources. + +Rules: +- Base packages must not contain environment-specific values. +- A base package can be rendered with `kubectl kustomize` through its own `kustomization.yaml`. +- Parent packages compose child packages; child packages own their internal Kubernetes object files. diff --git a/k8s/base/app/AGENTS.md b/k8s/base/app/AGENTS.md new file mode 100644 index 0000000..9cc9956 --- /dev/null +++ b/k8s/base/app/AGENTS.md @@ -0,0 +1,69 @@ +# k8s/base/app AGENTS + +Role: +- own application-facing infrastructure base units +- define environment-neutral app workload, service, config reference, secret reference, storage, network policy, probe, and resource shapes +- organize many services by domain/service path directly under `k8s/base/app/<domain>/` + +Current structure: +- domains sit directly under `k8s/base/app/` (`identity/`, `storage/`, `test/`) +- inside each domain, separate by workload role (`stateful/`, `stateless/`) +- example: `identity/auth/stateful/identity-postgres/`, `identity/auth/stateless/auth-server/` + +Allowed: +- Kustomize base edits for application units +- Deployment / StatefulSet / Job / CronJob base resources +- Service / NetworkPolicy / PDB / HPA base resources +- ConfigMap / Secret reference wiring without secret values +- probe / resource / PVC / StorageClass reference shape +- component-specific manifests aligned with standards + +Forbidden: +- environment-specific values that belong in `k8s/overlays/<env>` +- editing packaged K3s component manifests directly +- treating server-local manifests as source of truth +- embedding production secret values in Git +- generating opaque YAML via script as the primary ownership path +- merging unrelated app rollout + migration + cluster upgrade into one hidden change + +Read first: +- `/docs/standards/infra/architecture-environments.md` +- `/docs/standards/infra/kustomize.md` +- `/docs/standards/infra/config-and-secrets.md` +- `/docs/standards/infra/workload-selection.md` +- `/docs/standards/infra/storage-pvc.md` +- `/docs/standards/infra/network-ingress-tls.md` +- `/docs/standards/infra/resources-probes-availability.md` +- `/docs/standards/infra/security-hardening.md` +- `/docs/standards/infra/observability-health.md` +- `/docs/standards/infra/db-and-migration.md` + +Component-specific routing: +- paths containing `keycloak` -> `/docs/standards/infra/keycloak.md` +- paths containing `vault` -> `/docs/standards/infra/vault.md` +- paths containing `minio` -> `/docs/standards/infra/minio.md` +- paths containing `flyway` or migration jobs -> `/docs/standards/infra/flyway.md` + +Examples: +- `/docs/examples/infra/kustomize.md` +- `/docs/examples/infra/config-and-secrets.md` +- `/docs/examples/infra/workload-selection.md` +- `/docs/examples/infra/storage-pvc.md` +- `/docs/examples/infra/network-ingress-tls.md` +- `/docs/examples/infra/resources-probes-availability.md` +- `/docs/examples/infra/security-hardening.md` +- `/docs/examples/infra/observability-health.md` +- `/docs/examples/infra/db-and-migration.md` +- `/docs/examples/infra/keycloak.md` +- `/docs/examples/infra/vault.md` +- `/docs/examples/infra/minio.md` +- `/docs/examples/infra/flyway.md` + +Rules: +- base must stay environment-neutral +- app services default to `ClusterIP` +- public exposure must be explicit and added through overlays/ingress policy +- health/metrics/admin endpoints stay non-public by default +- DB migration must stay separate from app startup +- stateful workloads must have explicit storage and restore reasoning +- domain/unit nesting should make ownership clear for large service counts diff --git a/k8s/base/app/README.md b/k8s/base/app/README.md new file mode 100644 index 0000000..6e62236 --- /dev/null +++ b/k8s/base/app/README.md @@ -0,0 +1,23 @@ +# Application Base + +This package owns the `app` namespace and the contract for application workloads. + +For large scale, workloads are grouped by unit, domain, workload kind, and workload: + +```text +base/app/units/<unit>/<domain>/<workload-kind>/<workload>/ +``` + +Examples: +- `base/app/units/commerce/checkout/services/order-api/` +- `base/app/units/commerce/checkout/workers/payment-settlement-worker/` +- `base/app/units/identity/auth/schedulers/token-cleanup-scheduler/` +- `base/app/units/data/storage/stateful/orders-postgres/` + +Rules: +- The unit folder is the broad ownership boundary for a business unit, platform area, or organization. +- The domain folder is the bounded context inside a unit. +- The workload-kind folder groups similar operating models inside one domain. +- The workload folder is the smallest independently deployable package. +- Kubernetes object files stay together inside the workload package. +- Shared namespace-level objects go in `shared/`. diff --git a/k8s/base/app/identity/auth/stateful/identity-postgres/configmap-initdb.yaml b/k8s/base/app/identity/auth/stateful/identity-postgres/configmap-initdb.yaml new file mode 100644 index 0000000..bff8129 --- /dev/null +++ b/k8s/base/app/identity/auth/stateful/identity-postgres/configmap-initdb.yaml @@ -0,0 +1,30 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: identity-postgres-initdb + labels: + app.kubernetes.io/name: identity-postgres + app.kubernetes.io/instance: identity-postgres + app.kubernetes.io/version: "16.4" + app.kubernetes.io/component: database + app.kubernetes.io/part-of: auth-platform + app.kubernetes.io/managed-by: kustomize +data: + 01-create-databases.sh: | + #!/usr/bin/env bash + set -euo pipefail + + KEYCLOAK_DB_PASSWORD="$(cat /run/secrets/keycloak-db/password)" + AUTH_SERVER_DB_PASSWORD="$(cat /run/secrets/auth-server-db/SPRING_DATASOURCE_PASSWORD)" + + psql --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-'SQL' + CREATE ROLE keycloak LOGIN; + CREATE DATABASE keycloak OWNER keycloak; + CREATE ROLE auth_server LOGIN; + CREATE DATABASE auth_server OWNER auth_server; + SQL + + psql --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" \ + -c "ALTER ROLE keycloak PASSWORD '$(printf '%s' "$KEYCLOAK_DB_PASSWORD" | sed "s/'/''/g")';" + psql --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" \ + -c "ALTER ROLE auth_server PASSWORD '$(printf '%s' "$AUTH_SERVER_DB_PASSWORD" | sed "s/'/''/g")';" diff --git a/k8s/base/app/identity/auth/stateful/identity-postgres/kustomization.yaml b/k8s/base/app/identity/auth/stateful/identity-postgres/kustomization.yaml new file mode 100644 index 0000000..e4ad18c --- /dev/null +++ b/k8s/base/app/identity/auth/stateful/identity-postgres/kustomization.yaml @@ -0,0 +1,18 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - serviceaccount.yaml + - configmap-initdb.yaml + - service-headless.yaml + - service.yaml + - statefulset.yaml +labels: + - pairs: + app.kubernetes.io/name: identity-postgres + app.kubernetes.io/instance: identity-postgres + app.kubernetes.io/version: "16.4" + app.kubernetes.io/component: database + app.kubernetes.io/part-of: auth-platform + app.kubernetes.io/managed-by: kustomize + includeSelectors: false + includeTemplates: true diff --git a/k8s/base/app/identity/auth/stateful/identity-postgres/service-headless.yaml b/k8s/base/app/identity/auth/stateful/identity-postgres/service-headless.yaml new file mode 100644 index 0000000..34a33db --- /dev/null +++ b/k8s/base/app/identity/auth/stateful/identity-postgres/service-headless.yaml @@ -0,0 +1,22 @@ +apiVersion: v1 +kind: Service +metadata: + name: identity-postgres-headless + labels: + app.kubernetes.io/name: identity-postgres + app.kubernetes.io/instance: identity-postgres + app.kubernetes.io/version: "16.4" + app.kubernetes.io/component: database + app.kubernetes.io/part-of: auth-platform + app.kubernetes.io/managed-by: kustomize +spec: + clusterIP: None + selector: + app.kubernetes.io/name: identity-postgres + app.kubernetes.io/instance: identity-postgres + ports: + - name: postgres + port: 5432 + targetPort: postgres + protocol: TCP + appProtocol: postgresql diff --git a/k8s/base/app/identity/auth/stateful/identity-postgres/service.yaml b/k8s/base/app/identity/auth/stateful/identity-postgres/service.yaml new file mode 100644 index 0000000..7977b70 --- /dev/null +++ b/k8s/base/app/identity/auth/stateful/identity-postgres/service.yaml @@ -0,0 +1,22 @@ +apiVersion: v1 +kind: Service +metadata: + name: identity-postgres + labels: + app.kubernetes.io/name: identity-postgres + app.kubernetes.io/instance: identity-postgres + app.kubernetes.io/version: "16.4" + app.kubernetes.io/component: database + app.kubernetes.io/part-of: auth-platform + app.kubernetes.io/managed-by: kustomize +spec: + type: ClusterIP + selector: + app.kubernetes.io/name: identity-postgres + app.kubernetes.io/instance: identity-postgres + ports: + - name: postgres + port: 5432 + targetPort: postgres + protocol: TCP + appProtocol: postgresql diff --git a/k8s/base/app/identity/auth/stateful/identity-postgres/serviceaccount.yaml b/k8s/base/app/identity/auth/stateful/identity-postgres/serviceaccount.yaml new file mode 100644 index 0000000..c26377d --- /dev/null +++ b/k8s/base/app/identity/auth/stateful/identity-postgres/serviceaccount.yaml @@ -0,0 +1,12 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: identity-postgres-sa + labels: + app.kubernetes.io/name: identity-postgres + app.kubernetes.io/instance: identity-postgres + app.kubernetes.io/version: "16.4" + app.kubernetes.io/component: database + app.kubernetes.io/part-of: auth-platform + app.kubernetes.io/managed-by: kustomize +automountServiceAccountToken: false diff --git a/k8s/base/app/identity/auth/stateful/identity-postgres/statefulset.yaml b/k8s/base/app/identity/auth/stateful/identity-postgres/statefulset.yaml new file mode 100644 index 0000000..fd7c54f --- /dev/null +++ b/k8s/base/app/identity/auth/stateful/identity-postgres/statefulset.yaml @@ -0,0 +1,174 @@ +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: identity-postgres + labels: + app.kubernetes.io/name: identity-postgres + app.kubernetes.io/instance: identity-postgres + app.kubernetes.io/version: "16.4" + app.kubernetes.io/component: database + app.kubernetes.io/part-of: auth-platform + app.kubernetes.io/managed-by: kustomize +spec: + replicas: 1 + serviceName: identity-postgres-headless + podManagementPolicy: OrderedReady + updateStrategy: + type: RollingUpdate + persistentVolumeClaimRetentionPolicy: + whenDeleted: Retain + whenScaled: Retain + selector: + matchLabels: + app.kubernetes.io/name: identity-postgres + app.kubernetes.io/instance: identity-postgres + template: + metadata: + labels: + app.kubernetes.io/name: identity-postgres + app.kubernetes.io/instance: identity-postgres + app.kubernetes.io/version: "16.4" + app.kubernetes.io/component: database + app.kubernetes.io/part-of: auth-platform + app.kubernetes.io/managed-by: kustomize + spec: + serviceAccountName: identity-postgres-sa + automountServiceAccountToken: false + terminationGracePeriodSeconds: 60 + securityContext: + runAsNonRoot: true + runAsUser: 999 + runAsGroup: 999 + fsGroup: 999 + fsGroupChangePolicy: OnRootMismatch + seccompProfile: + type: RuntimeDefault + containers: + - name: postgres + image: postgres:16.4 + imagePullPolicy: IfNotPresent + ports: + - name: postgres + containerPort: 5432 + protocol: TCP + env: + - name: POSTGRES_DB + value: postgres + - name: POSTGRES_USER_FILE + value: /run/secrets/superuser/username + - name: POSTGRES_PASSWORD_FILE + value: /run/secrets/superuser/password + - name: PGDATA + value: /var/lib/postgresql/data/pgdata + - name: KEYCLOAK_DB_PASSWORD_FILE + value: /run/secrets/keycloak-db/password + - name: AUTH_SERVER_DB_PASSWORD_FILE + value: /run/secrets/auth-server-db/SPRING_DATASOURCE_PASSWORD + resources: + requests: + cpu: 500m + memory: 1Gi + limits: + cpu: "1" + memory: 1Gi + startupProbe: + exec: + command: + - sh + - -c + - pg_isready -U "$(cat "$POSTGRES_USER_FILE")" + periodSeconds: 5 + failureThreshold: 60 + timeoutSeconds: 3 + readinessProbe: + exec: + command: + - sh + - -c + - pg_isready -U "$(cat "$POSTGRES_USER_FILE")" + periodSeconds: 10 + failureThreshold: 3 + timeoutSeconds: 3 + livenessProbe: + exec: + command: + - sh + - -c + - pg_isready -U "$(cat "$POSTGRES_USER_FILE")" + periodSeconds: 30 + failureThreshold: 3 + timeoutSeconds: 3 + securityContext: + runAsNonRoot: true + runAsUser: 999 + runAsGroup: 999 + allowPrivilegeEscalation: false + privileged: false + readOnlyRootFilesystem: true + capabilities: + drop: + - ALL + seccompProfile: + type: RuntimeDefault + volumeMounts: + - name: data + mountPath: /var/lib/postgresql/data + - name: initdb + mountPath: /docker-entrypoint-initdb.d + readOnly: true + - name: superuser + mountPath: /run/secrets/superuser + readOnly: true + - name: keycloak-db + mountPath: /run/secrets/keycloak-db + readOnly: true + - name: auth-server-db + mountPath: /run/secrets/auth-server-db + readOnly: true + - name: tmp + mountPath: /tmp + - name: run + mountPath: /var/run/postgresql + volumes: + - name: initdb + configMap: + name: identity-postgres-initdb + defaultMode: 0755 + - name: superuser + secret: + secretName: identity-postgres-superuser + defaultMode: 0400 + - name: keycloak-db + secret: + secretName: keycloak-db + defaultMode: 0400 + - name: auth-server-db + secret: + secretName: auth-server-db + defaultMode: 0400 + - name: tmp + emptyDir: + medium: Memory + sizeLimit: 128Mi + - name: run + emptyDir: + medium: Memory + sizeLimit: 64Mi + volumeClaimTemplates: + - metadata: + name: data + labels: + app.kubernetes.io/name: identity-postgres + app.kubernetes.io/instance: identity-postgres + app.kubernetes.io/version: "16.4" + app.kubernetes.io/component: database + app.kubernetes.io/part-of: auth-platform + app.kubernetes.io/managed-by: kustomize + spec: + accessModes: + - ReadWriteOnce + storageClassName: local-path + volumeMode: Filesystem + resources: + requests: + storage: 5Gi diff --git a/k8s/base/app/identity/auth/stateless/auth-server/configmap.yaml b/k8s/base/app/identity/auth/stateless/auth-server/configmap.yaml new file mode 100644 index 0000000..3761a22 --- /dev/null +++ b/k8s/base/app/identity/auth/stateless/auth-server/configmap.yaml @@ -0,0 +1,19 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: auth-server-config + labels: + app.kubernetes.io/name: auth-server + app.kubernetes.io/instance: auth-server + app.kubernetes.io/version: "0.1.0" + app.kubernetes.io/component: api + app.kubernetes.io/part-of: auth-platform + app.kubernetes.io/managed-by: kustomize +data: + SPRING_PROFILES_ACTIVE: dev + SPRING_DATASOURCE_URL: jdbc:postgresql://identity-postgres:5432/auth_server + APP_DATASOURCE_URL: jdbc:postgresql://identity-postgres:5432/auth_server + MANAGEMENT_SERVER_ADDRESS: 0.0.0.0 + MANAGEMENT_SERVER_PORT: "8081" + MANAGEMENT_ENDPOINTS_WEB_EXPOSURE_INCLUDE: health,prometheus + SERVER_PORT: "8080" diff --git a/k8s/base/app/identity/auth/stateless/auth-server/deployment.yaml b/k8s/base/app/identity/auth/stateless/auth-server/deployment.yaml new file mode 100644 index 0000000..8e1b749 --- /dev/null +++ b/k8s/base/app/identity/auth/stateless/auth-server/deployment.yaml @@ -0,0 +1,128 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: auth-server + labels: + app.kubernetes.io/name: auth-server + app.kubernetes.io/instance: auth-server + app.kubernetes.io/version: "0.1.0" + app.kubernetes.io/component: api + app.kubernetes.io/part-of: auth-platform + app.kubernetes.io/managed-by: kustomize +spec: + replicas: 1 + revisionHistoryLimit: 5 + progressDeadlineSeconds: 600 + strategy: + type: RollingUpdate + rollingUpdate: + maxSurge: 25% + maxUnavailable: 0 + selector: + matchLabels: + app.kubernetes.io/name: auth-server + app.kubernetes.io/instance: auth-server + template: + metadata: + labels: + app.kubernetes.io/name: auth-server + app.kubernetes.io/instance: auth-server + app.kubernetes.io/version: "0.1.0" + app.kubernetes.io/component: api + app.kubernetes.io/part-of: auth-platform + app.kubernetes.io/managed-by: kustomize + spec: + serviceAccountName: auth-server-sa + automountServiceAccountToken: false + terminationGracePeriodSeconds: 45 + topologySpreadConstraints: + - maxSkew: 1 + topologyKey: kubernetes.io/hostname + whenUnsatisfiable: ScheduleAnyway + labelSelector: + matchLabels: + app.kubernetes.io/name: auth-server + app.kubernetes.io/instance: auth-server + securityContext: + runAsNonRoot: true + runAsUser: 10001 + runAsGroup: 10001 + fsGroup: 10001 + fsGroupChangePolicy: OnRootMismatch + seccompProfile: + type: RuntimeDefault + containers: + - name: auth-server + image: registry.example.com/auth-platform/auth-server:0.1.0 + imagePullPolicy: IfNotPresent + ports: + - name: http + containerPort: 8080 + protocol: TCP + - name: metrics + containerPort: 8081 + protocol: TCP + envFrom: + - configMapRef: + name: auth-server-config + - secretRef: + name: auth-server-db + env: + - name: JAVA_TOOL_OPTIONS + value: "-XX:MaxRAMPercentage=75 -XX:+ExitOnOutOfMemoryError" + - name: SPRING_CONFIG_IMPORT + value: "optional:configtree:/etc/secrets/" + resources: + requests: + cpu: 250m + memory: 512Mi + limits: + memory: 768Mi + startupProbe: + httpGet: + path: /actuator/health/liveness + port: metrics + periodSeconds: 5 + failureThreshold: 24 + timeoutSeconds: 3 + readinessProbe: + httpGet: + path: /actuator/health/readiness + port: metrics + periodSeconds: 5 + failureThreshold: 3 + timeoutSeconds: 2 + livenessProbe: + httpGet: + path: /actuator/health/liveness + port: metrics + periodSeconds: 15 + failureThreshold: 3 + timeoutSeconds: 3 + securityContext: + runAsNonRoot: true + runAsUser: 10001 + runAsGroup: 10001 + allowPrivilegeEscalation: false + privileged: false + readOnlyRootFilesystem: true + capabilities: + drop: + - ALL + seccompProfile: + type: RuntimeDefault + volumeMounts: + - name: db-creds + mountPath: /etc/secrets + readOnly: true + - name: tmp + mountPath: /tmp + volumes: + - name: db-creds + secret: + secretName: auth-server-db + defaultMode: 0400 + - name: tmp + emptyDir: + medium: Memory + sizeLimit: 128Mi diff --git a/k8s/base/app/identity/auth/stateless/auth-server/kustomization.yaml b/k8s/base/app/identity/auth/stateless/auth-server/kustomization.yaml new file mode 100644 index 0000000..1e7b69a --- /dev/null +++ b/k8s/base/app/identity/auth/stateless/auth-server/kustomization.yaml @@ -0,0 +1,17 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - serviceaccount.yaml + - configmap.yaml + - deployment.yaml + - service.yaml +labels: + - pairs: + app.kubernetes.io/name: auth-server + app.kubernetes.io/instance: auth-server + app.kubernetes.io/version: "0.1.0" + app.kubernetes.io/component: api + app.kubernetes.io/part-of: auth-platform + app.kubernetes.io/managed-by: kustomize + includeSelectors: false + includeTemplates: true diff --git a/k8s/base/app/identity/auth/stateless/auth-server/service.yaml b/k8s/base/app/identity/auth/stateless/auth-server/service.yaml new file mode 100644 index 0000000..11114db --- /dev/null +++ b/k8s/base/app/identity/auth/stateless/auth-server/service.yaml @@ -0,0 +1,27 @@ +apiVersion: v1 +kind: Service +metadata: + name: auth-server + labels: + app.kubernetes.io/name: auth-server + app.kubernetes.io/instance: auth-server + app.kubernetes.io/version: "0.1.0" + app.kubernetes.io/component: api + app.kubernetes.io/part-of: auth-platform + app.kubernetes.io/managed-by: kustomize +spec: + type: ClusterIP + selector: + app.kubernetes.io/name: auth-server + app.kubernetes.io/instance: auth-server + ports: + - name: http + port: 80 + targetPort: http + protocol: TCP + appProtocol: http + - name: metrics + port: 8081 + targetPort: metrics + protocol: TCP + appProtocol: http diff --git a/k8s/base/app/identity/auth/stateless/auth-server/serviceaccount.yaml b/k8s/base/app/identity/auth/stateless/auth-server/serviceaccount.yaml new file mode 100644 index 0000000..fec8724 --- /dev/null +++ b/k8s/base/app/identity/auth/stateless/auth-server/serviceaccount.yaml @@ -0,0 +1,12 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: auth-server-sa + labels: + app.kubernetes.io/name: auth-server + app.kubernetes.io/instance: auth-server + app.kubernetes.io/version: "0.1.0" + app.kubernetes.io/component: api + app.kubernetes.io/part-of: auth-platform + app.kubernetes.io/managed-by: kustomize +automountServiceAccountToken: false diff --git a/k8s/base/app/identity/keycloak/stateless/keycloak/keycloak.yaml b/k8s/base/app/identity/keycloak/stateless/keycloak/keycloak.yaml new file mode 100644 index 0000000..ad7e405 --- /dev/null +++ b/k8s/base/app/identity/keycloak/stateless/keycloak/keycloak.yaml @@ -0,0 +1,113 @@ +apiVersion: k8s.keycloak.org/v2beta1 +kind: Keycloak +metadata: + name: keycloak + labels: + app.kubernetes.io/name: keycloak + app.kubernetes.io/instance: keycloak + app.kubernetes.io/version: "26.6.1" + app.kubernetes.io/component: identity-provider + app.kubernetes.io/part-of: auth-platform + app.kubernetes.io/managed-by: keycloak-operator +spec: + instances: 2 + db: + vendor: postgres + host: identity-postgres + port: 5432 + database: keycloak + usernameSecret: + name: keycloak-db-operator + key: username + passwordSecret: + name: keycloak-db-operator + key: password + poolInitialSize: 5 + poolMinSize: 5 + poolMaxSize: 20 + bootstrapAdmin: + user: + secret: keycloak-bootstrap-admin-operator # 어떤 시크릿에서 읽을지 지정하는 거 + hostname: + strict: true # 명시된 hostname을 기준으로 URL을 만든다. + backchannelDynamic: false # 백URL도 프론트 hostname과 동일하게 고정한다. + http: + httpEnabled: true + httpPort: 8080 + serviceHttpPort: 80 + serviceName: keycloak + labels: + app.kubernetes.io/name: keycloak + app.kubernetes.io/instance: keycloak + app.kubernetes.io/component: identity-provider + app.kubernetes.io/part-of: auth-platform + app.kubernetes.io/managed-by: keycloak-operator + proxy: + headers: xforwarded # X-Forwarded-For X-Forwarded-Proto X-Forwarded-Host X-Forwarded-Port 해당 헤더들을 사용하겠다는 의미 + ingress: + enabled: false + networkPolicy: + enabled: false + additionalOptions: + - name: cache + value: ispn # 애플리케이션의 캐시 엔진으로 ispn으로 사용 + - name: cache-stack + value: jdbc-ping # 클러스터링된 서버끼리 서로를 찾는(Discovery) 방식을 JDBC-PING으로 정한 것 + - name: health-enabled + value: "true" # 상태 확인(Health Check) 엔드포인트를 활성화 + - name: metrics-enabled + value: "true" # 메트릭(성능 지표) 수집 기능을 켬 + - name: log-console-output + value: json # 로그 형식을 JSON 구조로 변경 + env: + - name: JAVA_OPTS_APPEND + value: "-XX:MaxRAMPercentage=70 -XX:InitialRAMPercentage=50 -XX:+ExitOnOutOfMemoryError" + resources: + requests: + cpu: 500m + memory: 1Gi + limits: + cpu: "2" + memory: 2Gi + scheduling: + topologySpreadConstraints: + - maxSkew: 1 + topologyKey: kubernetes.io/hostname + whenUnsatisfiable: ScheduleAnyway + labelSelector: + matchLabels: + app.kubernetes.io/instance: keycloak + app.kubernetes.io/managed-by: keycloak-operator + affinity: # 특정 노드에 다른 pod에 관계에 따라 배치 위치를 결정하는 규칙을 정의한다 + podAntiAffinity: # 특정 조건을 만족하는 포드와는 같은 장소에 있지 않는다. + preferredDuringSchedulingIgnoredDuringExecution: # 가급적 지켜줘 + - weight: 100 # 가중치가 100임 매우 중요하다고 알림 + podAffinityTerm: # 어떤 파드를 피해다닐지 + topologyKey: kubernetes.io/hostname # 피할 기준은 호스트 네임으로 설정 + labelSelector: + matchLabels: + app.kubernetes.io/instance: keycloak # keycloak과 피하겠다 + app.kubernetes.io/managed-by: keycloak-operator # keycloak-operator에 의해 관리되는 + unsupported: + podTemplate: + spec: + securityContext: + runAsNonRoot: true + runAsUser: 1000 + runAsGroup: 1000 + fsGroup: 1000 + fsGroupChangePolicy: OnRootMismatch + seccompProfile: + type: RuntimeDefault + containers: + - name: keycloak + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + runAsNonRoot: true + runAsUser: 1000 + runAsGroup: 1000 + seccompProfile: + type: RuntimeDefault diff --git a/k8s/base/app/identity/keycloak/stateless/keycloak/kustomization.yaml b/k8s/base/app/identity/keycloak/stateless/keycloak/kustomization.yaml new file mode 100644 index 0000000..76dc97e --- /dev/null +++ b/k8s/base/app/identity/keycloak/stateless/keycloak/kustomization.yaml @@ -0,0 +1,14 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - keycloak.yaml +labels: + - pairs: + app.kubernetes.io/name: keycloak + app.kubernetes.io/instance: keycloak + app.kubernetes.io/version: "26.6.1" + app.kubernetes.io/component: identity-provider + app.kubernetes.io/part-of: auth-platform + app.kubernetes.io/managed-by: kustomize + includeSelectors: false + includeTemplates: true diff --git a/k8s/base/app/storage/minio/stateful/minio/kustomization.yaml b/k8s/base/app/storage/minio/stateful/minio/kustomization.yaml new file mode 100644 index 0000000..5a60bfa --- /dev/null +++ b/k8s/base/app/storage/minio/stateful/minio/kustomization.yaml @@ -0,0 +1,14 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - tenant.yaml +labels: + - pairs: + app.kubernetes.io/name: minio + app.kubernetes.io/instance: minio + app.kubernetes.io/version: "2025.01.20" + app.kubernetes.io/component: object-storage + app.kubernetes.io/part-of: storage-platform + app.kubernetes.io/managed-by: kustomize + includeSelectors: false + includeTemplates: true diff --git a/k8s/base/app/storage/minio/stateful/minio/tenant.yaml b/k8s/base/app/storage/minio/stateful/minio/tenant.yaml new file mode 100644 index 0000000..e4fd2fa --- /dev/null +++ b/k8s/base/app/storage/minio/stateful/minio/tenant.yaml @@ -0,0 +1,92 @@ +apiVersion: minio.min.io/v2 +kind: Tenant +metadata: + name: minio + labels: + app.kubernetes.io/name: minio + app.kubernetes.io/instance: minio + app.kubernetes.io/version: "2025.01.20" + app.kubernetes.io/component: object-storage + app.kubernetes.io/part-of: storage-platform + app.kubernetes.io/managed-by: kustomize +spec: + image: minio/minio:RELEASE.2025-01-20T14-49-07Z + imagePullPolicy: IfNotPresent + mountPath: /export + configuration: + name: minio-tenant-env + requestAutoCert: true + certConfig: + commonName: minio + organizationName: + - example.com + dnsNames: + - minio + - minio-hl + pools: + - name: pool-0 + servers: 4 + volumesPerServer: 1 + volumeClaimTemplate: + metadata: + name: data + spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 20Gi + storageClassName: local-path + resources: + requests: + cpu: 250m + memory: 1Gi + limits: + cpu: "1" + memory: 2Gi + securityContext: + runAsUser: 1000 + runAsGroup: 1000 + runAsNonRoot: true + fsGroup: 1000 + fsGroupChangePolicy: OnRootMismatch + seccompProfile: + type: RuntimeDefault + containerSecurityContext: + runAsUser: 1000 + runAsGroup: 1000 + runAsNonRoot: true + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: + - ALL + features: + bucketDNS: false + prometheusOperator: false + podManagementPolicy: Parallel + exposeServices: + minio: false + console: false + logging: + anonymous: false + json: true + quiet: false +--- +apiVersion: policy/v1 +kind: PodDisruptionBudget +metadata: + name: minio-pdb + labels: + app.kubernetes.io/name: minio + app.kubernetes.io/instance: minio + app.kubernetes.io/version: "2025.01.20" + app.kubernetes.io/component: object-storage + app.kubernetes.io/part-of: storage-platform + app.kubernetes.io/managed-by: kustomize +spec: + maxUnavailable: 1 + unhealthyPodEvictionPolicy: AlwaysAllow + selector: + matchLabels: + v1.min.io/tenant: minio diff --git a/k8s/base/managing/AGENTS.md b/k8s/base/managing/AGENTS.md new file mode 100644 index 0000000..ca48bda --- /dev/null +++ b/k8s/base/managing/AGENTS.md @@ -0,0 +1,46 @@ +# k8s/base/managing AGENTS + +Role: +- own management and operational Kubernetes base units +- model bootstrap, migration, backup, restore, maintenance, and admin workloads as declarative Kustomize bases +- keep operational workloads separate from long-running application serving workloads + +Allowed: +- Job / CronJob base resources for operational tasks +- maintenance ServiceAccount / RBAC / ConfigMap / Secret reference wiring +- backup / restore / migration helper workload shapes +- admin-only service shapes when explicitly justified + +Forbidden: +- long-running product application workloads +- environment-specific values that belong in `k8s/overlays/<env>` +- scripts becoming the primary source of YAML truth +- large heredoc-generated manifests as the default path +- embedding production secret values +- hiding environment differences in shell conditionals instead of overlays +- giant all-in-one jobs that mix unrelated concerns + +Read first: +- `/docs/standards/infra/workload-selection.md` +- `/docs/standards/infra/db-and-migration.md` +- `/docs/standards/infra/flyway.md` +- `/docs/standards/infra/backup-restore.md` +- `/docs/standards/infra/operations-runbook-upgrade-rollback.md` +- `/docs/standards/infra/config-and-secrets.md` +- `/docs/standards/infra/security-hardening.md` +- `/docs/standards/infra/kustomize.md` + +Examples: +- `/docs/examples/infra/flyway.md` +- `/docs/examples/infra/backup-restore.md` +- `/docs/examples/infra/operations-runbook-upgrade-rollback.md` +- `/docs/examples/infra/db-and-migration.md` +- `/docs/examples/infra/kustomize.md` +- `/docs/examples/infra/scripts.md` + +Rules: +- management jobs are explicit operational units, not hidden app startup hooks +- migrations must stay separate from app startup +- backup and restore paths must be documented before risky stateful changes +- destructive operations require explicit opt-in and runbook backing +- operational workloads must still follow security, resource, secret, and namespace standards diff --git a/k8s/base/managing/kustomization.yaml b/k8s/base/managing/kustomization.yaml new file mode 100755 index 0000000..7413ead --- /dev/null +++ b/k8s/base/managing/kustomization.yaml @@ -0,0 +1,5 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +resources: + - namespace diff --git a/k8s/base/managing/migration-flyway/configmap-sql.yaml b/k8s/base/managing/migration-flyway/configmap-sql.yaml new file mode 100644 index 0000000..4a24e5f --- /dev/null +++ b/k8s/base/managing/migration-flyway/configmap-sql.yaml @@ -0,0 +1,80 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: migration-flyway-sql + labels: + app.kubernetes.io/name: migration-flyway + app.kubernetes.io/instance: migration-flyway + app.kubernetes.io/version: "0.1.0" + app.kubernetes.io/component: migration + app.kubernetes.io/part-of: auth-platform + app.kubernetes.io/managed-by: kustomize +data: + V1__create_users_table.sql: | + create schema if not exists auth; + + create or replace function auth.set_updated_at() + returns trigger as $$ + begin + new.updated_at := current_timestamp; + return new; + end; + $$ language plpgsql; + + create table auth.users ( + id uuid not null, + email text not null, + encoded_password text not null, + name text not null, + provider text not null, + created_at timestamp with time zone not null default current_timestamp, + updated_at timestamp with time zone not null default current_timestamp, + constraint pk_users primary key (id), + constraint uq_users__email unique (email), + constraint ck_users__provider check (provider in ('LOCAL', 'GOOGLE', 'GITHUB')) + ); + + create index ix_users__created_at on auth.users (created_at); + + create trigger trg_users__set_updated_at + before update on auth.users + for each row + when (old.* is distinct from new.*) + execute function auth.set_updated_at(); + V2__add_oauth_login_columns.sql: | + alter table auth.users alter column encoded_password drop not null; + + alter table auth.users add column provider_subject text; + + alter table auth.users + add constraint uq_users__provider_provider_subject unique (provider, provider_subject); + V3__add_user_provider_field_constraints.sql: | + alter table auth.users + add constraint ck_users__local_password_required + check ( + (provider = 'LOCAL' and encoded_password is not null and provider_subject is null) + or (provider <> 'LOCAL') + ); + + alter table auth.users + add constraint ck_users__social_subject_required + check ( + (provider <> 'LOCAL' and provider_subject is not null and encoded_password is null) + or (provider = 'LOCAL') + ); + V4__add_keycloak_provider.sql: | + alter table auth.users drop constraint ck_users__provider; + + alter table auth.users + add constraint ck_users__provider check (provider in ('LOCAL', 'KEYCLOAK', 'GOOGLE', 'GITHUB')); + V5__keycloak_only_provider.sql: | + alter table auth.users drop constraint if exists ck_users__local_password_required; + alter table auth.users drop constraint if exists ck_users__social_subject_required; + alter table auth.users drop constraint if exists ck_users__provider; + + alter table auth.users drop column if exists encoded_password; + + alter table auth.users alter column provider_subject set not null; + + alter table auth.users + add constraint ck_users__provider check (provider = 'KEYCLOAK'); diff --git a/k8s/base/managing/migration-flyway/job.yaml b/k8s/base/managing/migration-flyway/job.yaml new file mode 100644 index 0000000..34ba8d6 --- /dev/null +++ b/k8s/base/managing/migration-flyway/job.yaml @@ -0,0 +1,119 @@ +apiVersion: batch/v1 +kind: Job +metadata: + name: migration-flyway + labels: + app.kubernetes.io/name: migration-flyway + app.kubernetes.io/instance: migration-flyway + app.kubernetes.io/version: "0.1.0" + app.kubernetes.io/component: migration + app.kubernetes.io/part-of: auth-platform + app.kubernetes.io/managed-by: kustomize +spec: + parallelism: 1 + completions: 1 + backoffLimit: 0 + activeDeadlineSeconds: 1800 + ttlSecondsAfterFinished: 86400 + template: + metadata: + labels: + app.kubernetes.io/name: migration-flyway + app.kubernetes.io/instance: migration-flyway + app.kubernetes.io/version: "0.1.0" + app.kubernetes.io/component: migration + app.kubernetes.io/part-of: auth-platform + app.kubernetes.io/managed-by: kustomize + spec: + serviceAccountName: migration-flyway-sa + automountServiceAccountToken: false + restartPolicy: Never + securityContext: + runAsNonRoot: true + runAsUser: 1000 + runAsGroup: 1000 + fsGroup: 1000 + fsGroupChangePolicy: OnRootMismatch + seccompProfile: + type: RuntimeDefault + initContainers: + - name: flyway-info + image: flyway/flyway:10.20.1 + imagePullPolicy: IfNotPresent + command: ["/bin/sh", "-c"] + args: ["FLYWAY_USER=\"$(cat /run/secrets/db/SPRING_DATASOURCE_USERNAME)\" FLYWAY_PASSWORD=\"$(cat /run/secrets/db/SPRING_DATASOURCE_PASSWORD)\" exec /flyway/flyway info"] + env: &flywayEnv + - name: FLYWAY_URL + value: jdbc:postgresql://identity-postgres:5432/auth_server + - name: FLYWAY_LOCATIONS + value: filesystem:/flyway/sql + - name: FLYWAY_SCHEMAS + value: auth + - name: FLYWAY_DEFAULT_SCHEMA + value: auth + - name: FLYWAY_TABLE + value: flyway_schema_history + - name: FLYWAY_VALIDATE_ON_MIGRATE + value: "true" + - name: FLYWAY_BASELINE_ON_MIGRATE + value: "false" + - name: FLYWAY_OUT_OF_ORDER + value: "false" + - name: FLYWAY_MIXED + value: "false" + - name: FLYWAY_CLEAN_DISABLED + value: "true" + resources: + requests: + cpu: 50m + memory: 128Mi + limits: + memory: 256Mi + securityContext: &flywaySC + runAsNonRoot: true + runAsUser: 1000 + runAsGroup: 1000 + allowPrivilegeEscalation: false + privileged: false + readOnlyRootFilesystem: true + capabilities: + drop: + - ALL + seccompProfile: + type: RuntimeDefault + volumeMounts: &flywayVM + - name: sql + mountPath: /flyway/sql + readOnly: true + - name: db-secret + mountPath: /run/secrets/db + readOnly: true + - name: tmp + mountPath: /tmp + containers: + - name: flyway-migrate + image: flyway/flyway:10.20.1 + imagePullPolicy: IfNotPresent + command: ["/bin/sh", "-c"] + args: ["FLYWAY_USER=\"$(cat /run/secrets/db/SPRING_DATASOURCE_USERNAME)\" FLYWAY_PASSWORD=\"$(cat /run/secrets/db/SPRING_DATASOURCE_PASSWORD)\" exec /flyway/flyway migrate"] + env: *flywayEnv + resources: + requests: + cpu: 100m + memory: 256Mi + limits: + memory: 512Mi + securityContext: *flywaySC + volumeMounts: *flywayVM + volumes: + - name: sql + configMap: + name: migration-flyway-sql + - name: db-secret + secret: + secretName: auth-server-db + defaultMode: 0400 + - name: tmp + emptyDir: + medium: Memory + sizeLimit: 128Mi diff --git a/k8s/base/managing/migration-flyway/kustomization.yaml b/k8s/base/managing/migration-flyway/kustomization.yaml new file mode 100644 index 0000000..2ee969e --- /dev/null +++ b/k8s/base/managing/migration-flyway/kustomization.yaml @@ -0,0 +1,16 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - serviceaccount.yaml + - configmap-sql.yaml + - job.yaml +labels: + - pairs: + app.kubernetes.io/name: migration-flyway + app.kubernetes.io/instance: migration-flyway + app.kubernetes.io/version: "0.1.0" + app.kubernetes.io/component: migration + app.kubernetes.io/part-of: auth-platform + app.kubernetes.io/managed-by: kustomize + includeSelectors: false + includeTemplates: true diff --git a/k8s/base/managing/migration-flyway/serviceaccount.yaml b/k8s/base/managing/migration-flyway/serviceaccount.yaml new file mode 100644 index 0000000..015f254 --- /dev/null +++ b/k8s/base/managing/migration-flyway/serviceaccount.yaml @@ -0,0 +1,12 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: migration-flyway-sa + labels: + app.kubernetes.io/name: migration-flyway + app.kubernetes.io/instance: migration-flyway + app.kubernetes.io/version: "0.1.0" + app.kubernetes.io/component: migration + app.kubernetes.io/part-of: auth-platform + app.kubernetes.io/managed-by: kustomize +automountServiceAccountToken: false diff --git a/k8s/base/managing/namespace/kustomization.yaml b/k8s/base/managing/namespace/kustomization.yaml new file mode 100644 index 0000000..dca4a51 --- /dev/null +++ b/k8s/base/managing/namespace/kustomization.yaml @@ -0,0 +1,5 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +resources: + - namespace.yaml diff --git a/k8s/base/managing/namespace/namespace.yaml b/k8s/base/managing/namespace/namespace.yaml new file mode 100644 index 0000000..6d6d436 --- /dev/null +++ b/k8s/base/managing/namespace/namespace.yaml @@ -0,0 +1,16 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: mnt + labels: + app.kubernetes.io/name: mnt + app.kubernetes.io/instance: mnt + app.kubernetes.io/component: namespace + app.kubernetes.io/part-of: infra-platform + app.kubernetes.io/managed-by: kustomize + pod-security.kubernetes.io/enforce: restricted + pod-security.kubernetes.io/enforce-version: latest + pod-security.kubernetes.io/audit: restricted + pod-security.kubernetes.io/audit-version: latest + pod-security.kubernetes.io/warn: restricted + pod-security.kubernetes.io/warn-version: latest diff --git a/k8s/base/plugins/docker-registry/configmap.yaml b/k8s/base/plugins/docker-registry/configmap.yaml new file mode 100755 index 0000000..31aed24 --- /dev/null +++ b/k8s/base/plugins/docker-registry/configmap.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: docker-registry-config +data: + REGISTRY_HTTP_ADDR: "0.0.0.0:5000" + REGISTRY_LOG_FORMATTER: "json" + REGISTRY_STORAGE: "s3" + REGISTRY_STORAGE_S3_REGION: "us-east-1" + REGISTRY_STORAGE_S3_REGIONENDPOINT: "http://minio" + REGISTRY_STORAGE_S3_BUCKET: "docker-registry" + REGISTRY_STORAGE_S3_FORCEPATHSTYLE: "true" + REGISTRY_STORAGE_S3_SKIPVERIFY: "true" + REGISTRY_STORAGE_REDIRECT_DISABLE: "true" + REGISTRY_STORAGE_DELETE_ENABLED: "true" diff --git a/k8s/base/plugins/docker-registry/deployment.yaml b/k8s/base/plugins/docker-registry/deployment.yaml new file mode 100644 index 0000000..856a09f --- /dev/null +++ b/k8s/base/plugins/docker-registry/deployment.yaml @@ -0,0 +1,94 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: docker-registry +spec: + replicas: 1 + revisionHistoryLimit: 5 + progressDeadlineSeconds: 600 + strategy: + type: RollingUpdate + rollingUpdate: + maxSurge: 25% + maxUnavailable: 0 + selector: + matchLabels: + app.kubernetes.io/name: docker-registry + app.kubernetes.io/instance: docker-registry + template: + metadata: + labels: + app.kubernetes.io/name: docker-registry + app.kubernetes.io/instance: docker-registry + spec: + serviceAccountName: docker-registry + automountServiceAccountToken: false + terminationGracePeriodSeconds: 45 + securityContext: + runAsNonRoot: true + runAsUser: 10001 + runAsGroup: 10001 + fsGroup: 10001 + fsGroupChangePolicy: OnRootMismatch + seccompProfile: + type: RuntimeDefault + containers: + - name: registry + image: registry:2.8.3 + imagePullPolicy: IfNotPresent + ports: + - name: http + containerPort: 5000 + protocol: TCP + envFrom: + - configMapRef: + name: docker-registry-config + - secretRef: + name: docker-registry-minio + resources: + requests: + cpu: 100m + memory: 256Mi + limits: + memory: 512Mi + startupProbe: + httpGet: + path: /v2/ + port: http + periodSeconds: 5 + failureThreshold: 12 + timeoutSeconds: 3 + readinessProbe: + httpGet: + path: /v2/ + port: http + periodSeconds: 10 + failureThreshold: 3 + timeoutSeconds: 3 + livenessProbe: + httpGet: + path: /v2/ + port: http + periodSeconds: 30 + failureThreshold: 3 + timeoutSeconds: 3 + securityContext: + runAsNonRoot: true + runAsUser: 10001 + runAsGroup: 10001 + allowPrivilegeEscalation: false + privileged: false + readOnlyRootFilesystem: true + capabilities: + drop: + - ALL + seccompProfile: + type: RuntimeDefault + volumeMounts: + - name: tmp + mountPath: /tmp + volumes: + - name: tmp + emptyDir: + medium: Memory + sizeLimit: 64Mi diff --git a/k8s/base/plugins/docker-registry/kustomization.yaml b/k8s/base/plugins/docker-registry/kustomization.yaml new file mode 100755 index 0000000..7e30b16 --- /dev/null +++ b/k8s/base/plugins/docker-registry/kustomization.yaml @@ -0,0 +1,19 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +resources: + - serviceaccount.yaml + - configmap.yaml + - service.yaml + - deployment.yaml + +labels: + - pairs: + app.kubernetes.io/name: docker-registry + app.kubernetes.io/instance: docker-registry + app.kubernetes.io/version: "2.8.3" + app.kubernetes.io/component: registry + app.kubernetes.io/part-of: platform-registry + app.kubernetes.io/managed-by: kustomize + includeSelectors: false + includeTemplates: true diff --git a/k8s/base/plugins/docker-registry/service.yaml b/k8s/base/plugins/docker-registry/service.yaml new file mode 100755 index 0000000..5644fc6 --- /dev/null +++ b/k8s/base/plugins/docker-registry/service.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Service +metadata: + name: docker-registry +spec: + type: ClusterIP + selector: + app.kubernetes.io/name: docker-registry + app.kubernetes.io/instance: docker-registry + ports: + - name: http + port: 5000 + targetPort: http + protocol: TCP + appProtocol: http diff --git a/k8s/base/plugins/docker-registry/serviceaccount.yaml b/k8s/base/plugins/docker-registry/serviceaccount.yaml new file mode 100644 index 0000000..5c5b56a --- /dev/null +++ b/k8s/base/plugins/docker-registry/serviceaccount.yaml @@ -0,0 +1,5 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: docker-registry +automountServiceAccountToken: false diff --git a/k8s/base/plugins/kustomization.yaml b/k8s/base/plugins/kustomization.yaml new file mode 100644 index 0000000..041fdae --- /dev/null +++ b/k8s/base/plugins/kustomization.yaml @@ -0,0 +1,6 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +resources: + - vault + - docker-registry diff --git a/k8s/base/plugins/oauth2-proxy/deployment.yaml b/k8s/base/plugins/oauth2-proxy/deployment.yaml new file mode 100644 index 0000000..3cbff44 --- /dev/null +++ b/k8s/base/plugins/oauth2-proxy/deployment.yaml @@ -0,0 +1,115 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: oauth2-proxy +spec: + replicas: 1 + revisionHistoryLimit: 5 + progressDeadlineSeconds: 600 + strategy: + type: RollingUpdate + rollingUpdate: + maxSurge: 25% + maxUnavailable: 0 + selector: + matchLabels: + app.kubernetes.io/name: oauth2-proxy + app.kubernetes.io/instance: oauth2-proxy + template: + metadata: + labels: + app.kubernetes.io/name: oauth2-proxy + app.kubernetes.io/instance: oauth2-proxy + spec: + serviceAccountName: oauth2-proxy-sa + automountServiceAccountToken: false + terminationGracePeriodSeconds: 30 + topologySpreadConstraints: + - maxSkew: 1 + topologyKey: kubernetes.io/hostname + whenUnsatisfiable: ScheduleAnyway + labelSelector: + matchLabels: + app.kubernetes.io/name: oauth2-proxy + app.kubernetes.io/instance: oauth2-proxy + securityContext: + runAsNonRoot: true + runAsUser: 65532 + runAsGroup: 65532 + fsGroup: 65532 + fsGroupChangePolicy: OnRootMismatch + seccompProfile: + type: RuntimeDefault + containers: + - name: oauth2-proxy + image: quay.io/oauth2-proxy/oauth2-proxy:v7.15.2 + imagePullPolicy: IfNotPresent + args: + - --config=/etc/oauth2-proxy/oauth2-proxy.cfg + ports: + - name: http + containerPort: 4180 + protocol: TCP + - name: metrics + containerPort: 44180 + protocol: TCP + resources: + requests: + cpu: 100m + memory: 128Mi + limits: + memory: 256Mi + startupProbe: + httpGet: + path: /ping + port: http + periodSeconds: 5 + failureThreshold: 12 + timeoutSeconds: 3 + readinessProbe: + httpGet: + path: /ready + port: http + periodSeconds: 10 + failureThreshold: 3 + timeoutSeconds: 3 + livenessProbe: + httpGet: + path: /ping + port: http + periodSeconds: 15 + failureThreshold: 3 + timeoutSeconds: 3 + securityContext: + runAsNonRoot: true + runAsUser: 65532 + runAsGroup: 65532 + allowPrivilegeEscalation: false + privileged: false + readOnlyRootFilesystem: true + capabilities: + drop: + - ALL + seccompProfile: + type: RuntimeDefault + volumeMounts: + - name: config + mountPath: /etc/oauth2-proxy + readOnly: true + - name: secrets + mountPath: /etc/oauth2-proxy-secrets + readOnly: true + - name: tmp + mountPath: /tmp + volumes: + - name: config + configMap: + name: oauth2-proxy-config + - name: secrets + secret: + secretName: oauth2-proxy-secrets + defaultMode: 0400 + - name: tmp + emptyDir: + medium: Memory + sizeLimit: 64Mi diff --git a/k8s/base/plugins/oauth2-proxy/kustomization.yaml b/k8s/base/plugins/oauth2-proxy/kustomization.yaml new file mode 100644 index 0000000..1a7ac2d --- /dev/null +++ b/k8s/base/plugins/oauth2-proxy/kustomization.yaml @@ -0,0 +1,18 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +resources: + - serviceaccount.yaml + - service.yaml + - deployment.yaml + +labels: + - pairs: + app.kubernetes.io/name: oauth2-proxy + app.kubernetes.io/instance: oauth2-proxy + app.kubernetes.io/version: "7.15.2" + app.kubernetes.io/component: auth-proxy + app.kubernetes.io/part-of: auth-platform + app.kubernetes.io/managed-by: kustomize + includeSelectors: false + includeTemplates: true diff --git a/k8s/base/plugins/oauth2-proxy/service.yaml b/k8s/base/plugins/oauth2-proxy/service.yaml new file mode 100644 index 0000000..c5e78c8 --- /dev/null +++ b/k8s/base/plugins/oauth2-proxy/service.yaml @@ -0,0 +1,20 @@ +apiVersion: v1 +kind: Service +metadata: + name: oauth2-proxy +spec: + type: ClusterIP + selector: + app.kubernetes.io/name: oauth2-proxy + app.kubernetes.io/instance: oauth2-proxy + ports: + - name: http + port: 4180 + targetPort: http + protocol: TCP + appProtocol: http + - name: metrics + port: 44180 + targetPort: metrics + protocol: TCP + appProtocol: http diff --git a/k8s/base/plugins/oauth2-proxy/serviceaccount.yaml b/k8s/base/plugins/oauth2-proxy/serviceaccount.yaml new file mode 100644 index 0000000..4c069bf --- /dev/null +++ b/k8s/base/plugins/oauth2-proxy/serviceaccount.yaml @@ -0,0 +1,5 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: oauth2-proxy-sa +automountServiceAccountToken: false diff --git a/k8s/base/plugins/vault/clusterrolebinding.yaml b/k8s/base/plugins/vault/clusterrolebinding.yaml new file mode 100644 index 0000000..615699d --- /dev/null +++ b/k8s/base/plugins/vault/clusterrolebinding.yaml @@ -0,0 +1,12 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: vault-tokenreview-binding +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: system:auth-delegator +subjects: + - kind: ServiceAccount + name: vault + namespace: mnt diff --git a/k8s/base/plugins/vault/configmap.yaml b/k8s/base/plugins/vault/configmap.yaml new file mode 100755 index 0000000..3aaccd9 --- /dev/null +++ b/k8s/base/plugins/vault/configmap.yaml @@ -0,0 +1,21 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: vault-config +data: + vault.hcl: | + ui = true + disable_mlock = true + + storage "file" { + path = "/vault/data" + } + + listener "tcp" { + address = "0.0.0.0:8200" + cluster_address = "0.0.0.0:8201" + tls_disable = 1 + } + + api_addr = "http://vault:8200" + cluster_addr = "http://vault:8201" diff --git a/k8s/base/plugins/vault/kustomization.yaml b/k8s/base/plugins/vault/kustomization.yaml new file mode 100755 index 0000000..6d6f056 --- /dev/null +++ b/k8s/base/plugins/vault/kustomization.yaml @@ -0,0 +1,20 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +resources: + - serviceaccount.yaml + - clusterrolebinding.yaml + - configmap.yaml + - service.yaml + - statefulset.yaml + +labels: + - pairs: + app.kubernetes.io/name: vault + app.kubernetes.io/instance: vault + app.kubernetes.io/version: "1.17.2" + app.kubernetes.io/component: secret-management + app.kubernetes.io/part-of: security-platform + app.kubernetes.io/managed-by: kustomize + includeSelectors: false + includeTemplates: true diff --git a/k8s/base/plugins/vault/service.yaml b/k8s/base/plugins/vault/service.yaml new file mode 100755 index 0000000..8bef24b --- /dev/null +++ b/k8s/base/plugins/vault/service.yaml @@ -0,0 +1,20 @@ +apiVersion: v1 +kind: Service +metadata: + name: vault +spec: + type: ClusterIP + selector: + app.kubernetes.io/name: vault + app.kubernetes.io/instance: vault + ports: + - name: http + port: 8200 + targetPort: http + protocol: TCP + appProtocol: http + - name: cluster + port: 8201 + targetPort: cluster + protocol: TCP + appProtocol: http diff --git a/k8s/base/plugins/vault/serviceaccount.yaml b/k8s/base/plugins/vault/serviceaccount.yaml new file mode 100755 index 0000000..ddac6a5 --- /dev/null +++ b/k8s/base/plugins/vault/serviceaccount.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: vault diff --git a/k8s/base/plugins/vault/statefulset.yaml b/k8s/base/plugins/vault/statefulset.yaml new file mode 100755 index 0000000..727a7af --- /dev/null +++ b/k8s/base/plugins/vault/statefulset.yaml @@ -0,0 +1,128 @@ +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: vault +spec: + replicas: 1 + serviceName: vault + podManagementPolicy: OrderedReady + updateStrategy: + type: RollingUpdate + persistentVolumeClaimRetentionPolicy: + whenDeleted: Retain + whenScaled: Retain + selector: + matchLabels: + app.kubernetes.io/name: vault + app.kubernetes.io/instance: vault + template: + metadata: + labels: + app.kubernetes.io/name: vault + app.kubernetes.io/instance: vault + spec: + serviceAccountName: vault + automountServiceAccountToken: true + terminationGracePeriodSeconds: 60 + securityContext: + runAsNonRoot: true + runAsUser: 100 + runAsGroup: 1000 + fsGroup: 1000 + fsGroupChangePolicy: OnRootMismatch + seccompProfile: + type: RuntimeDefault + containers: + - name: vault + image: hashicorp/vault:1.17.2 + imagePullPolicy: IfNotPresent + command: + - vault + - server + - -config=/vault/config/vault.hcl + ports: + - name: http + containerPort: 8200 + protocol: TCP + - name: cluster + containerPort: 8201 + protocol: TCP + env: + - name: VAULT_ADDR + value: "http://127.0.0.1:8200" + - name: VAULT_API_ADDR + value: "http://vault:8200" + - name: SKIP_SETCAP + value: "true" + resources: + requests: + cpu: 250m + memory: 256Mi + limits: + memory: 512Mi + startupProbe: + httpGet: + path: /v1/sys/health?standbyok=true&sealedcode=200&uninitcode=200 + port: http + periodSeconds: 5 + failureThreshold: 30 + timeoutSeconds: 3 + readinessProbe: + httpGet: + path: /v1/sys/health?standbyok=true&sealedcode=503&uninitcode=503 + port: http + periodSeconds: 10 + failureThreshold: 3 + timeoutSeconds: 3 + livenessProbe: + httpGet: + path: /v1/sys/health?standbyok=true&sealedcode=200&uninitcode=200 + port: http + periodSeconds: 30 + failureThreshold: 3 + timeoutSeconds: 3 + securityContext: + runAsNonRoot: true + runAsUser: 100 + runAsGroup: 1000 + allowPrivilegeEscalation: false + privileged: false + readOnlyRootFilesystem: true + capabilities: + drop: + - ALL + seccompProfile: + type: RuntimeDefault + volumeMounts: + - name: data + mountPath: /vault/data + - name: config + mountPath: /vault/config + readOnly: true + - name: tmp + mountPath: /tmp + - name: home + mountPath: /home/vault + volumes: + - name: config + configMap: + name: vault-config + - name: tmp + emptyDir: + medium: Memory + sizeLimit: 64Mi + - name: home + emptyDir: + medium: Memory + sizeLimit: 64Mi + volumeClaimTemplates: + - metadata: + name: data + spec: + accessModes: + - ReadWriteOnce + storageClassName: local-path + volumeMode: Filesystem + resources: + requests: + storage: 5Gi diff --git a/k8s/base/plugins/vso/helm/values.yaml b/k8s/base/plugins/vso/helm/values.yaml new file mode 100755 index 0000000..0877e9c --- /dev/null +++ b/k8s/base/plugins/vso/helm/values.yaml @@ -0,0 +1,39 @@ +defaultVaultConnection: + enabled: false + +controller: + # VSO 는 mnt namespace 에 설치되고 mnt 는 PSS restricted enforce 상태이므로 + # chart 의 Operator Pod 도 Restricted 프로필을 만족해야 한다. + podSecurityContext: + runAsNonRoot: true + seccompProfile: + type: RuntimeDefault + + manager: + resources: + limits: + cpu: 500m + memory: 128Mi + requests: + cpu: 10m + memory: 64Mi + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: + - ALL + seccompProfile: + type: RuntimeDefault + + kubeRbacProxy: + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: + - ALL + seccompProfile: + type: RuntimeDefault + +# kubeRBACProxy : metrics 접근 제어 diff --git a/k8s/base/plugins/vso/kustomization.yaml b/k8s/base/plugins/vso/kustomization.yaml new file mode 100755 index 0000000..0ba0e12 --- /dev/null +++ b/k8s/base/plugins/vso/kustomization.yaml @@ -0,0 +1,18 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +resources: + - serviceaccount.yaml + - vault-connection.yaml + - vault-auth.yaml + +labels: + - pairs: + app.kubernetes.io/name: vault-secrets-operator + app.kubernetes.io/instance: vso + app.kubernetes.io/version: "0.9.0" + app.kubernetes.io/component: secret-delivery + app.kubernetes.io/part-of: security-platform + app.kubernetes.io/managed-by: kustomize + includeSelectors: false + includeTemplates: true diff --git a/k8s/base/plugins/vso/serviceaccount.yaml b/k8s/base/plugins/vso/serviceaccount.yaml new file mode 100755 index 0000000..5d2ad22 --- /dev/null +++ b/k8s/base/plugins/vso/serviceaccount.yaml @@ -0,0 +1,5 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: vault-secrets-operator +automountServiceAccountToken: true diff --git a/k8s/base/plugins/vso/vault-auth.yaml b/k8s/base/plugins/vso/vault-auth.yaml new file mode 100755 index 0000000..614bfa1 --- /dev/null +++ b/k8s/base/plugins/vso/vault-auth.yaml @@ -0,0 +1,27 @@ +apiVersion: secrets.hashicorp.com/v1beta1 +kind: VaultAuth +metadata: + name: vault-auth-auth-platform +spec: + vaultConnectionRef: vault-connection + method: kubernetes + mount: kubernetes + kubernetes: + role: vso-auth-platform + serviceAccount: vault-secrets-operator + audiences: + - vault +--- +apiVersion: secrets.hashicorp.com/v1beta1 +kind: VaultAuth +metadata: + name: vault-auth-storage +spec: + vaultConnectionRef: vault-connection + method: kubernetes + mount: kubernetes + kubernetes: + role: vso-storage + serviceAccount: vault-secrets-operator + audiences: + - vault diff --git a/k8s/base/plugins/vso/vault-connection.yaml b/k8s/base/plugins/vso/vault-connection.yaml new file mode 100755 index 0000000..ce83fdc --- /dev/null +++ b/k8s/base/plugins/vso/vault-connection.yaml @@ -0,0 +1,7 @@ +apiVersion: secrets.hashicorp.com/v1beta1 +kind: VaultConnection +metadata: + name: vault-connection +spec: + address: http://vault.mnt.svc.cluster.local:8200 + skipTLSVerify: false diff --git a/k8s/components/forward-auth/kustomization.yaml b/k8s/components/forward-auth/kustomization.yaml new file mode 100644 index 0000000..27afc41 --- /dev/null +++ b/k8s/components/forward-auth/kustomization.yaml @@ -0,0 +1,19 @@ +apiVersion: kustomize.config.k8s.io/v1alpha1 +kind: Component + +resources: + - ../../base/plugins/oauth2-proxy + - oauth2-proxy-config.yaml + - oauth2-proxy-vault-secrets.yaml + - oauth2-proxy-ingress.yaml + - oauth2-proxy-middleware.yaml + - oauth2-proxy-networkpolicy.yaml + +patches: + - target: + kind: Ingress + name: auth-server + patch: |- + - op: replace + path: /metadata/annotations/traefik.ingress.kubernetes.io~1router.middlewares + value: mnt-oauth2-proxy-errors@kubernetescrd,mnt-oauth2-proxy-auth@kubernetescrd,kube-system-security-headers@kubernetescrd diff --git a/k8s/components/forward-auth/oauth2-proxy-config.yaml b/k8s/components/forward-auth/oauth2-proxy-config.yaml new file mode 100644 index 0000000..c253b61 --- /dev/null +++ b/k8s/components/forward-auth/oauth2-proxy-config.yaml @@ -0,0 +1,46 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: oauth2-proxy-config + labels: + app.kubernetes.io/name: oauth2-proxy + app.kubernetes.io/instance: oauth2-proxy + app.kubernetes.io/version: "7.15.2" + app.kubernetes.io/component: auth-proxy + app.kubernetes.io/part-of: auth-platform + app.kubernetes.io/managed-by: kustomize +data: + oauth2-proxy.cfg: | + provider = "keycloak-oidc" + http_address = "0.0.0.0:4180" + metrics_address = "0.0.0.0:44180" + redirect_url = "http://auth.local.test/oauth2/callback" + + oidc_issuer_url = "http://keycloak.local.test/realms/platform" + skip_oidc_discovery = true + login_url = "http://keycloak.local.test/realms/platform/protocol/openid-connect/auth" + redeem_url = "http://keycloak.mnt.svc.cluster.local/realms/platform/protocol/openid-connect/token" + profile_url = "http://keycloak.mnt.svc.cluster.local/realms/platform/protocol/openid-connect/userinfo" + validate_url = "http://keycloak.mnt.svc.cluster.local/realms/platform/protocol/openid-connect/userinfo" + oidc_jwks_url = "http://keycloak.mnt.svc.cluster.local/realms/platform/protocol/openid-connect/certs" + + client_id = "auth-server-ingress" + client_secret_file = "/etc/oauth2-proxy-secrets/client-secret" + cookie_secret_file = "/etc/oauth2-proxy-secrets/cookie-secret" + reverse_proxy = true + upstreams = [ "static://202" ] + email_domains = [ "*" ] + scope = "openid profile email" + insecure_oidc_allow_unverified_email = true + skip_provider_button = true + cookie_secure = false + cookie_samesite = "lax" + cookie_csrf_per_request = true + cookie_refresh = "4m" + set_xauthrequest = true + set_authorization_header = true + pass_access_token = true + pass_authorization_header = true + ssl_insecure_skip_verify = false + whitelist_domains = [ "auth.local.test" ] + trusted_proxy_ips = [ "10.42.0.0/16" ] diff --git a/k8s/components/forward-auth/oauth2-proxy-ingress.yaml b/k8s/components/forward-auth/oauth2-proxy-ingress.yaml new file mode 100644 index 0000000..80014f5 --- /dev/null +++ b/k8s/components/forward-auth/oauth2-proxy-ingress.yaml @@ -0,0 +1,27 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: oauth2-proxy + labels: + app.kubernetes.io/name: oauth2-proxy + app.kubernetes.io/instance: oauth2-proxy + app.kubernetes.io/version: "7.15.2" + app.kubernetes.io/component: auth-proxy + app.kubernetes.io/part-of: auth-platform + app.kubernetes.io/managed-by: kustomize + annotations: + traefik.ingress.kubernetes.io/router.entrypoints: web + traefik.ingress.kubernetes.io/router.middlewares: kube-system-security-headers@kubernetescrd +spec: + ingressClassName: traefik + rules: + - host: auth.local.test + http: + paths: + - path: /oauth2/ + pathType: Prefix + backend: + service: + name: oauth2-proxy + port: + name: http diff --git a/k8s/components/forward-auth/oauth2-proxy-middleware.yaml b/k8s/components/forward-auth/oauth2-proxy-middleware.yaml new file mode 100644 index 0000000..383500f --- /dev/null +++ b/k8s/components/forward-auth/oauth2-proxy-middleware.yaml @@ -0,0 +1,45 @@ +apiVersion: traefik.io/v1alpha1 +kind: Middleware +metadata: + name: oauth2-proxy-auth + labels: + app.kubernetes.io/name: oauth2-proxy + app.kubernetes.io/instance: oauth2-proxy + app.kubernetes.io/version: "7.15.2" + app.kubernetes.io/component: auth-proxy + app.kubernetes.io/part-of: auth-platform + app.kubernetes.io/managed-by: kustomize +spec: + forwardAuth: + address: http://oauth2-proxy.mnt.svc.cluster.local:4180/oauth2/auth + trustForwardHeader: true + authResponseHeaders: + - Authorization + - X-Auth-Request-Access-Token + - X-Auth-Request-Email + - X-Auth-Request-Preferred-Username + - X-Auth-Request-User + - X-Forwarded-Email + - X-Forwarded-User +--- +apiVersion: traefik.io/v1alpha1 +kind: Middleware +metadata: + name: oauth2-proxy-errors + labels: + app.kubernetes.io/name: oauth2-proxy + app.kubernetes.io/instance: oauth2-proxy + app.kubernetes.io/version: "7.15.2" + app.kubernetes.io/component: auth-proxy + app.kubernetes.io/part-of: auth-platform + app.kubernetes.io/managed-by: kustomize +spec: + errors: + status: + - "401" + statusRewrites: + "401": 302 + service: + name: oauth2-proxy + port: 4180 + query: /oauth2/start?rd={url} diff --git a/k8s/components/forward-auth/oauth2-proxy-networkpolicy.yaml b/k8s/components/forward-auth/oauth2-proxy-networkpolicy.yaml new file mode 100644 index 0000000..082c965 --- /dev/null +++ b/k8s/components/forward-auth/oauth2-proxy-networkpolicy.yaml @@ -0,0 +1,55 @@ +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: oauth2-proxy-ingress-traefik + labels: + app.kubernetes.io/name: oauth2-proxy + app.kubernetes.io/instance: oauth2-proxy + app.kubernetes.io/component: auth-proxy + app.kubernetes.io/part-of: auth-platform + app.kubernetes.io/managed-by: kustomize +spec: + podSelector: + matchLabels: + app.kubernetes.io/name: oauth2-proxy + app.kubernetes.io/instance: oauth2-proxy + policyTypes: + - Ingress + ingress: + - from: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: kube-system + podSelector: + matchLabels: + app.kubernetes.io/name: traefik + ports: + - protocol: TCP + port: 4180 +--- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: oauth2-proxy-egress-keycloak + labels: + app.kubernetes.io/name: oauth2-proxy + app.kubernetes.io/instance: oauth2-proxy + app.kubernetes.io/component: auth-proxy + app.kubernetes.io/part-of: auth-platform + app.kubernetes.io/managed-by: kustomize +spec: + podSelector: + matchLabels: + app.kubernetes.io/name: oauth2-proxy + app.kubernetes.io/instance: oauth2-proxy + policyTypes: + - Egress + egress: + - to: + - podSelector: + matchLabels: + app.kubernetes.io/instance: keycloak + app.kubernetes.io/managed-by: keycloak-operator + ports: + - protocol: TCP + port: 8080 diff --git a/k8s/components/forward-auth/oauth2-proxy-vault-secrets.yaml b/k8s/components/forward-auth/oauth2-proxy-vault-secrets.yaml new file mode 100644 index 0000000..159d749 --- /dev/null +++ b/k8s/components/forward-auth/oauth2-proxy-vault-secrets.yaml @@ -0,0 +1,24 @@ +apiVersion: secrets.hashicorp.com/v1beta1 +kind: VaultStaticSecret +metadata: + name: oauth2-proxy-secrets +spec: + vaultAuthRef: vault-auth-auth-platform + mount: secret + path: oauth2-proxy/forward-auth + refreshAfter: 60s + type: kv-v2 + destination: + name: oauth2-proxy-secrets + create: true + labels: + app.kubernetes.io/name: oauth2-proxy + app.kubernetes.io/component: auth-proxy + app.kubernetes.io/managed-by: vault-secrets-operator + transformation: + excludeRaw: true + templates: + client-secret: + text: '{{ if index .Secrets "client_secret" }}{{ index .Secrets "client_secret" }}{{ else }}{{ index .Secrets "client-secret" }}{{ end }}' + cookie-secret: + text: '{{ if index .Secrets "cookie_secret" }}{{ index .Secrets "cookie_secret" }}{{ else }}{{ index .Secrets "cookie-secret" }}{{ end }}' diff --git a/k8s/overlays/AGENTS.md b/k8s/overlays/AGENTS.md new file mode 100644 index 0000000..0dd5b67 --- /dev/null +++ b/k8s/overlays/AGENTS.md @@ -0,0 +1,44 @@ +# k8s/overlays AGENTS + +Role: +- own environment-specific Kustomize composition +- express only the differences between environments and reusable bases +- make environment-level render, diff, apply, audit, and GitOps sync straightforward + +Scope: +- `dev/` +- `staging/` +- `prod/` + +Allowed: +- namespace selection +- replicas +- resource requests/limits overrides +- image tags or image references +- ingress host/TLS differences +- environment-specific patches +- environment-specific secret references +- storage class and retention differences + +Forbidden: +- copying full base manifests into overlays +- redefining base resources wholesale without a clear environment-only reason +- placing production secret values in Git +- changing service ownership or workload kind without updating the base and standards +- mixing multiple environments in one overlay + +Read first: +- `/docs/standards/infra/kustomize.md` +- `/docs/standards/infra/architecture-environments.md` +- `/docs/standards/infra/config-and-secrets.md` +- `/docs/standards/infra/network-ingress-tls.md` +- `/docs/standards/infra/resources-probes-availability.md` +- `/docs/standards/infra/security-hardening.md` +- `/docs/standards/infra/operations-runbook-upgrade-rollback.md` + +Rules: +- overlays are environment-first by design +- keep patches small and named by target and intent +- render and diff the target environment before apply +- prod overlays must be the most conservative environment +- if an overlay starts re-declaring most of a resource, move common shape back into `k8s/base` diff --git a/k8s/overlays/README.md b/k8s/overlays/README.md new file mode 100644 index 0000000..b54e740 --- /dev/null +++ b/k8s/overlays/README.md @@ -0,0 +1,19 @@ +# Environment Overlays + +Overlays contain environment-specific differences. + +Required shape: + +```text +overlays/<env>/ +├── kustomization.yaml +├── app/ +├── managing/ +└── plugins/ +``` + +Rules: +- Overlay packages reuse base packages. +- Overlay packages contain patches, generated config, or environment-specific resources only. +- Do not duplicate base package composition in overlays. +- A workload override belongs at `overlays/<env>/app/units/<unit>/<domain>/<workload-kind>/<workload>/`. diff --git a/k8s/overlays/dev/auth/ingress.yaml b/k8s/overlays/dev/auth/ingress.yaml new file mode 100644 index 0000000..1f98a15 --- /dev/null +++ b/k8s/overlays/dev/auth/ingress.yaml @@ -0,0 +1,27 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: auth-server + labels: + app.kubernetes.io/name: auth-server + app.kubernetes.io/instance: auth-server + app.kubernetes.io/version: "0.1.0" + app.kubernetes.io/component: api + app.kubernetes.io/part-of: auth-platform + app.kubernetes.io/managed-by: kustomize + annotations: + traefik.ingress.kubernetes.io/router.entrypoints: web + traefik.ingress.kubernetes.io/router.middlewares: kube-system-security-headers@kubernetescrd +spec: + ingressClassName: traefik + rules: + - host: auth.local.test + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: auth-server + port: + name: http diff --git a/k8s/overlays/dev/auth/kustomization.yaml b/k8s/overlays/dev/auth/kustomization.yaml new file mode 100644 index 0000000..f82fe9b --- /dev/null +++ b/k8s/overlays/dev/auth/kustomization.yaml @@ -0,0 +1,50 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +namespace: mnt + +resources: + - ../../../base/app/identity/auth/stateless/auth-server + - ../../../base/managing/migration-flyway + - ingress.yaml + - networkpolicy.yaml + +images: + - name: registry.example.com/auth-platform/auth-server + newName: registry.project.com/auth-platform/auth-server + newTag: manual-20260512071751 + +patches: + - target: + kind: ConfigMap + name: auth-server-config + patch: |- + - op: add + path: /data/APP_SECURITY_KEYCLOAK_ISSUER_URI + value: http://keycloak.local.test/realms/platform + - op: add + path: /data/SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_ISSUER_URI + value: http://keycloak.local.test/realms/platform + - op: add + path: /data/SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_JWK_SET_URI + value: http://keycloak/realms/platform/protocol/openid-connect/certs + - op: add + path: /data/SERVER_MAX_HTTP_REQUEST_HEADER_SIZE + value: 64KB + - target: + kind: ServiceAccount + name: auth-server-sa + patch: |- + - op: add + path: /imagePullSecrets + value: + - name: docker-registry-pull-credentials + - target: + kind: Job + name: migration-flyway + patch: |- + - op: add + path: /metadata/annotations + value: + argocd.argoproj.io/sync-wave: "-1" + argocd.argoproj.io/hook: PreSync diff --git a/k8s/overlays/dev/auth/networkpolicy.yaml b/k8s/overlays/dev/auth/networkpolicy.yaml new file mode 100644 index 0000000..70e89cf --- /dev/null +++ b/k8s/overlays/dev/auth/networkpolicy.yaml @@ -0,0 +1,90 @@ +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: auth-server-ingress-traefik + labels: + app.kubernetes.io/name: auth-server + app.kubernetes.io/instance: auth-server + app.kubernetes.io/component: api + app.kubernetes.io/part-of: auth-platform + app.kubernetes.io/managed-by: kustomize +spec: + podSelector: + matchLabels: + app.kubernetes.io/name: auth-server + app.kubernetes.io/instance: auth-server + policyTypes: + - Ingress + ingress: + - from: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: kube-system + podSelector: + matchLabels: + app.kubernetes.io/name: traefik + ports: + - protocol: TCP + port: 8080 +--- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: auth-server-egress + labels: + app.kubernetes.io/name: auth-server + app.kubernetes.io/instance: auth-server + app.kubernetes.io/component: api + app.kubernetes.io/part-of: auth-platform + app.kubernetes.io/managed-by: kustomize +spec: + podSelector: + matchLabels: + app.kubernetes.io/name: auth-server + app.kubernetes.io/instance: auth-server + policyTypes: + - Egress + egress: + - to: + - podSelector: + matchLabels: + app.kubernetes.io/name: identity-postgres + app.kubernetes.io/instance: identity-postgres + ports: + - protocol: TCP + port: 5432 + - to: + - podSelector: + matchLabels: + app.kubernetes.io/instance: keycloak + app.kubernetes.io/managed-by: keycloak-operator + ports: + - protocol: TCP + port: 8080 +--- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: migration-flyway-egress + labels: + app.kubernetes.io/name: migration-flyway + app.kubernetes.io/instance: migration-flyway + app.kubernetes.io/component: migration + app.kubernetes.io/part-of: auth-platform + app.kubernetes.io/managed-by: kustomize +spec: + podSelector: + matchLabels: + app.kubernetes.io/name: migration-flyway + app.kubernetes.io/instance: migration-flyway + policyTypes: + - Egress + egress: + - to: + - podSelector: + matchLabels: + app.kubernetes.io/name: identity-postgres + app.kubernetes.io/instance: identity-postgres + ports: + - protocol: TCP + port: 5432 diff --git a/k8s/overlays/dev/database/kustomization.yaml b/k8s/overlays/dev/database/kustomization.yaml new file mode 100644 index 0000000..9d7eaf6 --- /dev/null +++ b/k8s/overlays/dev/database/kustomization.yaml @@ -0,0 +1,21 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +namespace: mnt + +resources: + - ../../../base/app/identity/auth/stateful/identity-postgres + - vault-secrets.yaml + - networkpolicy.yaml + +patches: + - target: + kind: StatefulSet + name: identity-postgres + patch: |- + - op: replace + path: /spec/persistentVolumeClaimRetentionPolicy/whenDeleted + value: Delete + - op: replace + path: /spec/persistentVolumeClaimRetentionPolicy/whenScaled + value: Delete diff --git a/k8s/overlays/dev/database/networkpolicy.yaml b/k8s/overlays/dev/database/networkpolicy.yaml new file mode 100644 index 0000000..74c693f --- /dev/null +++ b/k8s/overlays/dev/database/networkpolicy.yaml @@ -0,0 +1,34 @@ +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: identity-postgres-ingress-clients + labels: + app.kubernetes.io/name: identity-postgres + app.kubernetes.io/instance: identity-postgres + app.kubernetes.io/component: database + app.kubernetes.io/part-of: auth-platform + app.kubernetes.io/managed-by: kustomize +spec: + podSelector: + matchLabels: + app.kubernetes.io/name: identity-postgres + app.kubernetes.io/instance: identity-postgres + policyTypes: + - Ingress + ingress: + - from: + - podSelector: + matchLabels: + app.kubernetes.io/instance: keycloak + app.kubernetes.io/managed-by: keycloak-operator + - podSelector: + matchLabels: + app.kubernetes.io/name: auth-server + app.kubernetes.io/instance: auth-server + - podSelector: + matchLabels: + app.kubernetes.io/name: migration-flyway + app.kubernetes.io/instance: migration-flyway + ports: + - protocol: TCP + port: 5432 diff --git a/k8s/overlays/dev/database/vault-secrets.yaml b/k8s/overlays/dev/database/vault-secrets.yaml new file mode 100644 index 0000000..f8f032d --- /dev/null +++ b/k8s/overlays/dev/database/vault-secrets.yaml @@ -0,0 +1,63 @@ +apiVersion: secrets.hashicorp.com/v1beta1 +kind: VaultStaticSecret +metadata: + name: identity-postgres-superuser +spec: + vaultAuthRef: vault-auth-auth-platform + mount: secret + type: kv-v2 + path: identity-postgres/superuser + refreshAfter: 60s + destination: + name: identity-postgres-superuser + create: true + labels: + app.kubernetes.io/name: identity-postgres + app.kubernetes.io/component: database + app.kubernetes.io/managed-by: vault-secrets-operator +--- +apiVersion: secrets.hashicorp.com/v1beta1 +kind: VaultStaticSecret +metadata: + name: keycloak-db-creds +spec: + vaultAuthRef: vault-auth-auth-platform + mount: secret + type: kv-v2 + path: keycloak/db + refreshAfter: 60s + destination: + name: keycloak-db + create: true + labels: + app.kubernetes.io/name: keycloak + app.kubernetes.io/component: database + app.kubernetes.io/managed-by: vault-secrets-operator +--- +apiVersion: secrets.hashicorp.com/v1beta1 +kind: VaultStaticSecret +metadata: + name: auth-server-db-creds +spec: + vaultAuthRef: vault-auth-auth-platform + mount: secret + type: kv-v2 + path: auth-server/db + refreshAfter: 60s + destination: + name: auth-server-db + create: true + labels: + app.kubernetes.io/name: auth-server + app.kubernetes.io/component: database + app.kubernetes.io/managed-by: vault-secrets-operator + transformation: + templates: + SPRING_DATASOURCE_USERNAME: + text: '{{ index .Secrets "SPRING_DATASOURCE_USERNAME" }}' + SPRING_DATASOURCE_PASSWORD: + text: '{{ index .Secrets "SPRING_DATASOURCE_PASSWORD" }}' + APP_DATASOURCE_USERNAME: + text: '{{ index .Secrets "SPRING_DATASOURCE_USERNAME" }}' + APP_DATASOURCE_PASSWORD: + text: '{{ index .Secrets "SPRING_DATASOURCE_PASSWORD" }}' diff --git a/k8s/overlays/dev/keycloak-realm/kustomization.yaml b/k8s/overlays/dev/keycloak-realm/kustomization.yaml new file mode 100644 index 0000000..2455c52 --- /dev/null +++ b/k8s/overlays/dev/keycloak-realm/kustomization.yaml @@ -0,0 +1,7 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +namespace: mnt + +resources: + - platform-realm-import.yaml diff --git a/k8s/overlays/dev/keycloak-realm/platform-realm-import.yaml b/k8s/overlays/dev/keycloak-realm/platform-realm-import.yaml new file mode 100644 index 0000000..de2f5b3 --- /dev/null +++ b/k8s/overlays/dev/keycloak-realm/platform-realm-import.yaml @@ -0,0 +1,62 @@ +apiVersion: k8s.keycloak.org/v2beta1 +kind: KeycloakRealmImport +metadata: + name: platform-realm + labels: + app.kubernetes.io/name: keycloak + app.kubernetes.io/instance: keycloak + app.kubernetes.io/version: "26.6.1" + app.kubernetes.io/component: identity-provider + app.kubernetes.io/part-of: auth-platform + app.kubernetes.io/managed-by: kustomize +spec: + keycloakCRName: keycloak + placeholders: + AUTH_SERVER_INGRESS_CLIENT_SECRET: + secret: + name: keycloak-client-auth-server-ingress + key: client-secret + realm: + realm: platform + enabled: true + sslRequired: external + registrationAllowed: true + loginWithEmailAllowed: true + accessTokenLifespan: 300 + defaultRoles: + - user + roles: + realm: + - name: user + clients: + - clientId: auth-server-ingress + name: auth-server-ingress + protocol: openid-connect + publicClient: false + clientAuthenticatorType: client-secret + secret: ${AUTH_SERVER_INGRESS_CLIENT_SECRET} + standardFlowEnabled: true + directAccessGrantsEnabled: false + serviceAccountsEnabled: false + defaultClientScopes: + - profile + - email + - roles + protocolMappers: + - name: realm roles in id token + protocol: openid-connect + protocolMapper: oidc-usermodel-realm-role-mapper + config: + user.attribute: foo + claim.name: realm_access.roles + jsonType.label: String + multivalued: "true" + access.token.claim: "true" + id.token.claim: "true" + introspection.token.claim: "true" + redirectUris: + - http://auth.local.test/oauth2/callback + webOrigins: + - http://auth.local.test + rootUrl: http://auth.local.test + baseUrl: http://auth.local.test diff --git a/k8s/overlays/dev/keycloak/ingress-admin.yaml b/k8s/overlays/dev/keycloak/ingress-admin.yaml new file mode 100644 index 0000000..5af8855 --- /dev/null +++ b/k8s/overlays/dev/keycloak/ingress-admin.yaml @@ -0,0 +1,27 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: keycloak-admin + labels: + app.kubernetes.io/name: keycloak + app.kubernetes.io/instance: keycloak + app.kubernetes.io/version: "26.6.1" + app.kubernetes.io/component: identity-provider + app.kubernetes.io/part-of: auth-platform + app.kubernetes.io/managed-by: kustomize + annotations: + traefik.ingress.kubernetes.io/router.entrypoints: web + traefik.ingress.kubernetes.io/router.middlewares: mnt-keycloak-security-headers@kubernetescrd +spec: + ingressClassName: traefik + rules: + - host: keycloak-admin.local.test + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: keycloak + port: + name: http diff --git a/k8s/overlays/dev/keycloak/ingress-public.yaml b/k8s/overlays/dev/keycloak/ingress-public.yaml new file mode 100644 index 0000000..6321b04 --- /dev/null +++ b/k8s/overlays/dev/keycloak/ingress-public.yaml @@ -0,0 +1,48 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: keycloak-public + labels: + app.kubernetes.io/name: keycloak + app.kubernetes.io/instance: keycloak + app.kubernetes.io/version: "26.6.1" + app.kubernetes.io/component: identity-provider + app.kubernetes.io/part-of: auth-platform + app.kubernetes.io/managed-by: kustomize + annotations: + traefik.ingress.kubernetes.io/router.entrypoints: web + traefik.ingress.kubernetes.io/router.middlewares: mnt-keycloak-security-headers@kubernetescrd # frameDeny 제외 — admin 콘솔의 silent SSO iframe 허용 +spec: + ingressClassName: traefik + rules: + - host: keycloak.local.test + http: + paths: + - path: /realms/ + pathType: Prefix + backend: + service: + name: keycloak + port: + name: http + - path: /resources/ + pathType: Prefix + backend: + service: + name: keycloak + port: + name: http + - path: /.well-known/ + pathType: Prefix + backend: + service: + name: keycloak + port: + name: http + - path: /js/ + pathType: Prefix + backend: + service: + name: keycloak + port: + name: http diff --git a/k8s/overlays/dev/keycloak/keycloak-hostname-patch.yaml b/k8s/overlays/dev/keycloak/keycloak-hostname-patch.yaml new file mode 100644 index 0000000..91f45b6 --- /dev/null +++ b/k8s/overlays/dev/keycloak/keycloak-hostname-patch.yaml @@ -0,0 +1,8 @@ +apiVersion: k8s.keycloak.org/v2beta1 +kind: Keycloak +metadata: + name: keycloak +spec: + hostname: + hostname: http://keycloak.local.test + admin: http://keycloak-admin.local.test diff --git a/k8s/overlays/dev/keycloak/kustomization.yaml b/k8s/overlays/dev/keycloak/kustomization.yaml new file mode 100644 index 0000000..0d6b97a --- /dev/null +++ b/k8s/overlays/dev/keycloak/kustomization.yaml @@ -0,0 +1,18 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +namespace: mnt + +resources: + - ../../../base/app/identity/keycloak/stateless/keycloak + - vault-secrets.yaml + - middleware.yaml + - ingress-public.yaml + - ingress-admin.yaml + - networkpolicy.yaml + +patches: + - target: + kind: Keycloak + name: keycloak + path: keycloak-hostname-patch.yaml diff --git a/k8s/overlays/dev/keycloak/middleware.yaml b/k8s/overlays/dev/keycloak/middleware.yaml new file mode 100644 index 0000000..9f46671 --- /dev/null +++ b/k8s/overlays/dev/keycloak/middleware.yaml @@ -0,0 +1,15 @@ +apiVersion: traefik.io/v1alpha1 +kind: Middleware +metadata: + name: keycloak-security-headers + labels: + app.kubernetes.io/name: keycloak + app.kubernetes.io/instance: keycloak + app.kubernetes.io/component: identity-provider + app.kubernetes.io/part-of: auth-platform + app.kubernetes.io/managed-by: kustomize +spec: + headers: + browserXssFilter: true + contentTypeNosniff: true + referrerPolicy: strict-origin-when-cross-origin diff --git a/k8s/overlays/dev/keycloak/networkpolicy.yaml b/k8s/overlays/dev/keycloak/networkpolicy.yaml new file mode 100644 index 0000000..d5dcb66 --- /dev/null +++ b/k8s/overlays/dev/keycloak/networkpolicy.yaml @@ -0,0 +1,140 @@ +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: keycloak-peer + labels: + app.kubernetes.io/name: keycloak + app.kubernetes.io/instance: keycloak + app.kubernetes.io/component: identity-provider + app.kubernetes.io/part-of: auth-platform + app.kubernetes.io/managed-by: kustomize +spec: + podSelector: + matchLabels: + app.kubernetes.io/instance: keycloak + app.kubernetes.io/managed-by: keycloak-operator + policyTypes: + - Ingress + - Egress + ingress: + - from: + - podSelector: + matchLabels: + app.kubernetes.io/instance: keycloak + app.kubernetes.io/managed-by: keycloak-operator + egress: + - to: + - podSelector: + matchLabels: + app.kubernetes.io/instance: keycloak + app.kubernetes.io/managed-by: keycloak-operator +--- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: keycloak-ingress-traefik + labels: + app.kubernetes.io/name: keycloak + app.kubernetes.io/instance: keycloak + app.kubernetes.io/component: identity-provider + app.kubernetes.io/part-of: auth-platform + app.kubernetes.io/managed-by: kustomize +spec: + podSelector: + matchLabels: + app.kubernetes.io/instance: keycloak + app.kubernetes.io/managed-by: keycloak-operator + policyTypes: + - Ingress + ingress: + - from: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: kube-system + podSelector: + matchLabels: + app.kubernetes.io/name: traefik + ports: + - protocol: TCP + port: 8080 +--- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: keycloak-egress-postgres + labels: + app.kubernetes.io/name: keycloak + app.kubernetes.io/instance: keycloak + app.kubernetes.io/component: identity-provider + app.kubernetes.io/part-of: auth-platform + app.kubernetes.io/managed-by: kustomize +spec: + podSelector: + matchLabels: + app.kubernetes.io/instance: keycloak + app.kubernetes.io/managed-by: keycloak-operator + policyTypes: + - Egress + egress: + - to: + - podSelector: + matchLabels: + app.kubernetes.io/name: identity-postgres + app.kubernetes.io/instance: identity-postgres + ports: + - protocol: TCP + port: 5432 +--- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: keycloak-ingress-auth-server + labels: + app.kubernetes.io/name: keycloak + app.kubernetes.io/instance: keycloak + app.kubernetes.io/component: identity-provider + app.kubernetes.io/part-of: auth-platform + app.kubernetes.io/managed-by: kustomize +spec: + podSelector: + matchLabels: + app.kubernetes.io/instance: keycloak + app.kubernetes.io/managed-by: keycloak-operator + policyTypes: + - Ingress + ingress: + - from: + - podSelector: + matchLabels: + app.kubernetes.io/name: auth-server + app.kubernetes.io/instance: auth-server + ports: + - protocol: TCP + port: 8080 +--- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: keycloak-ingress-oauth2-proxy + labels: + app.kubernetes.io/name: keycloak + app.kubernetes.io/instance: keycloak + app.kubernetes.io/component: identity-provider + app.kubernetes.io/part-of: auth-platform + app.kubernetes.io/managed-by: kustomize +spec: + podSelector: + matchLabels: + app.kubernetes.io/instance: keycloak + app.kubernetes.io/managed-by: keycloak-operator + policyTypes: + - Ingress + ingress: + - from: + - podSelector: + matchLabels: + app.kubernetes.io/name: oauth2-proxy + app.kubernetes.io/instance: oauth2-proxy + ports: + - protocol: TCP + port: 8080 diff --git a/k8s/overlays/dev/keycloak/vault-secrets.yaml b/k8s/overlays/dev/keycloak/vault-secrets.yaml new file mode 100644 index 0000000..daa536f --- /dev/null +++ b/k8s/overlays/dev/keycloak/vault-secrets.yaml @@ -0,0 +1,90 @@ +apiVersion: secrets.hashicorp.com/v1beta1 +kind: VaultStaticSecret +metadata: + name: keycloak-bootstrap-admin +spec: + vaultAuthRef: vault-auth-auth-platform + mount: secret + type: kv-v2 + path: keycloak/bootstrap-admin + refreshAfter: 60s + destination: + name: keycloak-bootstrap-admin + create: true + labels: + app.kubernetes.io/name: keycloak + app.kubernetes.io/component: identity-provider + app.kubernetes.io/managed-by: vault-secrets-operator +--- +apiVersion: secrets.hashicorp.com/v1beta1 +kind: VaultStaticSecret +metadata: + name: keycloak-bootstrap-admin-operator +spec: + vaultAuthRef: vault-auth-auth-platform + mount: secret + type: kv-v2 + path: keycloak/bootstrap-admin + refreshAfter: 60s + destination: + name: keycloak-bootstrap-admin-operator + create: true + labels: + app.kubernetes.io/name: keycloak + app.kubernetes.io/component: identity-provider + app.kubernetes.io/managed-by: vault-secrets-operator + transformation: + excludeRaw: true + templates: + username: + text: '{{ .Secrets.KEYCLOAK_ADMIN }}' + password: + text: '{{ .Secrets.KEYCLOAK_ADMIN_PASSWORD }}' +--- +apiVersion: secrets.hashicorp.com/v1beta1 +kind: VaultStaticSecret +metadata: + name: keycloak-db-operator +spec: + vaultAuthRef: vault-auth-auth-platform + mount: secret + type: kv-v2 + path: keycloak/db + refreshAfter: 60s + destination: + name: keycloak-db-operator + create: true + labels: + app.kubernetes.io/name: keycloak + app.kubernetes.io/component: database + app.kubernetes.io/managed-by: vault-secrets-operator + transformation: + excludeRaw: true + templates: + username: + text: keycloak + password: + text: '{{ .Secrets.password }}' +--- +apiVersion: secrets.hashicorp.com/v1beta1 +kind: VaultStaticSecret +metadata: + name: keycloak-client-auth-server-ingress +spec: + vaultAuthRef: vault-auth-auth-platform + mount: secret + type: kv-v2 + path: keycloak/clients/auth-server-ingress + refreshAfter: 60s + destination: + name: keycloak-client-auth-server-ingress + create: true + labels: + app.kubernetes.io/name: keycloak + app.kubernetes.io/component: identity-provider + app.kubernetes.io/managed-by: vault-secrets-operator + transformation: + excludeRaw: true + templates: + client-secret: + text: '{{ .Secrets.client_secret }}' diff --git a/k8s/overlays/dev/kustomization.yaml b/k8s/overlays/dev/kustomization.yaml new file mode 100644 index 0000000..d5d9192 --- /dev/null +++ b/k8s/overlays/dev/kustomization.yaml @@ -0,0 +1,18 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +namespace: mnt + +resources: + - ../../base/managing + - networkpolicy-baseline.yaml + - vault + - registry + - database + - auth + - keycloak + - storage + - keycloak-realm + +components: + - ../../components/forward-auth diff --git a/k8s/overlays/dev/networkpolicy-baseline.yaml b/k8s/overlays/dev/networkpolicy-baseline.yaml new file mode 100644 index 0000000..fde5bb9 --- /dev/null +++ b/k8s/overlays/dev/networkpolicy-baseline.yaml @@ -0,0 +1,43 @@ +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: default-deny-all + labels: + app.kubernetes.io/name: namespace-baseline + app.kubernetes.io/instance: mnt + app.kubernetes.io/component: network-policy + app.kubernetes.io/part-of: infra-platform + app.kubernetes.io/managed-by: kustomize +spec: + podSelector: {} + policyTypes: + - Ingress + - Egress +--- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: allow-dns-egress + labels: + app.kubernetes.io/name: namespace-baseline + app.kubernetes.io/instance: mnt + app.kubernetes.io/component: network-policy + app.kubernetes.io/part-of: infra-platform + app.kubernetes.io/managed-by: kustomize +spec: + podSelector: {} + policyTypes: + - Egress + egress: + - to: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: kube-system + podSelector: + matchLabels: + k8s-app: kube-dns + ports: + - protocol: UDP + port: 53 + - protocol: TCP + port: 53 diff --git a/k8s/overlays/dev/platform/cert-manager/kustomization.yaml b/k8s/overlays/dev/platform/cert-manager/kustomization.yaml new file mode 100644 index 0000000..b9138ad --- /dev/null +++ b/k8s/overlays/dev/platform/cert-manager/kustomization.yaml @@ -0,0 +1,5 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +resources: + - https://github.com/cert-manager/cert-manager/releases/download/v1.20.2/cert-manager.yaml diff --git a/k8s/overlays/dev/platform/keycloak-operator/kustomization.yaml b/k8s/overlays/dev/platform/keycloak-operator/kustomization.yaml new file mode 100644 index 0000000..40f3471 --- /dev/null +++ b/k8s/overlays/dev/platform/keycloak-operator/kustomization.yaml @@ -0,0 +1,39 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +namespace: mnt + +resources: + - https://raw.githubusercontent.com/keycloak/keycloak-k8s-resources/26.6.1/kubernetes/keycloaks.k8s.keycloak.org-v1.yml + - https://raw.githubusercontent.com/keycloak/keycloak-k8s-resources/26.6.1/kubernetes/keycloakrealmimports.k8s.keycloak.org-v1.yml + - https://raw.githubusercontent.com/keycloak/keycloak-k8s-resources/26.6.1/kubernetes/kubernetes.yml + - networkpolicy.yaml + +patches: + - target: + kind: ClusterRoleBinding + name: keycloak-operator-clusterrole-binding + patch: |- + - op: replace + path: /subjects/0/namespace + value: mnt + - target: + kind: Deployment + name: keycloak-operator + patch: |- + - op: add + path: /spec/template/spec/securityContext + value: + runAsNonRoot: true + seccompProfile: + type: RuntimeDefault + - op: add + path: /spec/template/spec/containers/0/securityContext + value: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + - op: replace + path: /spec/template/spec/containers/0/startupProbe/failureThreshold + value: 18 diff --git a/k8s/overlays/dev/platform/keycloak-operator/networkpolicy.yaml b/k8s/overlays/dev/platform/keycloak-operator/networkpolicy.yaml new file mode 100644 index 0000000..b08c881 --- /dev/null +++ b/k8s/overlays/dev/platform/keycloak-operator/networkpolicy.yaml @@ -0,0 +1,29 @@ +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: keycloak-operator-egress-kubernetes-api + labels: + app.kubernetes.io/name: keycloak-operator + app.kubernetes.io/instance: keycloak-operator + app.kubernetes.io/component: identity-operator + app.kubernetes.io/part-of: auth-platform + app.kubernetes.io/managed-by: kustomize +spec: + podSelector: + matchLabels: + app.kubernetes.io/name: keycloak-operator + policyTypes: + - Egress + egress: + - to: + - ipBlock: + cidr: 10.43.0.0/16 + ports: + - protocol: TCP + port: 443 + - to: + - ipBlock: + cidr: 10.208.141.0/24 + ports: + - protocol: TCP + port: 6443 diff --git a/k8s/overlays/dev/platform/traefik/dashboard-service.yaml b/k8s/overlays/dev/platform/traefik/dashboard-service.yaml new file mode 100644 index 0000000..b209a0e --- /dev/null +++ b/k8s/overlays/dev/platform/traefik/dashboard-service.yaml @@ -0,0 +1,21 @@ +apiVersion: v1 +kind: Service +metadata: + name: traefik-dashboard + labels: + app.kubernetes.io/name: traefik + app.kubernetes.io/instance: traefik-dev + app.kubernetes.io/component: ingress-controller + app.kubernetes.io/part-of: platform + app.kubernetes.io/managed-by: kustomize +spec: + type: ClusterIP + selector: + app.kubernetes.io/name: traefik + app.kubernetes.io/instance: traefik-kube-system + ports: + - name: admin + port: 8080 + targetPort: traefik + protocol: TCP + appProtocol: http diff --git a/k8s/overlays/dev/platform/traefik/helmchartconfig.yaml b/k8s/overlays/dev/platform/traefik/helmchartconfig.yaml new file mode 100644 index 0000000..ef20c3f --- /dev/null +++ b/k8s/overlays/dev/platform/traefik/helmchartconfig.yaml @@ -0,0 +1,44 @@ +apiVersion: helm.cattle.io/v1 +kind: HelmChartConfig +metadata: + name: traefik + labels: + app.kubernetes.io/name: traefik + app.kubernetes.io/instance: traefik-dev + app.kubernetes.io/component: ingress-controller + app.kubernetes.io/part-of: platform + app.kubernetes.io/managed-by: kustomize +spec: + valuesContent: |- + api: + dashboard: true + deployment: + replicas: 2 + ingressRoute: + dashboard: + enabled: true + entryPoints: + - traefik + service: + spec: + externalTrafficPolicy: Local + ports: + web: + tls: + enabled: false + websecure: + tls: + enabled: false + ingressClass: + enabled: true + isDefaultClass: true + additionalArguments: + - "--providers.kubernetesingress.ingressclass=traefik" + - "--metrics.prometheus=true" + resources: + requests: + cpu: 100m + memory: 128Mi + limits: + cpu: 500m + memory: 256Mi diff --git a/k8s/overlays/dev/platform/traefik/kustomization.yaml b/k8s/overlays/dev/platform/traefik/kustomization.yaml new file mode 100644 index 0000000..c0c13e9 --- /dev/null +++ b/k8s/overlays/dev/platform/traefik/kustomization.yaml @@ -0,0 +1,9 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +namespace: kube-system + +resources: + - dashboard-service.yaml + - helmchartconfig.yaml + - middleware.yaml diff --git a/k8s/overlays/dev/platform/traefik/middleware.yaml b/k8s/overlays/dev/platform/traefik/middleware.yaml new file mode 100644 index 0000000..45160de --- /dev/null +++ b/k8s/overlays/dev/platform/traefik/middleware.yaml @@ -0,0 +1,16 @@ +apiVersion: traefik.io/v1alpha1 +kind: Middleware +metadata: + name: security-headers + labels: + app.kubernetes.io/name: traefik + app.kubernetes.io/instance: traefik-dev + app.kubernetes.io/component: ingress-controller + app.kubernetes.io/part-of: platform + app.kubernetes.io/managed-by: kustomize +spec: + headers: + contentTypeNosniff: true + browserXssFilter: true + referrerPolicy: strict-origin-when-cross-origin + frameDeny: true diff --git a/k8s/overlays/dev/registry/ingress-public.yaml b/k8s/overlays/dev/registry/ingress-public.yaml new file mode 100644 index 0000000..98f2736 --- /dev/null +++ b/k8s/overlays/dev/registry/ingress-public.yaml @@ -0,0 +1,27 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: docker-registry-public + labels: + app.kubernetes.io/name: docker-registry + app.kubernetes.io/instance: docker-registry + app.kubernetes.io/version: "2.8.3" + app.kubernetes.io/component: registry + app.kubernetes.io/part-of: platform-registry + app.kubernetes.io/managed-by: kustomize + annotations: + traefik.ingress.kubernetes.io/router.entrypoints: web + traefik.ingress.kubernetes.io/router.middlewares: kube-system-security-headers@kubernetescrd,mnt-docker-registry-basic-auth@kubernetescrd +spec: + ingressClassName: traefik + rules: + - host: registry.project.com + http: + paths: + - path: /v2 + pathType: Prefix + backend: + service: + name: docker-registry + port: + name: http diff --git a/k8s/overlays/dev/registry/kustomization.yaml b/k8s/overlays/dev/registry/kustomization.yaml new file mode 100644 index 0000000..76ac1f6 --- /dev/null +++ b/k8s/overlays/dev/registry/kustomization.yaml @@ -0,0 +1,11 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +namespace: mnt + +resources: + - ../../../base/plugins/docker-registry + - vault-secrets.yaml + - middleware.yaml + - ingress-public.yaml + - networkpolicy.yaml diff --git a/k8s/overlays/dev/registry/middleware.yaml b/k8s/overlays/dev/registry/middleware.yaml new file mode 100644 index 0000000..dc51cc5 --- /dev/null +++ b/k8s/overlays/dev/registry/middleware.yaml @@ -0,0 +1,14 @@ +apiVersion: traefik.io/v1alpha1 +kind: Middleware +metadata: + name: docker-registry-basic-auth + labels: + app.kubernetes.io/name: docker-registry + app.kubernetes.io/instance: docker-registry + app.kubernetes.io/component: registry + app.kubernetes.io/part-of: platform-registry + app.kubernetes.io/managed-by: kustomize +spec: + basicAuth: + secret: docker-registry-basic-auth + realm: docker-registry diff --git a/k8s/overlays/dev/registry/networkpolicy.yaml b/k8s/overlays/dev/registry/networkpolicy.yaml new file mode 100644 index 0000000..708929e --- /dev/null +++ b/k8s/overlays/dev/registry/networkpolicy.yaml @@ -0,0 +1,82 @@ +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: docker-registry-ingress-intra + labels: + app.kubernetes.io/name: docker-registry + app.kubernetes.io/instance: docker-registry + app.kubernetes.io/component: registry + app.kubernetes.io/part-of: platform-registry + app.kubernetes.io/managed-by: kustomize +spec: + podSelector: + matchLabels: + app.kubernetes.io/name: docker-registry + app.kubernetes.io/instance: docker-registry + policyTypes: + - Ingress + ingress: + - from: + - podSelector: {} + ports: + - protocol: TCP + port: 5000 +--- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: docker-registry-ingress-traefik + labels: + app.kubernetes.io/name: docker-registry + app.kubernetes.io/instance: docker-registry + app.kubernetes.io/component: registry + app.kubernetes.io/part-of: platform-registry + app.kubernetes.io/managed-by: kustomize +spec: + podSelector: + matchLabels: + app.kubernetes.io/name: docker-registry + app.kubernetes.io/instance: docker-registry + policyTypes: + - Ingress + ingress: + - from: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: kube-system + podSelector: + matchLabels: + app.kubernetes.io/name: traefik + ports: + - protocol: TCP + port: 5000 +--- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: docker-registry-egress-minio + labels: + app.kubernetes.io/name: docker-registry + app.kubernetes.io/instance: docker-registry + app.kubernetes.io/component: registry + app.kubernetes.io/part-of: platform-registry + app.kubernetes.io/managed-by: kustomize +spec: + podSelector: + matchLabels: + app.kubernetes.io/name: docker-registry + app.kubernetes.io/instance: docker-registry + policyTypes: + - Egress + egress: + - to: + - podSelector: + matchLabels: + v1.min.io/tenant: minio + ports: + - protocol: TCP + port: 80 + - protocol: TCP + port: 443 + - protocol: TCP + port: 9000 diff --git a/k8s/overlays/dev/registry/vault-secrets.yaml b/k8s/overlays/dev/registry/vault-secrets.yaml new file mode 100644 index 0000000..52d5ab0 --- /dev/null +++ b/k8s/overlays/dev/registry/vault-secrets.yaml @@ -0,0 +1,83 @@ +apiVersion: secrets.hashicorp.com/v1beta1 +kind: VaultStaticSecret +metadata: + name: docker-registry-minio +spec: + vaultAuthRef: vault-auth-storage + mount: secret + type: kv-v2 + path: docker-registry/minio + refreshAfter: 60s + destination: + name: docker-registry-minio + create: true + labels: + app.kubernetes.io/name: docker-registry + app.kubernetes.io/component: registry + app.kubernetes.io/managed-by: vault-secrets-operator + transformation: + excludeRaw: true + templates: + REGISTRY_STORAGE_S3_ACCESSKEY: + text: '{{ .Secrets.access_key }}' + REGISTRY_STORAGE_S3_SECRETKEY: + text: '{{ .Secrets.secret_key }}' +--- +apiVersion: secrets.hashicorp.com/v1beta1 +kind: VaultStaticSecret +metadata: + name: docker-registry-basic-auth +spec: + vaultAuthRef: vault-auth-storage + mount: secret + type: kv-v2 + path: docker-registry/basic-auth + refreshAfter: 60s + destination: + name: docker-registry-basic-auth + create: true + overwrite: true + labels: + app.kubernetes.io/name: docker-registry + app.kubernetes.io/component: registry + app.kubernetes.io/managed-by: vault-secrets-operator + transformation: + excludeRaw: true + excludes: + - .* + templates: + users: + text: '{{ .Secrets.users }}' +--- +apiVersion: secrets.hashicorp.com/v1beta1 +kind: VaultStaticSecret +metadata: + name: docker-registry-pull-credentials +spec: + vaultAuthRef: vault-auth-storage + mount: secret + type: kv-v2 + path: docker-registry/basic-auth + refreshAfter: 60s + destination: + name: docker-registry-pull-credentials + create: true + type: kubernetes.io/dockerconfigjson + labels: + app.kubernetes.io/name: docker-registry + app.kubernetes.io/component: registry + app.kubernetes.io/managed-by: vault-secrets-operator + transformation: + excludeRaw: true + templates: + .dockerconfigjson: + text: | + { + "auths": { + "registry.project.com": { + "username": "{{ .Secrets.username }}", + "password": "{{ .Secrets.password }}", + "auth": "{{ printf "%s:%s" .Secrets.username .Secrets.password | b64enc }}" + } + } + } diff --git a/k8s/overlays/dev/storage/kustomization.yaml b/k8s/overlays/dev/storage/kustomization.yaml new file mode 100644 index 0000000..317200e --- /dev/null +++ b/k8s/overlays/dev/storage/kustomization.yaml @@ -0,0 +1,27 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +namespace: mnt + +resources: + - ../../../base/app/storage/minio/stateful/minio + - vault-secrets.yaml + - networkpolicy.yaml + +patches: + - target: + group: minio.min.io + version: v2 + kind: Tenant + name: minio + patch: |- + - op: replace + path: /spec/certConfig/dnsNames + value: + - minio + - minio-hl + - minio.mnt.svc.cluster.local + - "*.minio-hl.mnt.svc.cluster.local" + - op: replace + path: /spec/requestAutoCert + value: false diff --git a/k8s/overlays/dev/storage/networkpolicy.yaml b/k8s/overlays/dev/storage/networkpolicy.yaml new file mode 100644 index 0000000..3e33308 --- /dev/null +++ b/k8s/overlays/dev/storage/networkpolicy.yaml @@ -0,0 +1,140 @@ +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: minio-ingress-peer + labels: + app.kubernetes.io/name: minio + app.kubernetes.io/instance: minio + app.kubernetes.io/component: object-storage + app.kubernetes.io/part-of: storage-platform + app.kubernetes.io/managed-by: kustomize +spec: + podSelector: + matchLabels: + v1.min.io/tenant: minio + policyTypes: + - Ingress + ingress: + - from: + - podSelector: + matchLabels: + v1.min.io/tenant: minio + ports: + - protocol: TCP + port: 9000 + - protocol: TCP + port: 9001 +--- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: minio-ingress-operator + labels: + app.kubernetes.io/name: minio + app.kubernetes.io/instance: minio + app.kubernetes.io/component: object-storage + app.kubernetes.io/part-of: storage-platform + app.kubernetes.io/managed-by: kustomize +spec: + podSelector: + matchLabels: + v1.min.io/tenant: minio + policyTypes: + - Ingress + ingress: + - from: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: minio-operator + podSelector: + matchLabels: + app.kubernetes.io/instance: minio-operator + app.kubernetes.io/name: operator + ports: + - protocol: TCP + port: 9000 +--- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: minio-ingress-clients + labels: + app.kubernetes.io/name: minio + app.kubernetes.io/instance: minio + app.kubernetes.io/component: object-storage + app.kubernetes.io/part-of: storage-platform + app.kubernetes.io/managed-by: kustomize +spec: + podSelector: + matchLabels: + v1.min.io/tenant: minio + policyTypes: + - Ingress + ingress: + - from: + - podSelector: + matchLabels: + app.kubernetes.io/part-of: auth-platform + - podSelector: + matchLabels: + app.kubernetes.io/part-of: platform-registry + ports: + - protocol: TCP + port: 9000 +--- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: minio-egress-peer + labels: + app.kubernetes.io/name: minio + app.kubernetes.io/instance: minio + app.kubernetes.io/component: object-storage + app.kubernetes.io/part-of: storage-platform + app.kubernetes.io/managed-by: kustomize +spec: + podSelector: + matchLabels: + v1.min.io/tenant: minio + policyTypes: + - Egress + egress: + - to: + - podSelector: + matchLabels: + v1.min.io/tenant: minio + ports: + - protocol: TCP + port: 9000 + - protocol: TCP + port: 9001 +--- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: minio-egress-kubernetes-api + labels: + app.kubernetes.io/name: minio + app.kubernetes.io/instance: minio + app.kubernetes.io/component: object-storage + app.kubernetes.io/part-of: storage-platform + app.kubernetes.io/managed-by: kustomize +spec: + podSelector: + matchLabels: + v1.min.io/tenant: minio + policyTypes: + - Egress + egress: + - to: + - ipBlock: + cidr: 10.43.0.0/16 + ports: + - protocol: TCP + port: 443 + - to: + - ipBlock: + cidr: 10.208.141.0/24 + ports: + - protocol: TCP + port: 6443 diff --git a/k8s/overlays/dev/storage/vault-secrets.yaml b/k8s/overlays/dev/storage/vault-secrets.yaml new file mode 100644 index 0000000..e41ed63 --- /dev/null +++ b/k8s/overlays/dev/storage/vault-secrets.yaml @@ -0,0 +1,17 @@ +apiVersion: secrets.hashicorp.com/v1beta1 +kind: VaultStaticSecret +metadata: + name: minio-tenant-env +spec: + vaultAuthRef: vault-auth-storage + mount: secret + type: kv-v2 + path: minio/tenant-env + refreshAfter: 60s + destination: + name: minio-tenant-env + create: true + labels: + app.kubernetes.io/name: minio + app.kubernetes.io/component: object-storage + app.kubernetes.io/managed-by: vault-secrets-operator diff --git a/k8s/overlays/dev/vault/kustomization.yaml b/k8s/overlays/dev/vault/kustomization.yaml new file mode 100644 index 0000000..c0119e8 --- /dev/null +++ b/k8s/overlays/dev/vault/kustomization.yaml @@ -0,0 +1,17 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +namespace: mnt + +resources: + - ../../../base/plugins/vault + - networkpolicy.yaml + +patches: + - target: + kind: StatefulSet + name: vault + patch: |- + - op: replace + path: /spec/volumeClaimTemplates/0/spec/resources/requests/storage + value: 1Gi diff --git a/k8s/overlays/dev/vault/networkpolicy.yaml b/k8s/overlays/dev/vault/networkpolicy.yaml new file mode 100644 index 0000000..4be9889 --- /dev/null +++ b/k8s/overlays/dev/vault/networkpolicy.yaml @@ -0,0 +1,76 @@ +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: vault-ingress-clients + labels: + app.kubernetes.io/name: vault + app.kubernetes.io/instance: vault + app.kubernetes.io/component: secret-management + app.kubernetes.io/part-of: security-platform + app.kubernetes.io/managed-by: kustomize +# ----------------------------------------------------------------------------- +# Vault 정책 — 두 방향 통제 +# +# (1) Ingress: VSO Controller Pod 에서만 8200 수신 +# VSO 가 `vault-secrets-operator-system` ns 와 `mnt` ns 양쪽에서 접근할 +# 가능성 (과거 설치 잔존 + 신규 전용 ns) 이 있어 둘 다 허용. +# +# (2) Egress: kube-apiserver 의 TokenReview 호출만 허용 +# Vault k8s auth 는 클라이언트가 보낸 SA JWT 를 검증하기 위해 +# `authentication.k8s.io/v1/tokenreviews` 를 호출한다. kube-apiserver 는 +# host network 프로세스이므로 Pod/Namespace 라벨로 selector 를 못 쓰고 +# ipBlock 이 불가피 (stock NetworkPolicy 의 한계; Cilium 이면 +# `toEntities: [kube-apiserver]` 로 해결됨). +# +# 두 CIDR 이 필요한 이유: +# - 443/TCP → `kubernetes.default.svc` (Service ClusterIP). Vault 가 +# 먼저 보내는 목적지. Service CIDR 에서 할당됨. +# - 6443/TCP → control-plane 노드의 실제 kube-apiserver 포트. k3s 의 +# kube-proxy(iptables) 가 Service → node DNAT 를 수행하는데, 기본 +# CNI (flannel + kube-router) 는 NetworkPolicy 를 veth iptables 단에서 +# 평가하면서 DNAT 전/후 시점이 엇갈려 두 쪽 모두 허용해야 안전. +# +# CIDR 값의 출처: +# - 10.43.0.0/16 → k3s `service-cidr` 기본값. `/etc/rancher/k3s/config.yaml` +# 의 값과 동기화. 커스텀 service-cidr 를 쓰면 이 값을 맞춰 변경. +# - 10.208.141.0/24 → **이 overlay 의 control-plane 노드 서브넷**. +# 노드를 추가/교체하거나 staging/prod overlay 를 만들 때 실제 값으로 +# 반드시 재지정. dev 단일 노드 기준으로 /24 여유를 둠. +# +# staging/prod overlay 는 이 파일을 그대로 복붙하지 말고 각자의 서브넷으로 +# 교체. 재사용 구조가 필요해지면 base + per-env patch 로 분리할 것. +# ----------------------------------------------------------------------------- +spec: + podSelector: + matchLabels: + app.kubernetes.io/name: vault + app.kubernetes.io/instance: vault + policyTypes: + - Ingress + - Egress + ingress: + - from: + - podSelector: + matchLabels: + app.kubernetes.io/name: vault-secrets-operator + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: vault-secrets-operator-system + podSelector: + matchLabels: + app.kubernetes.io/name: vault-secrets-operator + ports: + - protocol: TCP + port: 8200 + egress: + # Vault → kube-apiserver (TokenReview 용) + - to: + - ipBlock: + cidr: 10.43.0.0/16 # k3s service-cidr — kubernetes.default.svc 포함 + - ipBlock: + cidr: 10.208.141.0/24 # dev control-plane 노드 서브넷 (env-specific) + ports: + - protocol: TCP + port: 443 # Service ClusterIP 경유 + - protocol: TCP + port: 6443 # node DNAT 후 kube-apiserver 실포트 diff --git a/k8s/overlays/dev/vso/kustomization.yaml b/k8s/overlays/dev/vso/kustomization.yaml new file mode 100644 index 0000000..bb2f91c --- /dev/null +++ b/k8s/overlays/dev/vso/kustomization.yaml @@ -0,0 +1,7 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +namespace: mnt + +resources: + - ../../../base/plugins/vso diff --git a/k8s/scripts/AGENTS.md b/k8s/scripts/AGENTS.md new file mode 100644 index 0000000..c1d8f72 --- /dev/null +++ b/k8s/scripts/AGENTS.md @@ -0,0 +1,43 @@ +# k8s/scripts AGENTS + +Role: +- own helper automation for render, diff, apply, validate, backup, restore, and CI checks +- support Kustomize and operations workflows without replacing declarative ownership + +Allowed: +- bash entrypoints +- shell libraries +- CI validation scripts +- backup / restore helper scripts +- wrapper commands around `kubectl kustomize`, `kubectl diff -k`, `kubectl apply -k` + +Forbidden: +- scripts becoming the primary source of YAML truth +- large heredoc-generated manifests as the default path +- embedding production secret values +- hiding environment differences in shell conditionals instead of overlays +- giant all-in-one deploy scripts that mix unrelated concerns + +Read first: +- `/docs/standards/infra/scripts.md` +- `/docs/standards/infra/kustomize.md` +- `/docs/standards/infra/operations-runbook-upgrade-rollback.md` +- `/docs/standards/infra/backup-restore.md` +- `/docs/standards/infra/k3s-specific.md` + +Examples: +- `/docs/examples/infra/scripts.md` +- `/docs/examples/infra/kustomize.md` +- `/docs/examples/infra/backup-restore.md` +- `/docs/examples/infra/operations-runbook-upgrade-rollback.md` +- `/docs/examples/infra/k3s-specific.md` + +Rules: +- scripts are helpers, not source of truth +- prefer bash +- non-trivial scripts use functions + `main "$@"` +- use `local` for function-local variables +- ShellCheck must pass +- prefer render -> diff -> apply flow +- destructive actions require explicit opt-in +- environment/context/namespace must be explicit, not implicit diff --git a/k8s/scripts/README.md b/k8s/scripts/README.md new file mode 100644 index 0000000..8952393 --- /dev/null +++ b/k8s/scripts/README.md @@ -0,0 +1,80 @@ +# Kubernetes Scripts + +Imperative bootstrap, teardown, and break-glass operations for the `mnt` infra +stack. 선언적 관리(Service/Job/DB rollout 등)는 Kustomize 패키지의 몫이고, +여기 스크립트는 **phase orchestration** 과 **one-shot imperative setup** +(Vault init / unseal, Helm install) 만 담당한다. + +## Layout + +``` +scripts/ +├── bin/ # 사용자가 직접 실행하는 엔트리 포인트 (main "$@") +│ ├── bootstrap.sh # 전체 스택 부트스트랩 +│ └── teardown.sh # 전체 스택 제거 +├── tasks/ # bin/ 이 호출하는 단일 책임 태스크 +│ ├── minio-operator-install.sh +│ ├── vault-init.sh # Vault init + unseal + k8s auth + policy/role +│ ├── vault-seed-apps.sh # 앱 시크릿 5 개 Vault KV 에 seed +│ ├── vault-setup-admin.sh +│ └── vso-install.sh # VSO Helm upgrade --install +├── lib/ # 공유 쉘 라이브러리 +│ ├── common.sh # log/die, retry, confirm, kube-context 가드, finalizer 헬퍼 +│ └── vault.sh # vault_exec 래퍼, 로그인/unseal 유틸 +└── ci/ + └── validate.sh # kustomize build + kubeconform + kube-linter + shellcheck + shfmt +``` + +## Entry points + +```bash +# 부트스트랩 (대화형, bash history 안전) +bash k8s/scripts/bin/bootstrap.sh <dev|staging|prod> + +# 제거 (정상 경로) +bash k8s/scripts/bin/teardown.sh <dev|staging|prod> +CONFIRM=yes bash k8s/scripts/bin/teardown.sh <dev|staging|prod> + +# 로컬/CI 품질 게이트 +bash k8s/scripts/ci/validate.sh +``` + +## 필수 환경 변수 (안전 가드) + +쉘 rc 에 한 번 선언: + +```bash +export KUBE_CONTEXT_DEV="homelab" +export KUBE_CONTEXT_STAGING="staging-cluster" +export KUBE_CONTEXT_PROD="prod-cluster" +``` + +- `bin/bootstrap.sh` 와 `bin/teardown.sh` 는 `ENV_NAME` 에 대응하는 + `KUBE_CONTEXT_<ENV>` 와 `kubectl config current-context` 가 일치하는지 + **실행 전 검증**한다. 매핑이 없으면 대화형으로 현재 context 이름 재입력을 + 요구한다. +- `env=prod` destructive 작업은 `ALLOW_PROD_DESTRUCTIVE=yes` 와 namespace + 이름 재입력 TTY 확인이 추가로 필요하다. + +## 파괴적 플래그 + +| 플래그 | 범위 | 효과 | +| --- | --- | --- | +| `CONFIRM=yes` | 두 엔트리 | 대화형 확인 프롬프트 자동 승인 (CI) | +| `SKIP_DIFF=yes` | bootstrap | Phase 3 의 `kubectl diff` preview 생략 (비권장) | +| `RESET_STALE_SECRETS=yes` | bootstrap | VSO-managed K8s Secret 을 선제 삭제 (Vault 값 재주입 유도) | +| `VAULT_KEYS_FILE=<path>` | bootstrap | Vault init key 저장 위치. **env=prod 는 repo 바깥 경로 필수** | +| `FORCE_FINALIZERS=yes` | teardown | Phase 6 활성 — finalizer 강제 제거 + `/finalize` API. Terminating 복구 외 금지 | +| `ALLOW_PROD_DESTRUCTIVE=yes` | teardown | env=prod teardown 허용 (namespace 재입력 추가 확인 필요) | +| `TEARDOWN_VSO_OPERATOR=yes` | teardown | **클러스터 공용** VSO Helm 릴리즈 + 전용 namespace + `vault-tokenreview-binding` 제거. 다른 env 의 Vault/VSO 가 함께 멈춤 | +| `TEARDOWN_VSO_CRDS=yes` | teardown | **클러스터 공용** VSO CRD + ClusterRole/CRB/Webhook 제거. 모든 env 의 VaultStaticSecret 인스턴스가 삭제됨 | +| `TEARDOWN_MINIO_OPERATOR=yes` | teardown | MinIO Operator Helm 릴리즈까지 제거 | + +## Rules + +- 서비스 단위 스크립트를 새로 만들지 않는다. +- Service / Job / Scheduler / DB rollout 은 Kustomize 패키지에서 선언. +- 스크립트는 phase orchestration 이지 Kubernetes 리소스 스펙의 출처가 아니다. +- 비트리비얼 스크립트는 `functions + main "$@"` 구조를 따른다. +- 모든 `.sh` 는 `ci/validate.sh` 의 `shellcheck -S style` + `shfmt -i 2 -bn -ci` + 를 통과해야 한다. diff --git a/k8s/scripts/bin/bootstrap.sh b/k8s/scripts/bin/bootstrap.sh new file mode 100755 index 0000000..99dc65e --- /dev/null +++ b/k8s/scripts/bin/bootstrap.sh @@ -0,0 +1,346 @@ +#!/usr/bin/env bash +# Entry point: bootstrap the full infra stack for a given environment. +# +# Phases: +# 0. MinIO Operator Helm install (tasks/minio-operator-install.sh) +# 1. Apply namespace + PSS labels (kubectl apply -k base/managing/namespace) +# 2. (optional) Reset stale K8s Secrets (RESET_STALE_SECRETS=yes 일 때만) +# 2.5 VSO CRD 선행 설치 (VaultStaticSecret CR 가 overlay 에 포함돼 있어) +# 2.6 cert-manager 선행 설치 (ClusterIssuer / Certificate CR 가 overlay 에 포함돼 있어) +# 2.7 Keycloak Operator 선행 설치 (Keycloak / KeycloakRealmImport CR 가 overlay 에 포함돼 있어) +# 2.8 Traefik HelmChartConfig + Middleware/TLSOption (kube-system 에 배치 — root overlay 의 `namespace: mnt` 와 충돌하므로 별도 apply) +# 3. Render + diff + confirm + apply overlay (--server-side --field-manager=project-infra-bootstrap) +# 4. Wait for vault-0 Running +# 5. Vault init + unseal + KV + k8s auth + policy / role (tasks/vault-init.sh) +# 6. Seed application secrets (tasks/vault-seed-apps.sh) +# 7. Helm install VSO (tasks/vso-install.sh) +# 8. Apply VSO CRs (kubectl apply -k overlays/<env>/vso/) +# 9. MinIO docker-registry bucket/user/policy 프로비저닝 (tasks/minio-provision-registry.sh) +# +# 대화형 실행 (bash history 에 비밀번호 남지 않음): +# bash k8s/scripts/bin/bootstrap.sh dev +# +# Required context (안전 가드): +# KUBE_CONTEXT_DEV / KUBE_CONTEXT_STAGING / KUBE_CONTEXT_PROD +# 또는 KUBE_CONTEXT= 로 명시. 쉘 rc 에 선언해 두면 현재 kubectl context +# 와 일치 여부를 자동 검증한다. 매핑이 없으면 대화형으로 context 이름 +# 재입력을 요구. +# +# Optional env: +# RESET_STALE_SECRETS=yes 이미 있는 VSO-managed K8s Secret 을 삭제 후 재생성 +# VAULT_KEYS_FILE=<path> env=prod 필수 — repo 바깥 경로 +# SKIP_DIFF=yes Phase 3 의 kubectl diff preview 생략 +# CONFIRM=yes 대화형 프롬프트 자동 승인 (CI) + +set -Eeuo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=../lib/common.sh +. "$SCRIPT_DIR/../lib/common.sh" + +REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" +K8S_ROOT="$REPO_ROOT/k8s" +TASKS_DIR="$SCRIPT_DIR/../tasks" +export REPO_ROOT + +NAMESPACE="mnt" +FIELD_MANAGER="project-infra-bootstrap" + +# VSO 가 관리하는 K8s Secret 이름 목록 (기존 찌꺼기 감지용) +VSO_MANAGED_SECRETS=( + identity-postgres-superuser + keycloak-db + keycloak-db-operator + auth-server-db + keycloak-bootstrap-admin + keycloak-bootstrap-admin-operator + keycloak-client-auth-server-ingress + minio-tenant-env + docker-registry-minio +) + +usage() { + cat >&2 <<EOF +Usage: bin/bootstrap.sh <dev|staging|prod> + +Required env (권장 — 쉘 rc 에 선언): + KUBE_CONTEXT_DEV / KUBE_CONTEXT_STAGING / KUBE_CONTEXT_PROD env → kubectl context 매핑 + (또는 KUBE_CONTEXT=<name> 로 한 번만 overrides) + +Options: + RESET_STALE_SECRETS=yes 이미 존재하는 VSO-managed Secret 을 삭제 후 재생성 + VAULT_KEYS_FILE=<path> env=prod 필수 — repo 바깥 경로 + SKIP_DIFF=yes Phase 3 diff preview 생략 (비권장) + CONFIRM=yes 대화형 확인 자동 승인 (CI) +EOF + exit 1 +} + +precheck_namespace() { + if ! ns_exists "$NAMESPACE"; then + log "Precheck: namespace '$NAMESPACE' 없음 — 신규 생성 흐름" + return 0 + fi + local phase + phase="$(ns_phase "$NAMESPACE")" + if [[ "$phase" == "Terminating" ]]; then + err "namespace '$NAMESPACE' 가 Terminating 상태입니다." + err "이전 teardown 이 완료되지 않아 새 리소스 생성이 불가능합니다." + err "" + err "복구 절차:" + err " 1) 이전 teardown 을 마저 끝내기:" + err " CONFIRM=yes bash k8s/scripts/bin/teardown.sh $ENV_NAME" + err " 2) namespace 가 완전히 사라진 걸 확인한 뒤 bootstrap 재실행:" + err " kubectl get namespace $NAMESPACE" + err " bash k8s/scripts/bin/bootstrap.sh $ENV_NAME" + die "bootstrap 중단" + fi + log "Precheck: namespace '$NAMESPACE' phase=$phase — 계속 진행" +} + +phase0_minio_operator() { + log "[0/9] MinIO Operator 설치 (Tenant CRD 선행)" + bash "$TASKS_DIR/minio-operator-install.sh" +} + +phase1_namespace() { + log "[1/9] Namespace + PSS 라벨 선행 apply" + kubectl apply -k "$K8S_ROOT/base/managing/namespace" \ + --server-side --field-manager="$FIELD_MANAGER" + retry 5 1 kubectl get namespace "$NAMESPACE" >/dev/null +} + +phase2_reset_stale_secrets() { + log "[2/9] 기존 VSO-managed K8s Secret 점검" + local stale_found=0 s + for s in "${VSO_MANAGED_SECRETS[@]}"; do + if kubectl -n "$NAMESPACE" get secret "$s" >/dev/null 2>&1; then + stale_found=$((stale_found + 1)) + if [[ "${RESET_STALE_SECRETS:-}" == "yes" ]]; then + log " 삭제: $s (RESET_STALE_SECRETS=yes)" + kubectl -n "$NAMESPACE" delete secret "$s" --ignore-not-found + else + warn " $s 이미 존재 (VSO 가 덮어쓰지 않음). Vault 값 반영이 필요하면 RESET_STALE_SECRETS=yes 로 재실행하거나 수동 삭제하세요." + fi + fi + done + (( stale_found == 0 )) && log " 기존 Secret 없음" +} + +phase2_5_vso_crds() { + # overlays/<env>/{database,keycloak,storage}/vault-secrets.yaml 에 VaultStaticSecret + # CR 들이 포함돼 있어, Phase 3 overlay apply 시점에 CRD 가 없으면 "resource mapping + # not found" 로 실패. Helm 차트 install 은 Phase 7 이므로 CRD 만 선행 적용. + log "[2.5/9] VSO CRD 선행 설치" + local version="${VSO_VERSION:-0.9.0}" + helm repo add hashicorp https://helm.releases.hashicorp.com >/dev/null 2>&1 || true + helm repo update hashicorp >/dev/null + helm show crds hashicorp/vault-secrets-operator --version "$version" \ + | kubectl apply -f - --server-side --field-manager="$FIELD_MANAGER" +} + +# cert-manager 전체 (CRD + namespace + controller + webhook) 을 overlay 밖에서 +# 선행 설치. 이후 phase 3 에서 overlay 가 참조하는 ClusterIssuer / Certificate CR +# 이 등록될 수 있다. CRD established 대기를 반드시 건다 — deploy/webhook 가 +# Ready 되기 전에 Certificate CR apply 시 admission webhook 이 거부함. +phase2_6_cert_manager() { + log "[2.6/9] cert-manager 선행 설치" + kubectl apply -k "$K8S_ROOT/overlays/$ENV_NAME/platform/cert-manager" \ + --server-side --field-manager="$FIELD_MANAGER" + + log " cert-manager CRD established 대기" + retry 30 2 kubectl wait --for=condition=Established --timeout=10s \ + crd/clusterissuers.cert-manager.io \ + crd/certificates.cert-manager.io \ + crd/certificaterequests.cert-manager.io \ + crd/orders.acme.cert-manager.io \ + crd/challenges.acme.cert-manager.io + + log " cert-manager Deployment Available 대기" + retry 30 5 kubectl -n cert-manager wait --for=condition=Available --timeout=10s \ + deploy/cert-manager \ + deploy/cert-manager-webhook \ + deploy/cert-manager-cainjector +} + +# Keycloak Operator (CRDs + Operator Deployment) 선행 설치. Keycloak / +# KeycloakRealmImport CR 이 phase 3 에 포함되므로 CRD 등록이 먼저 되어야 한다. +phase2_7_keycloak_operator() { + log "[2.7/9] Keycloak Operator 선행 설치" + kubectl apply -k "$K8S_ROOT/overlays/$ENV_NAME/platform/keycloak-operator" \ + --server-side --field-manager="$FIELD_MANAGER" + + log " Keycloak Operator CRD established 대기" + retry 30 2 kubectl wait --for=condition=Established --timeout=10s \ + crd/keycloaks.k8s.keycloak.org \ + crd/keycloakrealmimports.k8s.keycloak.org + + log " Keycloak Operator Deployment Available 대기" + retry 60 5 kubectl -n "$NAMESPACE" wait --for=condition=Available --timeout=10s \ + deploy/keycloak-operator +} + +# kube-system Traefik 커스터마이징 — HelmChartConfig (K3s Helm-controller 가 +# 재수렴), Middleware (https-redirect / security-headers), TLSOption (modern-tls). +# root overlay 가 `namespace: mnt` 로 전역 주입하므로 kube-system 타깃 리소스는 +# 이 overlay 빌드에 포함시키지 않고 별도 apply 한다. +phase2_8_traefik() { + log "[2.8/9] Traefik HelmChartConfig + Middleware 적용 (kube-system)" + kubectl apply -k "$K8S_ROOT/overlays/$ENV_NAME/platform/traefik" \ + --server-side --field-manager="$FIELD_MANAGER" +} + +# render → server-side dry-run → diff → confirm → apply +# +# kubectl diff exit codes (GNU man page): +# 0 — 변경 없음 +# 1 — 변경 있음 (정상) +# >1 — 실행 오류 (RBAC / API 연결 / invalid manifest 등) +# 이전 구현은 `|| true` 로 모든 비-0 을 흡수해서 에러가 apply 까지 흘러갔다. +phase3_overlay_apply() { + log "[3/9] 인프라 overlay 배포 ($OVERLAY_DIR)" + + local tmpdir + tmpdir="$(mktemp -d -t project-infra-bootstrap.XXXXXX)" + trap_cleanup_path "$tmpdir" + + local rendered="$tmpdir/rendered.yaml" + kustomize build "$OVERLAY_DIR" > "$rendered" + log " render 완료: $(grep -c '^kind:' "$rendered") resources" + + # --- server-side dry-run: admission / RBAC / schema 를 API server 로 검증 --- + log " server-side dry-run 검증" + if ! kubectl apply -f "$rendered" --dry-run=server \ + --server-side --field-manager="$FIELD_MANAGER" \ + >"$tmpdir/dryrun.out" 2>&1; then + err " server-side dry-run 실패 — apply 중단" + sed 's/^/ /' "$tmpdir/dryrun.out" >&2 + die "dry-run 검증 실패" + fi + + # --- diff preview — 변경 있음(rc=1)은 정상, 실행 오류(rc>=2)는 die --- + if [[ "${SKIP_DIFF:-}" == "yes" ]]; then + warn " SKIP_DIFF=yes → diff preview 생략" + else + log " server-side diff preview (대용량이면 스크롤)" + local rc=0 + kubectl diff -f "$rendered" --server-side --field-manager="$FIELD_MANAGER" \ + >"$tmpdir/diff.out" 2>&1 || rc=$? + case "$rc" in + 0) log " (변경 없음)" ;; + 1) + if [[ -s "$tmpdir/diff.out" ]]; then + sed 's/^/ /' "$tmpdir/diff.out" >&2 + else + log " (diff 출력 없음)" + fi + ;; + *) + err " kubectl diff 실행 실패 (exit=$rc) — apply 중단" + sed 's/^/ /' "$tmpdir/diff.out" >&2 + die "diff 실패" + ;; + esac + fi + + confirm "위 변경사항을 env=$ENV_NAME context='$(kubectl config current-context)' 에 apply 하시겠습니까?" \ + || die "사용자 취소" + + kubectl apply -f "$rendered" --server-side --field-manager="$FIELD_MANAGER" + log " vault/registry/앱 워크로드 apply 완료" +} + +phase4_wait_vault_running() { + # Vault readiness probe 는 initialized+unsealed 일 때만 통과하므로 초기화 *전* + # 에는 Ready 가 될 수 없음 → Running 단계까지만 기다림. + log "[4/9] vault-0 Running 대기 (120s × 재시도 3회)" + retry 3 10 kubectl -n "$NAMESPACE" wait \ + --for=jsonpath='{.status.phase}'=Running pod/vault-0 --timeout=120s +} + +phase5_vault_init() { + log "[5/9] Vault 초기화 / unseal / auth / policy / role" + ENV_NAME="$ENV_NAME" bash "$TASKS_DIR/vault-init.sh" +} + +phase6_seed_apps() { + log "[6/9] 앱 시크릿 5 개 Vault KV 에 seed" + ENV_NAME="$ENV_NAME" bash "$TASKS_DIR/vault-seed-apps.sh" +} + +phase7_vso_install() { + log "[7/9] VSO Helm upgrade --install" + ENV_NAME="$ENV_NAME" bash "$TASKS_DIR/vso-install.sh" +} + +phase8_vso_crs() { + log "[8/9] VSO CR 적용 ($OVERLAY_DIR/vso/)" + kubectl apply -k "$OVERLAY_DIR/vso/" \ + --server-side --field-manager="$FIELD_MANAGER" +} + +# docker-registry 가 MinIO 를 S3 backend 로 쓰도록 bucket + 서비스 user + +# bucket-scoped policy 를 설정. MinIO Tenant 는 phase 8 에서 minio-tenant-env +# Secret 이 생긴 뒤 기동되므로 이 phase 는 반드시 phase 8 이후에 실행. +phase9_minio_provision_registry() { + log "[9/9] MinIO docker-registry bucket/user/policy 프로비저닝" + ENV_NAME="$ENV_NAME" bash "$TASKS_DIR/minio-provision-registry.sh" +} + +summary() { + log "============================================" + log " $ENV_NAME 부트스트랩 완료" + log "============================================" + log "unseal keys + root token: ${VAULT_KEYS_FILE:-$REPO_ROOT/vault-init-keys.json} (0600)" + log " → 오프라인 / 외부 KMS 로 즉시 이동하세요 (Git 반입 금지)" + log "" + log "앱 시크릿 5 개는 Phase 6 에서 입력/seed 됨." + log "조회: vault kv get -field=password secret/<path>" + log "" + log "다음 확인:" + log " kubectl -n $NAMESPACE get pods" + log " kubectl -n $NAMESPACE get secrets" + log " kubectl -n $NAMESPACE get vaultstaticsecret" +} + +main() { + ENV_NAME="${1:-}" + case "$ENV_NAME" in + dev|staging|prod) ;; + *) usage ;; + esac + + OVERLAY_DIR="$K8S_ROOT/overlays/$ENV_NAME" + [[ -d "$OVERLAY_DIR" ]] || die "overlay 디렉토리 없음: $OVERLAY_DIR" + + require_cmd kubectl helm jq kustomize + + # 안전 가드 — 실수 클러스터 apply 방지 + require_kube_context "$ENV_NAME" + + log "============================================" + log " Project-Infra 부트스트랩 ($ENV_NAME)" + log " context : $(kubectl config current-context)" + log "============================================" + + precheck_namespace + phase0_minio_operator + phase1_namespace + phase2_reset_stale_secrets + phase2_5_vso_crds + phase2_6_cert_manager + phase2_7_keycloak_operator + phase2_8_traefik + phase3_overlay_apply + phase4_wait_vault_running + phase5_vault_init + phase6_seed_apps + phase7_vso_install + phase8_vso_crs + phase9_minio_provision_registry + summary +} + +# Google shell style — 직접 실행일 때만 main 호출 (source 된 경우 함수만 노출). +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + main "$@" +fi diff --git a/k8s/scripts/bin/teardown.sh b/k8s/scripts/bin/teardown.sh new file mode 100755 index 0000000..9a1232f --- /dev/null +++ b/k8s/scripts/bin/teardown.sh @@ -0,0 +1,309 @@ +#!/usr/bin/env bash +# Entry point: tear down the full infra stack for a given environment. +# +# 체계적 정리 — 기본 경로는 "정상 API 삭제" 만 사용한다. finalizer 강제 제거와 +# /finalize API 호출 같은 파괴적 복구 경로는 별도 플래그 아래로 격리했다. +# +# Phases: +# 1. Precheck (namespace 존재 여부) +# 2. VSO CR 삭제 (controller 살아있을 때 정상 경로만) +# 3. VSO Helm uninstall +# 4. 인프라 overlay 삭제 (Vault / Registry / 앱) +# 5. namespace 삭제 +# 6. (opt) FORCE_FINALIZERS=yes 일 때만 — finalizer 강제 제거 + /finalize +# 7. Cluster-scoped 리소스 정리 (ClusterRoleBinding, VSO ClusterRole/Webhook/CRD) +# +# 대화형: +# bash k8s/scripts/bin/teardown.sh dev +# +# CI (비대화): +# CONFIRM=yes bash k8s/scripts/bin/teardown.sh dev +# +# Required context (bootstrap 과 동일한 안전 가드): +# KUBE_CONTEXT_<ENV_UPPER> 또는 KUBE_CONTEXT +# +# Destructive 플래그: +# FORCE_FINALIZERS=yes +# Phase 6 실행 — PVC / CRD / 모든 namespaced 리소스 finalizer 강제 제거 및 +# namespace /finalize 호출. PV 가 orphaned 되고 컨트롤러 정리 누락이 발생할 +# 수 있으므로 Terminating 복구 외 목적으로는 쓰지 말 것. +# +# ALLOW_PROD_DESTRUCTIVE=yes +# env=prod teardown 을 허용. 추가로 namespace 이름 재입력 TTY 확인이 필요. +# +# TEARDOWN_VSO_OPERATOR=yes +# Phase 3 에서 VSO Helm 릴리즈와 vault-secrets-operator-system namespace 까지 +# 제거. VSO operator 는 환경별이 아니라 **클러스터 공용** 이다. 다른 env 가 +# 같은 클러스터에 있으면 그쪽 VSO reconciliation 이 같이 멈춘다. +# Phase 7 의 vault-tokenreview-binding (cluster-scoped 이름 고정) 도 이 +# 플래그로 함께 삭제한다. VSO 를 완전히 내릴 때만 켜라. 기본은 skip. +# +# TEARDOWN_VSO_CRDS=yes +# Phase 7 에서 cluster-scoped VSO 리소스 (ClusterRole/CRB/Webhook/CRD) 를 +# 제거. CRD 는 클러스터 전체 공유라 같은 클러스터에 다른 env 가 있으면 +# 그쪽 인스턴스까지 날아간다. 단일-env-per-cluster 환경 또는 VSO 를 완전히 +# 버리려는 의도일 때만 켜라. 기본은 skip. +# +# TEARDOWN_MINIO_OPERATOR=yes +# MinIO Operator Helm 릴리즈도 제거 (기본 유지). + +set -Eeuo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=../lib/common.sh +. "$SCRIPT_DIR/../lib/common.sh" + +REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" +K8S_ROOT="$REPO_ROOT/k8s" + +NAMESPACE="mnt" +VSO_NAMESPACE="${VSO_NAMESPACE:-vault-secrets-operator-system}" +VSO_RELEASE="${VSO_RELEASE:-vault-secrets-operator}" +VSO_CRD_KINDS=(vaultstaticsecret vaultauth vaultconnection) + +usage() { + cat >&2 <<EOF +Usage: bin/teardown.sh <dev|staging|prod> + +Required env: + KUBE_CONTEXT_DEV / KUBE_CONTEXT_STAGING / KUBE_CONTEXT_PROD + (또는 KUBE_CONTEXT=<name>) + +Options: + CONFIRM=yes 대화형 확인 자동 승인 + FORCE_FINALIZERS=yes Phase 6 (finalizer 강제 + /finalize) 활성화 + ALLOW_PROD_DESTRUCTIVE=yes env=prod teardown 허용 + namespace 재입력 필요 + TEARDOWN_VSO_OPERATOR=yes VSO Helm 릴리즈 + 전용 namespace + vault-tokenreview-binding + 모두 제거. VSO 는 클러스터 공용 플랫폼 리소스이므로 + 다른 env 의 reconciliation 이 함께 멈춤. + TEARDOWN_VSO_CRDS=yes VSO cluster-scoped 리소스 (ClusterRole/CRB/Webhook/CRD) + 제거. 클러스터 전체에 영향. + TEARDOWN_MINIO_OPERATOR=yes MinIO Operator 도 함께 제거 +EOF + exit 1 +} + +phase1_precheck() { + log "[1/7] Precheck" + if ns_exists "$NAMESPACE"; then + log " namespace $NAMESPACE phase=$(ns_phase "$NAMESPACE")" + else + log " namespace $NAMESPACE 없음 → 일부 phase 는 skip" + fi +} + +phase2_vso_cr_delete() { + log "[2/7] VSO CR 삭제" + if kubectl get -k "$OVERLAY_DIR/vso/" >/dev/null 2>&1; then + # 정상 API 삭제만 시도. 타임아웃 나도 Phase 6 (FORCE_FINALIZERS) 에 위임. + kubectl delete -k "$OVERLAY_DIR/vso/" --ignore-not-found \ + --wait=true --timeout=60s 2>&1 \ + || warn " VSO CR 삭제 타임아웃 — FORCE_FINALIZERS=yes 로 재실행하면 강제 해제" + else + log " VSO overlay 리소스 없음 → skip" + fi +} + +uninstall_vso_in_ns() { + local ns="$1" + kubectl get namespace "$ns" >/dev/null 2>&1 || return 0 + + # pre-delete hook Job 이 stuck 이면 uninstall 이 hang → 먼저 정리 + kubectl -n "$ns" get job -l app.kubernetes.io/instance="$VSO_RELEASE" -o name 2>/dev/null \ + | xargs -r kubectl -n "$ns" delete --force --grace-period=0 --ignore-not-found 2>/dev/null || true + kubectl -n "$ns" delete job pdcc-vault-secrets-operator \ + --force --grace-period=0 --ignore-not-found 2>/dev/null || true + + if helm -n "$ns" status "$VSO_RELEASE" >/dev/null 2>&1; then + log " [$ns] helm uninstall $VSO_RELEASE" + helm -n "$ns" uninstall "$VSO_RELEASE" --wait --timeout 5m 2>&1 \ + || warn " [$ns] helm uninstall 실패 — 릴리즈 Secret 잔존물 직접 제거" + else + log " [$ns] VSO 릴리즈 없음" + fi + + # uninstall 실패 / rollback 잔존 상태에서 남는 sh.helm.release.v1.* Secret 정리 + kubectl -n "$ns" get secret -o name 2>/dev/null \ + | grep "sh.helm.release.*${VSO_RELEASE}" \ + | xargs -r kubectl -n "$ns" delete --ignore-not-found 2>/dev/null || true +} + +# $NAMESPACE (mnt) 내 잔존 uninstall 은 **env-specific 레거시 cleanup** 이라 +# 항상 실행한다 — 과거 VSO 가 mnt 에 설치됐던 적이 있으면 릴리즈 메타데이터가 +# 남아 다음 install 을 막으므로. $VSO_NAMESPACE (공용 operator 공간) 에 대한 +# uninstall 은 클러스터 공유이므로 명시적 opt-in 요구. +phase3_vso_helm_uninstall() { + log "[3/7] VSO Helm uninstall" + uninstall_vso_in_ns "$NAMESPACE" + + if [[ "${TEARDOWN_VSO_OPERATOR:-}" == "yes" ]]; then + warn " TEARDOWN_VSO_OPERATOR=yes — 공용 VSO operator ($VSO_NAMESPACE) 제거" + warn " 경고: 같은 클러스터에 다른 env 의 VaultStaticSecret 이 있으면 reconciliation 이 멈춥니다." + uninstall_vso_in_ns "$VSO_NAMESPACE" + else + log " VSO operator ($VSO_NAMESPACE) 보존 (TEARDOWN_VSO_OPERATOR=yes 로 제거 가능)" + fi +} + +phase4_overlay_delete() { + log "[4/7] 인프라 리소스 삭제 (kustomize overlay)" + if ns_exists "$NAMESPACE"; then + kubectl delete -k "$OVERLAY_DIR" --ignore-not-found \ + --wait=true --timeout=120s 2>&1 \ + || warn " overlay 삭제 타임아웃 — 필요 시 FORCE_FINALIZERS=yes 로 Phase 6 사용" + else + log " namespace 없음 → overlay 삭제 skip" + fi +} + +phase5_namespace_delete() { + log "[5/7] namespace 정상 삭제" + if ns_exists "$NAMESPACE"; then + kubectl delete namespace "$NAMESPACE" --ignore-not-found --wait=false 2>/dev/null || true + log " namespace 제거 대기 (최대 60s)" + if wait_namespace_gone "$NAMESPACE" 60; then + log " namespace $NAMESPACE 제거 완료" + else + warn " namespace $NAMESPACE 가 Terminating 60s 초과." + warn " 정상 삭제로 끝나지 않은 경우:" + warn " 1) 'kubectl get all -n $NAMESPACE' 로 남은 리소스 원인 확인" + warn " 2) controller/operator 재기동 으로 finalizer 처리 시도" + warn " 3) 그래도 막히면 FORCE_FINALIZERS=yes 로 재실행 — 단 파괴적" + fi + else + log " namespace 이미 없음" + fi +} + +# FORCE_FINALIZERS=yes 일 때만 — PV orphaned / 데이터 정합성 리스크 있음. +phase6_force_finalizers() { + if [[ "${FORCE_FINALIZERS:-}" != "yes" ]]; then + log "[6/7] FORCE_FINALIZERS!=yes → skip (파괴적 경로 비활성)" + return 0 + fi + + log "[6/7] FORCE_FINALIZERS=yes — finalizer 강제 제거 + /finalize" + warn " 경고: PV orphan / controller 정리 누락이 발생할 수 있습니다." + warn " Terminating stuck 복구 외 목적으로 사용하지 마세요." + + if ! ns_exists "$NAMESPACE"; then + log " namespace 이미 없음 → skip" + return 0 + fi + + strip_finalizers_in_ns "$NAMESPACE" persistentvolumeclaim + strip_finalizers_in_ns "$NAMESPACE" "${VSO_CRD_KINDS[@]}" 2>/dev/null || true + log " namespace $NAMESPACE 전체 리소스 finalizer 일괄 제거 (최후 수단)" + strip_finalizers_all_ns_resources "$NAMESPACE" + + kubectl delete namespace "$NAMESPACE" --ignore-not-found --wait=false 2>/dev/null || true + if wait_namespace_gone "$NAMESPACE" 30; then + log " namespace $NAMESPACE 제거 완료 (FORCE_FINALIZERS)" + return 0 + fi + + warn " namespace 여전히 Terminating → /finalize API 호출" + force_finalize_namespace "$NAMESPACE" \ + || warn " /finalize 호출 실패 — 수동 확인 필요" + if wait_namespace_gone "$NAMESPACE" 30; then + log " namespace $NAMESPACE 제거 완료 (/finalize)" + else + err " namespace $NAMESPACE 여전히 존재. 'kubectl get namespace $NAMESPACE -o yaml' 로 확인 필요." + fi +} + +phase7_cluster_scoped() { + log "[7/7] Cluster-scoped 리소스 정리" + + # vault-tokenreview-binding 은 cluster-scoped 이름 고정이라 env 별 분리가 + # 불가능하다 (kustomize base 를 env-scoped 이름으로 재설계하기 전까지). + # 기본 경로에서 삭제하면 다른 env 의 Vault k8s auth TokenReview 가 부서지므로 + # TEARDOWN_VSO_OPERATOR=yes 와 함께 게이트 (플랫폼 auth 평면 전체 정리). + if [[ "${TEARDOWN_VSO_OPERATOR:-}" == "yes" ]]; then + log " vault-tokenreview-binding 제거 (TEARDOWN_VSO_OPERATOR=yes)" + kubectl delete clusterrolebinding vault-tokenreview-binding --ignore-not-found 2>&1 \ + | sed 's/^/ /' || true + if ns_exists "$VSO_NAMESPACE"; then + log " VSO namespace $VSO_NAMESPACE 삭제" + kubectl delete namespace "$VSO_NAMESPACE" --ignore-not-found --wait=true --timeout=60s 2>/dev/null \ + || warn " $VSO_NAMESPACE 삭제 타임아웃 — 수동 확인 필요" + fi + else + log " vault-tokenreview-binding 보존 (TEARDOWN_VSO_OPERATOR=yes 로 제거 가능)" + fi + + # VSO 의 ClusterRole / CRB / Webhook / CRD 는 클러스터 전체 공유다. + # 운영자가 의도적으로 VSO 전체를 버릴 때만 TEARDOWN_VSO_CRDS=yes 로 활성화. + if [[ "${TEARDOWN_VSO_CRDS:-}" == "yes" ]]; then + warn " TEARDOWN_VSO_CRDS=yes — cluster-scoped VSO 리소스 제거 (ClusterRole/CRB/Webhook/CRD)" + warn " 경고: 같은 클러스터의 다른 env 에서 VSO 를 쓰고 있으면 모두 영향받습니다." + local kind + for kind in clusterrole clusterrolebinding validatingwebhookconfiguration mutatingwebhookconfiguration; do + kubectl get "$kind" -o name 2>/dev/null \ + | grep -E 'vault-secrets-operator' \ + | xargs -r kubectl delete --ignore-not-found 2>/dev/null || true + done + kubectl get crd -o name 2>/dev/null \ + | grep 'secrets.hashicorp.com' \ + | xargs -r kubectl delete --ignore-not-found 2>/dev/null || true + else + log " cluster-scoped VSO 리소스 보존 (TEARDOWN_VSO_CRDS=yes 로 제거 가능)" + fi + + if [[ "${TEARDOWN_MINIO_OPERATOR:-}" == "yes" ]]; then + log " TEARDOWN_MINIO_OPERATOR=yes → MinIO Operator 제거" + helm -n minio-operator uninstall minio-operator --wait --timeout 5m 2>/dev/null \ + || warn " MinIO Operator Helm uninstall 실패" + kubectl delete namespace minio-operator --ignore-not-found --wait=true --timeout=60s \ + || warn " minio-operator namespace 삭제 실패" + else + log " MinIO Operator 유지 (TEARDOWN_MINIO_OPERATOR=yes 로 제거 가능)" + fi +} + +summary() { + log "============================================" + log " $ENV_NAME 삭제 완료" + log "============================================" + log "Vault unseal key 파일은 그대로 남아있습니다 (${VAULT_KEYS_FILE:-$REPO_ROOT/vault-init-keys.json})." + log "완전 초기화하려면 수동 삭제하세요." +} + +main() { + ENV_NAME="${1:-}" + case "$ENV_NAME" in + dev|staging|prod) ;; + *) usage ;; + esac + + OVERLAY_DIR="$K8S_ROOT/overlays/$ENV_NAME" + [[ -d "$OVERLAY_DIR" ]] || die "overlay 디렉토리 없음: $OVERLAY_DIR" + + require_cmd kubectl helm jq + + # 안전 가드 1 — kubectl context 가 env 와 맞는지 + require_kube_context "$ENV_NAME" + + # 안전 가드 2 — prod 는 추가 게이트 (namespace 재입력 포함) + require_production_gate "$ENV_NAME" "$NAMESPACE" + + log "============================================" + log " Project-Infra 삭제 ($ENV_NAME)" + log " context : $(kubectl config current-context)" + log "============================================" + + confirm "namespace='$NAMESPACE' 의 모든 Vault / Registry / VSO / 앱 리소스를 삭제합니다. 계속?" \ + || die "사용자 취소" + + phase1_precheck + phase2_vso_cr_delete + phase3_vso_helm_uninstall + phase4_overlay_delete + phase5_namespace_delete + phase6_force_finalizers + phase7_cluster_scoped + summary +} + +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + main "$@" +fi diff --git a/k8s/scripts/ci/validate.sh b/k8s/scripts/ci/validate.sh new file mode 100755 index 0000000..c959f22 --- /dev/null +++ b/k8s/scripts/ci/validate.sh @@ -0,0 +1,167 @@ +#!/usr/bin/env bash +# Validate all kustomize overlays against schema + lint rules, +# 그리고 scripts/ 전체에 대해 shell 품질 게이트 (shellcheck + shfmt) 를 실행한다. +# +# Runs, for each environment (dev, staging, prod): +# 1. `kustomize build overlays/<env>` — structural validity +# 2. `kustomize build overlays/<env>/vso` — VSO CRD overlay (built separately +# because it requires the VSO Helm +# release to be installed first) +# 3. `kubeconform -strict -ignore-missing-schemas` — OpenAPI schema validation +# with CRD schemas fetched from the Datree catalog. +# 4. `kube-linter lint` — anti-pattern lint against +# root .kube-linter.yaml configuration. +# +# Plus (not per-overlay): +# 5. `shellcheck -S style` over k8s/scripts/**/*.sh — shell correctness + style +# 6. `shfmt -i 2 -bn -ci -d` over k8s/scripts/ — formatting diff (fail on drift) +# +# Exit codes: +# 0 — everything passed +# 1 — at least one step failed (details printed) +# +# Tools expected on $PATH: +# kustomize kubeconform kube-linter shellcheck shfmt + +set -Eeuo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=../lib/common.sh +. "$SCRIPT_DIR/../lib/common.sh" + +REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" +K8S_ROOT="$REPO_ROOT/k8s" +KUBE_LINTER_CFG="$REPO_ROOT/.kube-linter.yaml" + +# Local-user tooling fallback: when tools aren't installed system-wide, +# allow users to drop them under ~/bin (CI typically installs to a PATH dir). +if [[ -d "$HOME/bin" ]]; then + export PATH="$HOME/bin:$PATH" +fi + +require_cmd kustomize kubeconform kube-linter shellcheck shfmt + +ENVS=(dev staging prod) +OVERLAYS_TO_BUILD=() +for env in "${ENVS[@]}"; do + root_overlay="$K8S_ROOT/overlays/$env" + vso_overlay="$K8S_ROOT/overlays/$env/vso" + if [[ -f "$root_overlay/kustomization.yaml" ]]; then + OVERLAYS_TO_BUILD+=("$root_overlay") + else + log "skip (kustomization.yaml 없음): overlays/$env" + fi + if [[ -f "$vso_overlay/kustomization.yaml" ]]; then + OVERLAYS_TO_BUILD+=("$vso_overlay") + fi +done + +# Datree CRD catalog — kubeconform fetches per-CRD JSON schema on demand. +CRD_CATALOG='https://raw.githubusercontent.com/datreeio/CRDs-catalog/main/{{.Group}}/{{.ResourceKind}}_{{.ResourceAPIVersion}}.json' + +WORK_DIR="$(mktemp -d -t project-infra-validate.XXXXXX)" +trap_cleanup_path "$WORK_DIR" + +declare -A BUILD_STATUS SCHEMA_STATUS LINT_STATUS + +for overlay in "${OVERLAYS_TO_BUILD[@]}"; do + rel="${overlay#"$REPO_ROOT/"}" + rendered="$WORK_DIR/$(echo "$rel" | tr '/' '_').yaml" + + # -- 1. kustomize build ---------------------------------------------------- + if kustomize build "$overlay" > "$rendered" 2>"$rendered.err"; then + BUILD_STATUS[$rel]="ok" + log "kustomize build $rel — OK ($(grep -c '^kind:' "$rendered") resources)" + else + BUILD_STATUS[$rel]="fail" + err "kustomize build $rel — FAIL" + cat "$rendered.err" >&2 + continue + fi + + # -- 2. kubeconform -------------------------------------------------------- + if kubeconform -strict -ignore-missing-schemas \ + -schema-location default \ + -schema-location "$CRD_CATALOG" \ + -summary \ + "$rendered" >"$rendered.kconf" 2>&1; then + SCHEMA_STATUS[$rel]="ok" + log " kubeconform — OK ($(tail -1 "$rendered.kconf"))" + else + SCHEMA_STATUS[$rel]="fail" + err " kubeconform — FAIL" + cat "$rendered.kconf" >&2 + fi + + # -- 3. kube-linter -------------------------------------------------------- + if kube-linter lint --config "$KUBE_LINTER_CFG" "$rendered" \ + >"$rendered.klint" 2>&1; then + LINT_STATUS[$rel]="ok" + log " kube-linter — OK" + else + LINT_STATUS[$rel]="fail" + err " kube-linter — findings:" + sed 's/^/ /' "$rendered.klint" >&2 + fi +done + +# ----------------------------------------------------------------------------- +# 5. shellcheck — scripts/**/*.sh 전체 +# ----------------------------------------------------------------------------- +SHELL_STATUS="skip" +SCRIPTS_ROOT="$K8S_ROOT/scripts" +mapfile -t SHELL_FILES < <(find "$SCRIPTS_ROOT" -type f -name '*.sh' -print | sort) + +if (( ${#SHELL_FILES[@]} > 0 )); then + log "shellcheck — ${#SHELL_FILES[@]} files" + if shellcheck -S style -x "${SHELL_FILES[@]}" >"$WORK_DIR/shellcheck.out" 2>&1; then + SHELL_STATUS="ok" + log " shellcheck — OK" + else + SHELL_STATUS="fail" + err " shellcheck — findings:" + sed 's/^/ /' "$WORK_DIR/shellcheck.out" >&2 + fi +fi + +# ----------------------------------------------------------------------------- +# 6. shfmt — 포매팅 drift 검증 (수정 없이 diff 만 출력) +# ----------------------------------------------------------------------------- +FMT_STATUS="skip" +if (( ${#SHELL_FILES[@]} > 0 )); then + log "shfmt -i 2 -bn -ci -d" + if shfmt -i 2 -bn -ci -d "${SHELL_FILES[@]}" >"$WORK_DIR/shfmt.out" 2>&1; then + FMT_STATUS="ok" + log " shfmt — OK" + else + FMT_STATUS="fail" + err " shfmt — drift 감지 (로컬에서 'shfmt -i 2 -bn -ci -w k8s/scripts' 로 정렬하세요):" + sed 's/^/ /' "$WORK_DIR/shfmt.out" >&2 + fi +fi + +# ----------------------------------------------------------------------------- +# Summary +# ----------------------------------------------------------------------------- +echo >&2 +log "============================================" +log " Validation summary" +log "============================================" + +failed=0 +for overlay in "${OVERLAYS_TO_BUILD[@]}"; do + rel="${overlay#"$REPO_ROOT/"}" + b="${BUILD_STATUS[$rel]:-skip}" + s="${SCHEMA_STATUS[$rel]:-skip}" + l="${LINT_STATUS[$rel]:-skip}" + printf ' %-40s build=%-4s schema=%-4s lint=%s\n' "$rel" "$b" "$s" "$l" >&2 + [[ "$b" == "fail" || "$s" == "fail" || "$l" == "fail" ]] && failed=$((failed + 1)) +done +printf ' %-40s shellcheck=%-4s shfmt=%s\n' "scripts/" "$SHELL_STATUS" "$FMT_STATUS" >&2 +[[ "$SHELL_STATUS" == "fail" || "$FMT_STATUS" == "fail" ]] && failed=$((failed + 1)) + +if (( failed > 0 )); then + err "$failed check(s) 실패" + exit 1 +fi + +log "모든 check 통과" diff --git a/k8s/scripts/lib/common.sh b/k8s/scripts/lib/common.sh new file mode 100755 index 0000000..188c621 --- /dev/null +++ b/k8s/scripts/lib/common.sh @@ -0,0 +1,302 @@ +#!/usr/bin/env bash +# Common shell library for Project-Infra scripts. +# +# Source this with: . "$(dirname "$0")/../lib/common.sh" +# or from tasks/: . "$SCRIPT_DIR/../lib/common.sh" +# +# Provides: +# - strict mode + safe IFS +# - log()/warn()/err()/die() to stderr with ISO 8601 + level prefix +# - trap_cleanup_path() / trap_cleanup_fn() — EXIT 시 경로 rm -rf 또는 함수 호출 +# - require_cmd() / require_env() — preconditions +# - confirm() — interactive + CONFIRM=yes env-var gate +# - mask_secret() — masks sensitive values in logs +# - retry() — retry a command with linear backoff +# - require_kube_context() / require_production_gate() — env 타깃 검증 가드 +# - ns_exists() / ns_phase() / strip_finalizers_in_ns() / +# strip_finalizers_all_ns_resources() / force_finalize_namespace() / +# wait_namespace_gone() — namespace teardown 복구 헬퍼 +# +# All functions emit diagnostic output to stderr; stdout stays clean +# so callers can pipe subcommand output normally. + +# shellcheck shell=bash + +# ----------------------------------------------------------------------------- +# strict mode +# ----------------------------------------------------------------------------- +set -Eeuo pipefail +IFS=$'\n\t' + +# ----------------------------------------------------------------------------- +# logging +# ----------------------------------------------------------------------------- +_ts() { date -u +"%Y-%m-%dT%H:%M:%SZ"; } + +log() { printf '%s [INFO] %s\n' "$(_ts)" "$*" >&2; } +warn() { printf '%s [WARN] %s\n' "$(_ts)" "$*" >&2; } +err() { printf '%s [ERROR] %s\n' "$(_ts)" "$*" >&2; } +die() { err "$*"; exit 1; } + +# ----------------------------------------------------------------------------- +# cleanup registration +# +# eval 기반 문자열 cleanup 은 공통 라이브러리에 두기에 부적절하다 (셸 인젝션 +# 경로가 열리기 쉽고, Google shell 가이드 기준 code smell). 대신 두 가지 구체 +# 타입만 제공한다: +# - trap_cleanup_path <path> : EXIT 시 rm -rf 로 삭제할 경로 +# - trap_cleanup_fn <fname> : EXIT 시 인자 없이 호출할 함수 이름 +# 두 종류 다 LIFO 로 실행되고, 실패해도 전체 종료 코드는 보존된다. +# ----------------------------------------------------------------------------- +_CLEANUP_PATHS=() +_CLEANUP_FNS=() + +trap_cleanup_path() { + local p="$1" + [[ -n "$p" ]] || return 0 + _CLEANUP_PATHS+=("$p") +} + +trap_cleanup_fn() { + local fn="$1" + declare -F "$fn" >/dev/null 2>&1 \ + || { err "trap_cleanup_fn: 함수를 찾을 수 없음: $fn"; return 1; } + _CLEANUP_FNS+=("$fn") +} + +_run_cleanups() { + local rc=$? + local i + for ((i = ${#_CLEANUP_FNS[@]} - 1; i >= 0; i--)); do + "${_CLEANUP_FNS[$i]}" || true + done + for ((i = ${#_CLEANUP_PATHS[@]} - 1; i >= 0; i--)); do + rm -rf -- "${_CLEANUP_PATHS[$i]}" || true + done + exit "$rc" +} + +trap _run_cleanups EXIT INT TERM + +# ----------------------------------------------------------------------------- +# preconditions +# ----------------------------------------------------------------------------- +require_cmd() { + local cmd + for cmd in "$@"; do + command -v "$cmd" >/dev/null 2>&1 || die "필수 명령어가 PATH 에 없습니다: $cmd" + done +} + +require_env() { + local var + for var in "$@"; do + if [[ -z "${!var:-}" ]]; then + die "필수 환경 변수가 비어있습니다: $var" + fi + done +} + +# ----------------------------------------------------------------------------- +# destructive gate +# ----------------------------------------------------------------------------- +# Usage: confirm "namespace 'mnt' 의 모든 리소스를 삭제합니다. 계속?" +# Returns 0 if the user said yes (interactively or via CONFIRM=yes env). +confirm() { + local prompt="$1" + if [[ "${CONFIRM:-}" == "yes" ]]; then + log "CONFIRM=yes → 자동 진행: $prompt" + return 0 + fi + if [[ ! -t 0 ]]; then + die "비대화 환경에서는 CONFIRM=yes 환경 변수를 지정하세요: $prompt" + fi + local answer + read -r -p "$prompt [y/N]: " answer + [[ "$answer" == "y" || "$answer" == "Y" ]] +} + +# ----------------------------------------------------------------------------- +# secret masking (for logs) +# ----------------------------------------------------------------------------- +# Usage: log "root token = $(mask_secret "$ROOT_TOKEN")" +mask_secret() { + local s="$1" + local n=${#s} + if (( n <= 8 )); then + printf '***' + else + printf '%s***%s' "${s:0:4}" "${s: -4}" + fi +} + +# ----------------------------------------------------------------------------- +# retry helper +# ----------------------------------------------------------------------------- +# Usage: retry 5 2 kubectl wait --for=condition=Ready pod/vault-0 -n mnt --timeout=10s +# - $1: max attempts +# - $2: sleep seconds between attempts +# - $3..: command and arguments +retry() { + local attempts="$1"; shift + local delay="$1"; shift + local i=0 + until "$@"; do + i=$((i + 1)) + if (( i >= attempts )); then + err "최대 시도 횟수 ${attempts} 회 초과: $*" + return 1 + fi + warn "실패 ($i/$attempts), ${delay}s 후 재시도: $*" + sleep "$delay" + done +} + +# ----------------------------------------------------------------------------- +# kube-context / env 타깃 검증 +# ----------------------------------------------------------------------------- +# require_kube_context <env_name> +# +# env → 기대 context 매핑을 다음 우선순위로 해결한다: +# 1) $KUBE_CONTEXT (명시적으로 주입된 값 — CI 에서 사용) +# 2) $KUBE_CONTEXT_<ENV_UPPER> (env 별 매핑 — 쉘 rc 에 선언하면 편함) +# +# 매칭 실패 시 현재 context 를 출력하고 사용자에게 context 이름을 직접 +# 재입력받아 확인한다. 비대화 환경은 die. +# +# 이 함수를 통과하면 다음이 보장된다: +# - kubectl 이 가리키는 cluster 가 env 의 의도된 cluster +# - 사용자/CI 가 그 사실을 명시적으로 인지함 (실수 클러스터 apply 방지) +require_kube_context() { + local env_name="$1" + require_cmd kubectl + + local current + current="$(kubectl config current-context 2>/dev/null || true)" + [[ -n "$current" ]] || die "kubectl current-context 가 비어있습니다. kubeconfig 를 먼저 설정하세요." + + local env_upper + env_upper="$(printf '%s' "$env_name" | tr '[:lower:]' '[:upper:]')" + local mapped_var="KUBE_CONTEXT_${env_upper}" + local expected="${KUBE_CONTEXT:-${!mapped_var:-}}" + + if [[ -n "$expected" ]]; then + if [[ "$current" != "$expected" ]]; then + die "kube-context 불일치: env=$env_name 기대='$expected' 현재='$current' (KUBE_CONTEXT 또는 ${mapped_var} 와 kubectl 현재 context 가 다름)" + fi + log "kube-context OK: env=$env_name context='$current'" + return 0 + fi + + # 매핑이 없을 때: + # - 비대화(CI) → 무조건 die. CONFIRM=yes 로도 우회 불가 (context 는 + # destructive 작업의 타깃이라 명시성이 절대 원칙). + # - 대화형 TTY → 현재 context 이름 재입력으로 확인. + if [[ ! -t 0 ]]; then + die "비대화 환경에서는 KUBE_CONTEXT 또는 ${mapped_var} 가 필수입니다 (CONFIRM=yes 로 우회 불가)." + fi + warn "env=$env_name 의 기대 context 가 지정되지 않았습니다." + warn " (권장) export KUBE_CONTEXT_${env_upper}='<context-name>' 를 쉘 rc 에 선언" + warn "현재 context: $current" + local typed + read -r -p "확인을 위해 현재 context 이름을 그대로 입력하세요 ('$current'): " typed + [[ "$typed" == "$current" ]] || die "context 이름 불일치 — 중단" +} + +# require_production_gate <env_name> +# +# env=prod 에서 파괴적 작업을 실행하려면 ALLOW_PROD_DESTRUCTIVE=yes 를 요구. +# 추가로 namespace 이름 재입력을 강제해서 오타 한 번으로 prod 가 날아가는 것을 막는다. +# dev/staging 은 통과. +require_production_gate() { + local env_name="$1" + local ns="$2" + [[ "$env_name" == "prod" ]] || return 0 + + if [[ "${ALLOW_PROD_DESTRUCTIVE:-}" != "yes" ]]; then + die "env=prod 파괴적 작업은 ALLOW_PROD_DESTRUCTIVE=yes 환경 변수가 필요합니다." + fi + if [[ ! -t 0 ]]; then + die "env=prod 는 대화형 TTY 에서만 실행 가능합니다 (namespace 재입력 확인 필요)." + fi + local typed + warn "env=prod 파괴적 작업 — namespace '$ns' 를 그대로 재입력하세요." + read -r -p "namespace: " typed + [[ "$typed" == "$ns" ]] || die "namespace 재입력 불일치 — 중단" +} + +# ----------------------------------------------------------------------------- +# namespace / finalizer 정리 헬퍼 +# ----------------------------------------------------------------------------- + +# ns_phase <namespace> — namespace 의 .status.phase 를 출력. 없으면 빈 문자열. +ns_phase() { + local ns="$1" + kubectl get namespace "$ns" -o jsonpath='{.status.phase}' 2>/dev/null || true +} + +# ns_exists <namespace> — 존재하면 0, 없으면 1 +ns_exists() { + kubectl get namespace "$1" >/dev/null 2>&1 +} + +# strip_finalizers_in_ns <namespace> <kind...> +# 지정한 kind 들의 모든 인스턴스에서 metadata.finalizers 를 제거한다. +# kind 가 CRD 여도 동작 (kubectl 이 해당 API 서버에 등록되어 있기만 하면). +strip_finalizers_in_ns() { + local ns="$1"; shift + local kind obj + for kind in "$@"; do + while IFS= read -r obj; do + [[ -z "$obj" ]] && continue + kubectl -n "$ns" patch "$obj" --type=merge \ + -p '{"metadata":{"finalizers":null}}' >/dev/null 2>&1 || true + log " finalizer 제거: -n $ns $obj" + done < <(kubectl -n "$ns" get "$kind" -o name 2>/dev/null || true) + done +} + +# strip_finalizers_all_ns_resources <namespace> +# namespace 에 남아있는 모든 namespaced 리소스의 finalizer 를 일괄 제거. +# 최후 수단 — Terminating 에 걸린 리소스들을 떼어낼 때만 사용. +strip_finalizers_all_ns_resources() { + local ns="$1" + local kinds + # namespaced=true 리소스 종류만 + kinds=$(kubectl api-resources --namespaced=true --verbs=delete -o name 2>/dev/null) + local kind + for kind in $kinds; do + while IFS= read -r obj; do + [[ -z "$obj" ]] && continue + kubectl -n "$ns" patch "$obj" --type=merge \ + -p '{"metadata":{"finalizers":null}}' >/dev/null 2>&1 || true + done < <(kubectl -n "$ns" get "$kind" -o name 2>/dev/null || true) + done +} + +# force_finalize_namespace <namespace> +# namespace 자체의 spec.finalizers 를 비워서 API 서버가 강제 삭제하도록 한다. +# kubectl replace --raw 로 /finalize 엔드포인트 호출. +# 전제: kubectl + jq 존재. 주의 — orphaned PV 등이 남을 수 있음. +force_finalize_namespace() { + local ns="$1" + require_cmd jq + log " namespace $ns 강제 finalize (API /finalize)" + kubectl get namespace "$ns" -o json \ + | jq '.spec.finalizers = [] | .metadata.finalizers = []' \ + | kubectl replace --raw "/api/v1/namespaces/${ns}/finalize" -f - >/dev/null +} + +# wait_namespace_gone <namespace> <timeout_seconds> +# namespace 가 완전히 사라질 때까지 대기. timeout 초과 시 1 반환. +wait_namespace_gone() { + local ns="$1" timeout="${2:-60}" i=0 + while ns_exists "$ns"; do + i=$((i + 1)) + if (( i >= timeout )); then + return 1 + fi + sleep 1 + done + return 0 +} diff --git a/k8s/scripts/lib/vault.sh b/k8s/scripts/lib/vault.sh new file mode 100755 index 0000000..a4d1abc --- /dev/null +++ b/k8s/scripts/lib/vault.sh @@ -0,0 +1,97 @@ +#!/usr/bin/env bash +# Vault helper functions. Depends on lib/common.sh being sourced first. +# +# Environment contract: +# VAULT_NAMESPACE — k8s namespace where vault-0 runs (default: mnt) +# VAULT_POD — Vault pod name (default: vault-0) +# VAULT_KEYS_FILE — path to JSON file produced by vault operator init +# (default: $REPO_ROOT/vault-init-keys.json) + +# shellcheck shell=bash +# shellcheck source=./common.sh +# (common.sh must be sourced by the caller.) + +: "${VAULT_NAMESPACE:=mnt}" +: "${VAULT_POD:=vault-0}" + +# vault_exec <args...> — exec vault CLI inside the Vault pod. +# -i 를 붙여서 호출자의 stdin 을 컨테이너로 파이프 (vault login -no-print - 등). +vault_exec() { + kubectl exec -i -n "$VAULT_NAMESPACE" "$VAULT_POD" -- vault "$@" +} + +# vault_exec_sh <script> — exec /bin/sh -c "$script" inside the Vault pod. +# Use when the vault CLI call needs shell features (heredocs, redirects, stdin). +vault_exec_sh() { + kubectl exec -i -n "$VAULT_NAMESPACE" "$VAULT_POD" -- sh -c "$1" +} + +# vault_status_json — echoes the JSON output of `vault status`, or empty on error. +vault_status_json() { + kubectl exec -n "$VAULT_NAMESPACE" "$VAULT_POD" -- \ + vault status -format=json 2>/dev/null || true +} + +vault_is_initialized() { + local json + json="$(vault_status_json)" + [[ -n "$json" ]] && echo "$json" | jq -e '.initialized == true' >/dev/null +} + +vault_is_sealed() { + local json + json="$(vault_status_json)" + [[ -n "$json" ]] && echo "$json" | jq -e '.sealed == true' >/dev/null +} + +# vault_unseal_from_keyfile <keys_file> +# +# 주의: `vault operator unseal <KEY>` 형태로 argv 에 키를 넘기면 `ps` 로 +# 노출된다 (container 내부 프로세스라도 보안 경계는 유지). vault CLI 는 +# argv 가 없으면 stdin 에서 읽으므로 stdin 으로만 전달한다. +vault_unseal_from_keyfile() { + local keys_file="$1" + [[ -f "$keys_file" ]] || die "unseal 키 파일이 없습니다: $keys_file" + + local threshold + threshold="$(jq -r '.unseal_threshold' "$keys_file")" + + local i key + for ((i = 0; i < threshold; i++)); do + key="$(jq -r ".unseal_keys_b64[$i]" "$keys_file")" + vault_exec operator unseal "$key" >/dev/null + done + unset key + log "Vault unseal 완료 (threshold=${threshold})" +} + +# vault_login_root_from_keyfile <keys_file> +# Logs the vault CLI inside the pod using the root token. stdout is suppressed +# so the token never reaches terminals or logs. +vault_login_root_from_keyfile() { + local keys_file="$1" + local token + token="$(jq -r '.root_token' "$keys_file")" + printf '%s' "$token" | vault_exec_sh 'vault login -no-print -' >/dev/null +} + +# vault_kv_exists <path> — returns 0 if the KV v2 secret exists. +vault_kv_exists() { + local path="$1" + [[ "$path" == secret/* ]] || path="secret/${path}" + vault_exec kv get -format=json "$path" >/dev/null 2>&1 +} + +# vault_auth_method_enabled <mount> — returns 0 if the auth method is enabled. +vault_auth_method_enabled() { + local mount="$1" + vault_exec auth list -format=json 2>/dev/null \ + | jq -e --arg m "${mount}/" '.[$m] // empty' >/dev/null +} + +# vault_secrets_engine_enabled <mount> — returns 0 if the secrets engine is enabled. +vault_secrets_engine_enabled() { + local mount="$1" + vault_exec secrets list -format=json 2>/dev/null \ + | jq -e --arg m "${mount}/" '.[$m] // empty' >/dev/null +} diff --git a/k8s/scripts/tasks/minio-operator-install.sh b/k8s/scripts/tasks/minio-operator-install.sh new file mode 100755 index 0000000..de0559e --- /dev/null +++ b/k8s/scripts/tasks/minio-operator-install.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +# Install (or upgrade) the MinIO Operator Helm release. +# +# MinIO Tenant CR (minio.min.io/v2) 는 이 Operator 가 CRD 를 먼저 등록해야 +# apply 가능하다. bootstrap.sh 의 Phase 1 이전에 실행된다. +# +# Operator 는 자체 namespace (기본 minio-operator) 에 배포된다. 애플리케이션 +# namespace (mnt) 와 분리되므로 Tenant CR 만 mnt 에 있어도 동작한다. +# +# Optional env: +# MINIO_OPERATOR_NAMESPACE — 기본 minio-operator +# MINIO_OPERATOR_RELEASE — 기본 minio-operator +# MINIO_OPERATOR_VERSION — 기본 7.0.0 + +set -Eeuo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=../lib/common.sh +. "$SCRIPT_DIR/../lib/common.sh" + +: "${MINIO_OPERATOR_NAMESPACE:=minio-operator}" +: "${MINIO_OPERATOR_RELEASE:=minio-operator}" +: "${MINIO_OPERATOR_VERSION:=7.0.0}" + +helm_install() { + log "helm repo: minio-operator 등록 (idempotent)" + helm repo add minio-operator https://operator.min.io >/dev/null 2>&1 || true + helm repo update minio-operator >/dev/null + + log "helm upgrade --install ${MINIO_OPERATOR_RELEASE} (v=${MINIO_OPERATOR_VERSION} ns=${MINIO_OPERATOR_NAMESPACE})" + helm upgrade --install "$MINIO_OPERATOR_RELEASE" minio-operator/operator \ + --namespace "$MINIO_OPERATOR_NAMESPACE" \ + --create-namespace \ + --version "$MINIO_OPERATOR_VERSION" \ + --wait \ + --atomic \ + --timeout 5m +} + +wait_ready() { + log "MinIO Operator Pod Ready 확인" + kubectl -n "$MINIO_OPERATOR_NAMESPACE" wait --for=condition=Ready \ + pod -l "app.kubernetes.io/name=operator" \ + --timeout=120s + + log "Tenant CRD 등록 확인" + kubectl get crd tenants.minio.min.io >/dev/null \ + || die "Tenant CRD 가 여전히 없음. Operator 설치 로그 확인." +} + +main() { + require_cmd helm kubectl + helm_install + wait_ready + log "minio-operator-install 태스크 완료" +} + +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + main "$@" +fi diff --git a/k8s/scripts/tasks/minio-provision-registry.sh b/k8s/scripts/tasks/minio-provision-registry.sh new file mode 100755 index 0000000..f557e1e --- /dev/null +++ b/k8s/scripts/tasks/minio-provision-registry.sh @@ -0,0 +1,189 @@ +#!/usr/bin/env bash +# MinIO Tenant 기동 후 docker-registry 용 bucket / 서비스 user / policy 를 +# 프로비저닝. +# +# 전제: +# - MinIO Tenant Pod 가 Ready (VSO 가 minio-tenant-env Secret 을 이미 동기화) +# - Vault 가 init + unseal 완료 +# - vault-seed 가 다음 경로를 seed 완료: +# secret/minio/tenant-env (root 계정, config.env 포맷) +# secret/docker-registry/minio (access_key / secret_key) +# +# 수행 순서: +# 1. Vault 에서 MinIO root 자격증명 + registry 전용 AK/SK 읽기 +# 2. kubectl port-forward 로 127.0.0.1 → MinIO Service 터널 생성 +# 3. mc alias 등록 → bucket 생성 → policy 작성 → user 생성 → policy 부착 +# +# idempotent: 이미 있는 bucket/user/policy 는 건너뜀 (회전은 별도 절차). +# +# Required env: +# REPO_ROOT +# +# Optional env: +# DOCKER_REGISTRY_BUCKET 기본 docker-registry +# DOCKER_REGISTRY_POLICY 기본 docker-registry-rw +# PF_LOCAL_PORT 기본 9900 (local port-forward) +# MINIO_SERVICE_PORT 기본 443 (Service 쪽 포트 — autoCert HTTPS) + +set -Eeuo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=../lib/common.sh +. "$SCRIPT_DIR/../lib/common.sh" +# shellcheck source=../lib/vault.sh +. "$SCRIPT_DIR/../lib/vault.sh" + +: "${DOCKER_REGISTRY_BUCKET:=docker-registry}" +: "${DOCKER_REGISTRY_POLICY:=docker-registry-rw}" +: "${PF_LOCAL_PORT:=9900}" +: "${MINIO_SERVICE_PORT:=443}" +: "${MINIO_NAMESPACE:=mnt}" +: "${MINIO_SERVICE:=minio}" + +MC_ALIAS="minio-prov" +PF_PID="" + +cleanup_port_forward() { + if [[ -n "${PF_PID:-}" ]] && kill -0 "$PF_PID" 2>/dev/null; then + kill "$PF_PID" 2>/dev/null || true + wait "$PF_PID" 2>/dev/null || true + fi +} + +wait_minio_ready() { + log "MinIO Tenant Pod Ready 대기" + retry 30 5 kubectl -n "$MINIO_NAMESPACE" wait --for=condition=Ready --timeout=10s \ + pod -l "v1.min.io/tenant=$MINIO_SERVICE" +} + +start_port_forward() { + log "port-forward 터널 생성 (127.0.0.1:${PF_LOCAL_PORT} → svc/${MINIO_SERVICE}:${MINIO_SERVICE_PORT})" + kubectl -n "$MINIO_NAMESPACE" port-forward \ + "svc/${MINIO_SERVICE}" "${PF_LOCAL_PORT}:${MINIO_SERVICE_PORT}" \ + >/dev/null 2>&1 & + PF_PID=$! + trap_cleanup_fn cleanup_port_forward + + retry 15 1 curl -ks --max-time 2 \ + "https://127.0.0.1:${PF_LOCAL_PORT}/minio/health/live" -o /dev/null + log " 터널 응답 OK" +} + +read_root_credentials() { + # secret/minio/tenant-env 는 "config.env" 필드 하나에 쉘 export 문 2 줄이 + # 들어 있다. 파싱해서 ROOT_USER / ROOT_PASSWORD 전역에 적재. + local env_blob + env_blob="$(vault_exec kv get -format=json secret/minio/tenant-env \ + | jq -r '.data.data["config.env"]')" + ROOT_USER="$(printf '%s\n' "$env_blob" \ + | sed -n 's/^export MINIO_ROOT_USER="\(.*\)"$/\1/p')" + ROOT_PASSWORD="$(printf '%s\n' "$env_blob" \ + | sed -n 's/^export MINIO_ROOT_PASSWORD="\(.*\)"$/\1/p')" + [[ -n "$ROOT_USER" && -n "$ROOT_PASSWORD" ]] \ + || die "MinIO root 자격증명 파싱 실패 — secret/minio/tenant-env 확인" +} + +read_registry_credentials() { + local json + json="$(vault_exec kv get -format=json secret/docker-registry/minio)" + REG_AK="$(printf '%s' "$json" | jq -r '.data.data.access_key')" + REG_SK="$(printf '%s' "$json" | jq -r '.data.data.secret_key')" + [[ -n "$REG_AK" && -n "$REG_SK" && "$REG_AK" != "null" && "$REG_SK" != "null" ]] \ + || die "docker-registry 자격증명 읽기 실패 — secret/docker-registry/minio 확인" +} + +configure_mc_alias() { + log "mc alias 등록 (alias=${MC_ALIAS})" + mc --insecure alias set "$MC_ALIAS" \ + "https://127.0.0.1:${PF_LOCAL_PORT}" \ + "$ROOT_USER" "$ROOT_PASSWORD" >/dev/null +} + +ensure_bucket() { + log "bucket 보장 (${DOCKER_REGISTRY_BUCKET})" + mc --insecure mb --ignore-existing "${MC_ALIAS}/${DOCKER_REGISTRY_BUCKET}" >/dev/null +} + +ensure_policy() { + log "policy 작성 (${DOCKER_REGISTRY_POLICY}) — bucket-scoped read/write" + local policy_file policy_json + policy_file="$(mktemp)" + trap_cleanup_path "$policy_file" + policy_json="$(jq -n --arg b "$DOCKER_REGISTRY_BUCKET" '{ + Version: "2012-10-17", + Statement: [ + { + Effect: "Allow", + Action: [ + "s3:ListBucket", + "s3:ListBucketMultipartUploads", + "s3:GetBucketLocation" + ], + Resource: ["arn:aws:s3:::\($b)"] + }, + { + Effect: "Allow", + Action: [ + "s3:PutObject", + "s3:GetObject", + "s3:DeleteObject", + "s3:ListMultipartUploadParts", + "s3:AbortMultipartUpload" + ], + Resource: ["arn:aws:s3:::\($b)/*"] + } + ] + }')" + printf '%s' "$policy_json" > "$policy_file" + chmod 600 "$policy_file" + # `policy create` 는 idempotent: 이미 있으면 덮어쓴다. + mc --insecure admin policy create "$MC_ALIAS" \ + "$DOCKER_REGISTRY_POLICY" "$policy_file" >/dev/null +} + +ensure_user() { + if mc --insecure admin user info "$MC_ALIAS" "$REG_AK" >/dev/null 2>&1; then + log "user '${REG_AK}' 이미 존재 — 재생성 skip (secret key 회전은 별도 절차)" + return 0 + fi + log "user '${REG_AK}' 생성" + mc --insecure admin user add "$MC_ALIAS" "$REG_AK" "$REG_SK" >/dev/null +} + +attach_policy() { + log "policy '${DOCKER_REGISTRY_POLICY}' → user '${REG_AK}' 부착" + # 이미 부착돼 있으면 mc 가 exit 1 을 반환. 우리는 idempotent 를 의도하므로 + # stderr 를 소비해서 noise 만 판별한 뒤 무시한다. + local out rc=0 + out="$(mc --insecure admin policy attach "$MC_ALIAS" \ + "$DOCKER_REGISTRY_POLICY" --user "$REG_AK" 2>&1)" || rc=$? + if (( rc != 0 )) && ! grep -qi "already" <<<"$out"; then + err " policy attach 실패: $out" + return "$rc" + fi +} + +main() { + require_cmd kubectl jq mc curl + require_env REPO_ROOT + + local keys_file="${VAULT_KEYS_FILE:-$REPO_ROOT/vault-init-keys.json}" + vault_login_root_from_keyfile "$keys_file" + + wait_minio_ready + start_port_forward + + read_root_credentials + read_registry_credentials + + configure_mc_alias + ensure_bucket + ensure_policy + ensure_user + attach_policy + + log "minio-provision-registry 태스크 완료" +} + +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + main "$@" +fi diff --git a/k8s/scripts/tasks/vault-init.sh b/k8s/scripts/tasks/vault-init.sh new file mode 100755 index 0000000..fb93bf0 --- /dev/null +++ b/k8s/scripts/tasks/vault-init.sh @@ -0,0 +1,166 @@ +#!/usr/bin/env bash +# Initialize Vault (Shamir 5-of-3) and bootstrap the Kubernetes auth method, +# KV v2 secrets engine, and the vault-secrets-operator policy/role. +# +# Idempotent: 이미 초기화된 Vault 에서는 init 을 skip 하지만 unseal 과 +# post-init 구성은 매번 재적용해서 bootstrap.sh 에서 여러 번 호출되어도 안전. +# +# Required env: +# REPO_ROOT — repository root +# +# Optional env: +# VAULT_NAMESPACE / VAULT_POD / VAULT_KEYS_FILE — see lib/vault.sh +# ENV_NAME — dev|staging|prod (bootstrap.sh 가 주입). prod 일 때 기본 경로 +# (repo working tree) 사용을 차단한다 — VAULT_KEYS_FILE 를 명시 +# 해서 repo 바깥의 안전한 경로로 써야 한다. + +set -Eeuo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=../lib/common.sh +. "$SCRIPT_DIR/../lib/common.sh" +# shellcheck source=../lib/vault.sh +. "$SCRIPT_DIR/../lib/vault.sh" + +KEY_SHARES=5 +KEY_THRESHOLD=3 + +resolve_keys_file() { + DEFAULT_KEYS_FILE="$REPO_ROOT/vault-init-keys.json" + KEYS_FILE="${VAULT_KEYS_FILE:-$DEFAULT_KEYS_FILE}" + # prod 에서 repo 내부 평문 저장 차단 — root token / unseal key 가 repo + # working tree 에 남으면 accidental commit / backup / IDE 인덱싱 경로로 유출. + if [[ "${ENV_NAME:-}" == "prod" && "$KEYS_FILE" == "$DEFAULT_KEYS_FILE" ]]; then + die "env=prod 에서는 VAULT_KEYS_FILE 를 repo 바깥 경로로 반드시 지정해야 합니다. (예: VAULT_KEYS_FILE=/run/secrets/vault-keys.json 또는 sops age 로 암호화)" + fi +} + +initialize_if_needed() { + if vault_is_initialized; then + log "Vault 는 이미 초기화되어 있습니다. 초기화 단계를 건너뜁니다." + return 0 + fi + log "Vault 초기화 중 (shares=${KEY_SHARES}, threshold=${KEY_THRESHOLD})..." + local tmp_out + tmp_out="$(mktemp)" + trap_cleanup_path "$tmp_out" + + vault_exec operator init \ + -key-shares="$KEY_SHARES" \ + -key-threshold="$KEY_THRESHOLD" \ + -format=json > "$tmp_out" + + chmod 600 "$tmp_out" + mv "$tmp_out" "$KEYS_FILE" + chmod 600 "$KEYS_FILE" + log "unseal keys + root token 저장: $KEYS_FILE (권한 0600)" +} + +unseal_if_needed() { + if vault_is_sealed; then + log "Vault sealed 상태, unseal 진행" + vault_unseal_from_keyfile "$KEYS_FILE" + else + log "Vault unsealed 상태" + fi +} + +enable_kv_v2() { + if vault_secrets_engine_enabled "secret"; then + log "secrets engine 'secret/' 이미 활성화됨" + return 0 + fi + log "KV v2 secrets engine 을 secret/ 에 활성화" + vault_exec secrets enable -path=secret kv-v2 +} + +enable_k8s_auth() { + if vault_auth_method_enabled "kubernetes"; then + log "auth method 'kubernetes/' 이미 활성화됨" + else + log "Kubernetes auth method 활성화" + vault_exec auth enable kubernetes + fi + + log "Kubernetes auth method 설정 (kubernetes_host + ca + token_reviewer_jwt)" + vault_exec_sh ' + vault write auth/kubernetes/config \ + kubernetes_host="https://kubernetes.default.svc.cluster.local:443" \ + kubernetes_ca_cert=@/var/run/secrets/kubernetes.io/serviceaccount/ca.crt \ + token_reviewer_jwt=@/var/run/secrets/kubernetes.io/serviceaccount/token + ' >/dev/null +} + +write_policies_and_roles() { + log "policy 'vso-auth-platform' 작성 (identity-postgres/*, auth-server/*, keycloak/*, oauth2-proxy/*)" + vault_exec_sh 'cat <<"EOF" | vault policy write vso-auth-platform - +path "secret/data/identity-postgres/*" { + capabilities = ["read"] +} +path "secret/data/auth-server/*" { + capabilities = ["read"] +} +path "secret/data/keycloak/*" { + capabilities = ["read"] +} +path "secret/data/oauth2-proxy/*" { + capabilities = ["read"] +} +EOF +' >/dev/null + + log "policy 'vso-storage' 작성 (minio/*, docker-registry/*)" + vault_exec_sh 'cat <<"EOF" | vault policy write vso-storage - +path "secret/data/minio/*" { + capabilities = ["read"] +} +path "secret/data/docker-registry/*" { + capabilities = ["read"] +} +EOF +' >/dev/null + + log "k8s auth role 'vso-auth-platform' 작성" + vault_exec write auth/kubernetes/role/vso-auth-platform \ + bound_service_account_names=vault-secrets-operator \ + bound_service_account_namespaces="$VAULT_NAMESPACE" \ + audience=vault \ + policies=vso-auth-platform \ + ttl=1h >/dev/null + + log "k8s auth role 'vso-storage' 작성" + vault_exec write auth/kubernetes/role/vso-storage \ + bound_service_account_names=vault-secrets-operator \ + bound_service_account_namespaces="$VAULT_NAMESPACE" \ + audience=vault \ + policies=vso-storage \ + ttl=1h >/dev/null +} + +cleanup_legacy() { + log "구 policy/role 정리 (존재하면 삭제)" + # 이전 단일 policy (세분화 전) 잔재 + vault_exec policy delete vault-secrets-operator >/dev/null 2>&1 || true + vault_exec delete auth/kubernetes/role/vault-secrets-operator >/dev/null 2>&1 || true + # Registry auth 제거로 더 이상 사용 안 하는 policy/role + vault_exec policy delete vso-registry >/dev/null 2>&1 || true + vault_exec delete auth/kubernetes/role/vso-registry >/dev/null 2>&1 || true +} + +main() { + require_cmd kubectl jq + require_env REPO_ROOT + + resolve_keys_file + initialize_if_needed + unseal_if_needed + vault_login_root_from_keyfile "$KEYS_FILE" + enable_kv_v2 + enable_k8s_auth + write_policies_and_roles + cleanup_legacy + log "vault-init 태스크 완료" +} + +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + main "$@" +fi diff --git a/k8s/scripts/tasks/vault-seed-apps.sh b/k8s/scripts/tasks/vault-seed-apps.sh new file mode 100755 index 0000000..bbe18a7 --- /dev/null +++ b/k8s/scripts/tasks/vault-seed-apps.sh @@ -0,0 +1,286 @@ +#!/usr/bin/env bash +# Seed application secrets into Vault KV v2. +# +# 이미 Vault KV 에 있는 경로는 skip — 운영자가 회전한 값을 절대 덮어쓰지 않는다. +# 아직 없는 경로에 대해 다음 순으로 값을 결정: +# 1) 환경 변수에 값이 있으면 사용 +# 2) TTY 면 read -r -s 로 프롬프트 (확인 재입력 + echo 없음, bash history 안전) +# 3) 비대화 + env 비어있고 AUTO_GENERATE=yes 면 openssl rand 로 자동 생성 +# → 생성된 값은 stdout 에 절대 출력하지 않음. 필요 시 나중에: +# vault kv get -field=password secret/<path> +# +# Vault CLI 의 `vault kv put <path> -` 모드로 **JSON payload 를 stdin** 으로 전달해 +# 비밀번호가 argv / 프로세스 테이블 / 쉘 히스토리 어디에도 노출되지 않는다. +# +# Required env: +# REPO_ROOT +# +# Optional env (비면 대화형 입력 or AUTO_GENERATE): +# POSTGRES_SUPERUSER_USERNAME 기본 postgres +# POSTGRES_SUPERUSER_PASSWORD +# +# KEYCLOAK_DB_PASSWORD +# +# AUTH_SERVER_DB_USERNAME 기본 auth_server +# AUTH_SERVER_DB_PASSWORD +# +# KEYCLOAK_ADMIN_USERNAME 기본 admin +# KEYCLOAK_ADMIN_PASSWORD +# +# MINIO_ROOT_USER 기본 minioadmin +# MINIO_ROOT_PASSWORD +# +# AUTH_SERVER_INGRESS_CLIENT_SECRET Keycloak realm client 'auth-server-ingress' 의 secret. +# KeycloakRealmImport 가 ${AUTH_SERVER_INGRESS_CLIENT_SECRET} +# env 치환으로 가져간다. oauth2-proxy 도 동일 값을 받음. +# +# OAUTH2_PROXY_COOKIE_SECRET oauth2-proxy session cookie signing/encryption key. +# +# DOCKER_REGISTRY_MINIO_ACCESSKEY docker-registry 가 MinIO 에 접근할 때 쓸 AK. +# 기본값 'docker-registry'. MinIO user 이름이 됨. +# DOCKER_REGISTRY_MINIO_SECRETKEY docker-registry MinIO SK (대소문자+숫자 8자 이상). +# +# DOCKER_REGISTRY_PUSH_USERNAME 외부 Ingress push 용 BasicAuth username. +# 기본값 'registry-push'. +# DOCKER_REGISTRY_PUSH_PASSWORD 외부 Ingress push 용 BasicAuth password. +# +# Options: +# AUTO_GENERATE=yes 대화형 TTY 가 아니고 env 도 비었을 때 랜덤 값 생성 +# +# NOTE: 비밀번호에 큰따옴표 " 는 사용 금지 (MinIO env-file 포맷이 깨짐). + +set -Eeuo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=../lib/common.sh +. "$SCRIPT_DIR/../lib/common.sh" +# shellcheck source=../lib/vault.sh +. "$SCRIPT_DIR/../lib/vault.sh" + +: "${POSTGRES_SUPERUSER_USERNAME:=postgres}" +: "${AUTH_SERVER_DB_USERNAME:=auth_server}" +: "${KEYCLOAK_ADMIN_USERNAME:=admin}" +: "${MINIO_ROOT_USER:=minioadmin}" +: "${DOCKER_REGISTRY_MINIO_ACCESSKEY:=docker-registry}" +: "${DOCKER_REGISTRY_PUSH_USERNAME:=registry-push}" + +generate_random_password() { + openssl rand -base64 48 | tr -d '/+=' | head -c 24 +} + +resolve_password() { + local var_name="$1" + local prompt_label="$2" + local value="${!var_name:-}" + + if [[ -n "$value" ]]; then + printf '%s' "$value" + return 0 + fi + + if [[ -t 0 ]]; then + local input confirm + while true; do + read -r -s -p "${prompt_label}: " input + echo >&2 + if [[ -z "$input" ]]; then + err " 비밀번호가 비어있습니다. 다시 입력하세요." + continue + fi + if [[ "$input" == *'"'* ]]; then + err " 큰따옴표(\") 는 사용할 수 없습니다 (MinIO env 포맷)." + continue + fi + read -r -s -p "${prompt_label} 한 번 더: " confirm + echo >&2 + if [[ "$input" == "$confirm" ]]; then + printf '%s' "$input" + return 0 + fi + err " 일치하지 않습니다. 다시." + done + fi + + if [[ "${AUTO_GENERATE:-}" == "yes" ]]; then + local generated + generated="$(generate_random_password)" + warn " ${var_name}: 자동 생성 (vault kv get 으로 조회 가능)" + printf '%s' "$generated" + return 0 + fi + + die "${var_name} 가 비어있고 비대화 환경이며 AUTO_GENERATE=yes 도 아닙니다." +} + +seed_if_missing() { + local path="$1" + if vault_kv_exists "$path"; then + log " secret/${path}: 이미 존재 → skip (회전된 값 보호)" + return 1 + fi + return 0 +} + +kv_put_json() { + local path="$1"; shift + local jq_expr="$1"; shift + jq -n "$@" "$jq_expr" \ + | kubectl exec -i -n "$VAULT_NAMESPACE" "$VAULT_POD" -- \ + vault kv put "secret/${path}" - >/dev/null +} + +vault_kv_get_field() { + local path="$1" + local field="$2" + vault_exec kv get -format=json "secret/${path}" \ + | jq -r --arg field "$field" '.data.data[$field] // empty' +} + +seed_identity_postgres_superuser() { + seed_if_missing "identity-postgres/superuser" || return 0 + local pw + pw="$(resolve_password POSTGRES_SUPERUSER_PASSWORD "Postgres superuser 비밀번호")" + kv_put_json "identity-postgres/superuser" \ + '{username: $u, password: $p}' \ + --arg u "$POSTGRES_SUPERUSER_USERNAME" \ + --arg p "$pw" + log " secret/identity-postgres/superuser 작성" +} + +seed_keycloak_db() { + seed_if_missing "keycloak/db" || return 0 + local pw + pw="$(resolve_password KEYCLOAK_DB_PASSWORD "Keycloak DB 비밀번호")" + kv_put_json "keycloak/db" \ + '{password: $p}' \ + --arg p "$pw" + log " secret/keycloak/db 작성" +} + +seed_auth_server_db() { + seed_if_missing "auth-server/db" || return 0 + local pw + pw="$(resolve_password AUTH_SERVER_DB_PASSWORD "auth-server DB 비밀번호")" + kv_put_json "auth-server/db" \ + '{SPRING_DATASOURCE_USERNAME: $u, SPRING_DATASOURCE_PASSWORD: $p}' \ + --arg u "$AUTH_SERVER_DB_USERNAME" \ + --arg p "$pw" + log " secret/auth-server/db 작성" +} + +seed_keycloak_bootstrap_admin() { + seed_if_missing "keycloak/bootstrap-admin" || return 0 + local pw + pw="$(resolve_password KEYCLOAK_ADMIN_PASSWORD "Keycloak 관리자 비밀번호")" + kv_put_json "keycloak/bootstrap-admin" \ + '{KEYCLOAK_ADMIN: $u, KEYCLOAK_ADMIN_PASSWORD: $p}' \ + --arg u "$KEYCLOAK_ADMIN_USERNAME" \ + --arg p "$pw" + log " secret/keycloak/bootstrap-admin 작성" +} + +seed_minio_tenant_env() { + seed_if_missing "minio/tenant-env" || return 0 + local pw env_content + pw="$(resolve_password MINIO_ROOT_PASSWORD "MinIO 루트 비밀번호")" + env_content=$( + printf 'export MINIO_ROOT_USER="%s"\nexport MINIO_ROOT_PASSWORD="%s"\n' \ + "$MINIO_ROOT_USER" "$pw" + ) + kv_put_json "minio/tenant-env" \ + '{"config.env": $cfg}' \ + --arg cfg "$env_content" + log " secret/minio/tenant-env 작성" +} + +# docker-registry 가 MinIO 에 접근할 때 쓸 AK/SK. 이 값으로 bootstrap 후속 +# phase 에서 MinIO admin user 를 만들고 bucket-scoped policy 를 부착한다. +# registry Deployment 는 VSO 가 동기화한 Secret `docker-registry-minio` 에서 +# 동일 값을 REGISTRY_STORAGE_S3_ACCESSKEY / SECRETKEY 로 읽는다. +seed_docker_registry_minio() { + seed_if_missing "docker-registry/minio" || return 0 + local sk + sk="$(resolve_password DOCKER_REGISTRY_MINIO_SECRETKEY "docker-registry MinIO secret key")" + kv_put_json "docker-registry/minio" \ + '{access_key: $ak, secret_key: $sk}' \ + --arg ak "$DOCKER_REGISTRY_MINIO_ACCESSKEY" \ + --arg sk "$sk" + log " secret/docker-registry/minio 작성 (access_key=${DOCKER_REGISTRY_MINIO_ACCESSKEY})" +} + +# 외부에서 registry.project.com 으로 push 할 때 Traefik BasicAuth 가 검증할 +# htpasswd 라인을 저장한다. Registry 자체 auth 는 켜지지 않고, 외부 Ingress +# 경계에서만 인증한다. +seed_docker_registry_basic_auth() { + seed_if_missing "docker-registry/basic-auth" || return 0 + local pw hash users + pw="$(resolve_password DOCKER_REGISTRY_PUSH_PASSWORD "docker-registry 외부 push 비밀번호")" + hash="$(openssl passwd -apr1 "$pw")" + users="${DOCKER_REGISTRY_PUSH_USERNAME}:${hash}" + kv_put_json "docker-registry/basic-auth" \ + '{username: $username, password: $password, users: $users}' \ + --arg username "$DOCKER_REGISTRY_PUSH_USERNAME" \ + --arg password "$pw" \ + --arg users "$users" + log " secret/docker-registry/basic-auth 작성 (username=${DOCKER_REGISTRY_PUSH_USERNAME})" +} + +# Keycloak realm import 가 client secret 을 ${AUTH_SERVER_INGRESS_CLIENT_SECRET} +# env 치환으로 요구한다. oauth2-proxy 는 동일 값을 /etc/oauth2-proxy-secrets/ +# client-secret 파일로 읽는다. 두 쪽이 같은 값을 써야 OIDC client 인증이 맞는다. +seed_keycloak_client_auth_server_ingress() { + seed_if_missing "keycloak/clients/auth-server-ingress" || return 0 + local pw + pw="$(resolve_password AUTH_SERVER_INGRESS_CLIENT_SECRET \ + "Keycloak client 'auth-server-ingress' secret")" + AUTH_SERVER_INGRESS_CLIENT_SECRET="$pw" + kv_put_json "keycloak/clients/auth-server-ingress" \ + '{client_secret: $p}' \ + --arg p "$pw" + log " secret/keycloak/clients/auth-server-ingress 작성" +} + +seed_oauth2_proxy_forward_auth() { + seed_if_missing "oauth2-proxy/forward-auth" || return 0 + local client_secret cookie_secret + if [[ -n "${AUTH_SERVER_INGRESS_CLIENT_SECRET:-}" ]]; then + client_secret="$AUTH_SERVER_INGRESS_CLIENT_SECRET" + elif vault_kv_exists "keycloak/clients/auth-server-ingress"; then + client_secret="$(vault_kv_get_field "keycloak/clients/auth-server-ingress" "client_secret")" + else + client_secret="$(resolve_password AUTH_SERVER_INGRESS_CLIENT_SECRET \ + "Keycloak client 'auth-server-ingress' secret")" + fi + [[ -n "$client_secret" ]] || die "auth-server-ingress client secret 을 확인할 수 없습니다." + cookie_secret="$(resolve_password OAUTH2_PROXY_COOKIE_SECRET \ + "oauth2-proxy cookie secret")" + kv_put_json "oauth2-proxy/forward-auth" \ + '{client_secret: $client_secret, cookie_secret: $cookie_secret}' \ + --arg client_secret "$client_secret" \ + --arg cookie_secret "$cookie_secret" + log " secret/oauth2-proxy/forward-auth 작성" +} + +main() { + require_cmd kubectl jq openssl + require_env REPO_ROOT + + local keys_file="${VAULT_KEYS_FILE:-$REPO_ROOT/vault-init-keys.json}" + vault_login_root_from_keyfile "$keys_file" + log "앱 시크릿 5 개 seed 시작 (이미 존재하는 경로는 skip)" + + seed_identity_postgres_superuser + seed_keycloak_db + seed_auth_server_db + seed_keycloak_bootstrap_admin + seed_minio_tenant_env + seed_keycloak_client_auth_server_ingress + seed_oauth2_proxy_forward_auth + seed_docker_registry_minio + seed_docker_registry_basic_auth + + log "vault-seed-apps 태스크 완료" +} + +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + main "$@" +fi diff --git a/k8s/scripts/tasks/vault-setup-admin.sh b/k8s/scripts/tasks/vault-setup-admin.sh new file mode 100755 index 0000000..1705329 --- /dev/null +++ b/k8s/scripts/tasks/vault-setup-admin.sh @@ -0,0 +1,180 @@ +#!/usr/bin/env bash +# Configure operator authentication for Vault. +# +# Adds a `userpass` auth method + `vault-admin` policy + one or more admin users. +# Operators use these credentials instead of the root token for day-to-day work. +# The root token stays offline and is only used for break-glass / rekey. +# +# Required env: +# REPO_ROOT — repo root (for vault-init-keys.json path) +# +# Interactive inputs (대화형 TTY 에서 자동 프롬프트): +# VAULT_ADMIN_USERNAME — login name (비면 프롬프트) +# VAULT_ADMIN_PASSWORD — 초기 비밀번호 (비면 무음 입력 프롬프트) +# +# Non-interactive 시 (CI 등): 위 두 개를 env var 로 전달해야 함. +# +# Optional env: +# VAULT_ADMIN_TTL — token TTL per login (default: 8h) +# VAULT_ADMIN_MAX_TTL — maximum TTL (default: 24h) +# +# 입력 검증: +# - username: 영문/숫자/._- 만 허용, 1..64 자 +# - TTL/MAX_TTL: 숫자+단위(s|m|h|d), 1..16 자 +# 이 값들은 Vault API 경로/파라미터에 쓰이는데, 과거 구현은 `sh -c` 문자열에 +# 직접 보간했다. allowlist 를 통과시키면 쉘 메타문자 주입 경로가 닫히고, +# 아래 `vault write` 호출은 `sh -c` 없이 kubectl exec 로 직접 실행한다. + +set -Eeuo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=../lib/common.sh +. "$SCRIPT_DIR/../lib/common.sh" +# shellcheck source=../lib/vault.sh +. "$SCRIPT_DIR/../lib/vault.sh" + +USERNAME_RE='^[A-Za-z0-9._-]{1,64}$' +TTL_RE='^[0-9]+[smhd]?$' + +validate_inputs() { + [[ "$VAULT_ADMIN_USERNAME" =~ $USERNAME_RE ]] \ + || die "VAULT_ADMIN_USERNAME 형식 불일치 (영문/숫자/._- 만 허용, 1..64자): '$VAULT_ADMIN_USERNAME'" + [[ "$ADMIN_TTL" =~ $TTL_RE ]] \ + || die "VAULT_ADMIN_TTL 형식 불일치 (예: 8h, 3600, 30m): '$ADMIN_TTL'" + [[ "$ADMIN_MAX_TTL" =~ $TTL_RE ]] \ + || die "VAULT_ADMIN_MAX_TTL 형식 불일치 (예: 24h, 86400): '$ADMIN_MAX_TTL'" + (( ${#ADMIN_TTL} <= 16 && ${#ADMIN_MAX_TTL} <= 16 )) \ + || die "TTL 값이 너무 깁니다." +} + +prompt_username_if_missing() { + [[ -n "${VAULT_ADMIN_USERNAME:-}" ]] && return 0 + if [[ ! -t 0 ]]; then + die "VAULT_ADMIN_USERNAME 환경 변수가 비어있습니다 (비대화 환경)." + fi + read -r -p "운영자 username (e.g. alice): " VAULT_ADMIN_USERNAME + [[ -n "$VAULT_ADMIN_USERNAME" ]] || die "username 이 비어있습니다." +} + +prompt_password_if_missing() { + [[ -n "${VAULT_ADMIN_PASSWORD:-}" ]] && return 0 + if [[ ! -t 0 ]]; then + die "VAULT_ADMIN_PASSWORD 환경 변수가 비어있습니다 (비대화 환경)." + fi + read -r -s -p "${VAULT_ADMIN_USERNAME} 초기 비밀번호: " VAULT_ADMIN_PASSWORD + echo >&2 + [[ -n "$VAULT_ADMIN_PASSWORD" ]] || die "비밀번호가 비어있습니다." + + local confirm + read -r -s -p "비밀번호 한 번 더 입력: " confirm + echo >&2 + [[ "$VAULT_ADMIN_PASSWORD" == "$confirm" ]] \ + || die "비밀번호가 일치하지 않습니다." + unset confirm +} + +enable_userpass() { + if vault_auth_method_enabled "userpass"; then + log "auth method 'userpass/' 이미 활성화됨" + else + log "userpass auth method 활성화" + vault_exec auth enable userpass + fi +} + +write_admin_policy() { + # sudo capability 는 sys/* 일부 엔드포인트 (seal/audit/generate-root) 때문에 필요. + # 권한이 강하므로 인프라 담당자에게만 부여. + log "policy 'vault-admin' 작성" + vault_exec_sh 'cat <<"EOF" | vault policy write vault-admin - +# KV v2 — 전 경로 관리 +path "secret/*" { + capabilities = ["create", "read", "update", "delete", "list"] +} +path "secret/data/*" { + capabilities = ["create", "read", "update", "delete", "list"] +} +path "secret/metadata/*" { + capabilities = ["create", "read", "update", "delete", "list"] +} + +# Auth method / identity 관리 +path "auth/*" { + capabilities = ["create", "read", "update", "delete", "list", "sudo"] +} +path "identity/*" { + capabilities = ["create", "read", "update", "delete", "list"] +} + +# policy 관리 (자기 자신 포함) +path "sys/policies/*" { + capabilities = ["create", "read", "update", "delete", "list"] +} +path "sys/policy/*" { + capabilities = ["create", "read", "update", "delete", "list"] +} + +# 상태 / 감사 장치 / mount 관리 +path "sys/health" { capabilities = ["read"] } +path "sys/seal-status" { capabilities = ["read"] } +path "sys/mounts" { capabilities = ["read", "list"] } +path "sys/mounts/*" { + capabilities = ["create", "read", "update", "delete", "list", "sudo"] +} +path "sys/audit" { capabilities = ["read", "list"] } +path "sys/audit/*" { + capabilities = ["create", "read", "update", "delete", "sudo"] +} +EOF +' >/dev/null +} + +# 사용자 생성/갱신 — `sh -c` 를 쓰지 않는다. +# +# 비밀번호는 `vault write -` 의 JSON stdin 으로 전달해 argv / 쉘 히스토리 노출이 +# 전혀 없다. 나머지 파라미터 (username / TTL) 는 allowlist 를 통과한 값만 +# kubectl exec argv 로 직접 전달된다 (쉘 해석 없음). +create_or_update_user() { + log "userpass user '${VAULT_ADMIN_USERNAME}' 생성/갱신 (ttl=${ADMIN_TTL} max_ttl=${ADMIN_MAX_TTL})" + jq -n --arg p "$VAULT_ADMIN_PASSWORD" \ + --arg ttl "$ADMIN_TTL" \ + --arg max "$ADMIN_MAX_TTL" \ + '{password:$p, token_policies:"vault-admin", token_ttl:$ttl, token_max_ttl:$max}' \ + | kubectl exec -i -n "$VAULT_NAMESPACE" "$VAULT_POD" -- \ + vault write "auth/userpass/users/${VAULT_ADMIN_USERNAME}" - >/dev/null +} + +print_next_steps() { + log "vault-setup-admin 태스크 완료" + log "" + log "다음 단계:" + log " 1) 운영자는 다음 명령으로 로그인 (root token 사용 중단)" + log " vault login -method=userpass username=${VAULT_ADMIN_USERNAME}" + log " 2) 각 운영자는 첫 로그인 후 비밀번호를 변경" + log " vault write auth/userpass/users/${VAULT_ADMIN_USERNAME}/password password='<new>'" + log " 3) root token 은 오프라인 금고로 이동 후 vault-init-keys.json 에서 삭제 검토" + log " (재발급은 \"vault operator generate-root -init\" 으로 가능)" +} + +main() { + require_cmd kubectl jq + require_env REPO_ROOT + + prompt_username_if_missing + prompt_password_if_missing + + ADMIN_TTL="${VAULT_ADMIN_TTL:-8h}" + ADMIN_MAX_TTL="${VAULT_ADMIN_MAX_TTL:-24h}" + validate_inputs + + local keys_file="${VAULT_KEYS_FILE:-$REPO_ROOT/vault-init-keys.json}" + vault_login_root_from_keyfile "$keys_file" + + enable_userpass + write_admin_policy + create_or_update_user + print_next_steps +} + +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + main "$@" +fi diff --git a/k8s/scripts/tasks/vso-install.sh b/k8s/scripts/tasks/vso-install.sh new file mode 100755 index 0000000..287facc --- /dev/null +++ b/k8s/scripts/tasks/vso-install.sh @@ -0,0 +1,102 @@ +#!/usr/bin/env bash +# Install (or upgrade) the vault-secrets-operator Helm release. +# +# Idempotent. 이전 실행에서 failed / pending / uninstalling 상태로 남은 릴리즈 +# 메타데이터가 있으면 먼저 uninstall 한 뒤 깨끗하게 재설치한다 (Helm 의 +# "no deployed releases" 문제 회피). +# +# `--atomic` 은 설치 실패 시 자동 rollback 을 수행하는데, rollback 결과 +# "no deployed releases" 상태가 돼서 다음 helm upgrade --install 이 실패하는 +# 연쇄 문제를 낳는다. 이 스크립트는 대신 실패 상태를 명시적으로 감지해 +# uninstall 로 cleanup 하므로 --atomic 없이 실행한다. +# +# VSO 는 전용 namespace 에 설치된다 (mnt 에 직접 두면 PSS Restricted 라벨과 +# 차트 Pod spec 의 불일치로 Pod 생성이 막힘). Controller 는 cluster-scoped RBAC +# 를 갖고 있어 mnt 의 VaultAuth/VaultStaticSecret 도 정상 watch 한다. +# +# Optional env: +# VSO_NAMESPACE — target namespace (default: vault-secrets-operator-system) +# VSO_RELEASE — helm release name (default: vault-secrets-operator) +# VSO_VERSION — chart version pin (default: 0.9.0) +# VSO_VALUES_FILE — values file path (default: $REPO_ROOT/k8s/base/plugins/vso/helm/values.yaml) + +set -Eeuo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=../lib/common.sh +. "$SCRIPT_DIR/../lib/common.sh" + +: "${VSO_NAMESPACE:=vault-secrets-operator-system}" +: "${VSO_RELEASE:=vault-secrets-operator}" +: "${VSO_VERSION:=0.9.0}" + +resolve_values_file() { + : "${VSO_VALUES_FILE:=$REPO_ROOT/k8s/base/plugins/vso/helm/values.yaml}" + [[ -f "$VSO_VALUES_FILE" ]] || die "values.yaml 없음: $VSO_VALUES_FILE" +} + +register_repo() { + log "helm repo: hashicorp 등록 (idempotent)" + helm repo add hashicorp https://helm.releases.hashicorp.com >/dev/null 2>&1 || true + helm repo update hashicorp >/dev/null +} + +# 기존 릴리즈가 dirty 상태 (failed/pending-*/uninstalling/uninstalled) 면 선제 +# uninstall 해야 다음 upgrade --install 이 성공한다. +cleanup_dirty_release() { + local status="" + if helm -n "$VSO_NAMESPACE" status "$VSO_RELEASE" -o json >/dev/null 2>&1; then + status="$(helm -n "$VSO_NAMESPACE" status "$VSO_RELEASE" -o json \ + | jq -r '.info.status // "unknown"')" + fi + + case "$status" in + "") + log "기존 Helm 릴리즈 없음 → 신규 install" + ;; + "deployed") + log "기존 릴리즈 상태=deployed → upgrade 진행" + ;; + "failed"|"pending-install"|"pending-upgrade"|"pending-rollback"|"uninstalling"|"uninstalled") + warn "기존 릴리즈 상태=$status (dirty) → helm uninstall 먼저" + helm -n "$VSO_NAMESPACE" uninstall "$VSO_RELEASE" --wait --timeout 5m 2>/dev/null \ + || warn " uninstall 중 오류 무시 (이미 부분 정리됐을 수 있음)" + ;; + *) + warn "기존 릴리즈 상태=$status (예상 외) — 일단 upgrade 시도" + ;; + esac +} + +install_or_upgrade() { + log "helm upgrade --install $VSO_RELEASE (v=$VSO_VERSION ns=$VSO_NAMESPACE)" + helm upgrade --install "$VSO_RELEASE" hashicorp/vault-secrets-operator \ + --namespace "$VSO_NAMESPACE" \ + --create-namespace \ + --version "$VSO_VERSION" \ + --values "$VSO_VALUES_FILE" \ + --wait \ + --timeout 5m +} + +wait_ready() { + log "VSO Pod Ready 확인" + kubectl -n "$VSO_NAMESPACE" wait --for=condition=Ready \ + pod -l "app.kubernetes.io/name=vault-secrets-operator" \ + --timeout=120s +} + +main() { + require_cmd helm kubectl jq + require_env REPO_ROOT + + resolve_values_file + register_repo + cleanup_dirty_release + install_or_upgrade + wait_ready + log "vso-install 태스크 완료" +} + +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + main "$@" +fi diff --git a/presentation/.venv/bin/Activate.ps1 b/presentation/.venv/bin/Activate.ps1 new file mode 100644 index 0000000..b49d77b --- /dev/null +++ b/presentation/.venv/bin/Activate.ps1 @@ -0,0 +1,247 @@ +<# +.Synopsis +Activate a Python virtual environment for the current PowerShell session. + +.Description +Pushes the python executable for a virtual environment to the front of the +$Env:PATH environment variable and sets the prompt to signify that you are +in a Python virtual environment. Makes use of the command line switches as +well as the `pyvenv.cfg` file values present in the virtual environment. + +.Parameter VenvDir +Path to the directory that contains the virtual environment to activate. The +default value for this is the parent of the directory that the Activate.ps1 +script is located within. + +.Parameter Prompt +The prompt prefix to display when this virtual environment is activated. By +default, this prompt is the name of the virtual environment folder (VenvDir) +surrounded by parentheses and followed by a single space (ie. '(.venv) '). + +.Example +Activate.ps1 +Activates the Python virtual environment that contains the Activate.ps1 script. + +.Example +Activate.ps1 -Verbose +Activates the Python virtual environment that contains the Activate.ps1 script, +and shows extra information about the activation as it executes. + +.Example +Activate.ps1 -VenvDir C:\Users\MyUser\Common\.venv +Activates the Python virtual environment located in the specified location. + +.Example +Activate.ps1 -Prompt "MyPython" +Activates the Python virtual environment that contains the Activate.ps1 script, +and prefixes the current prompt with the specified string (surrounded in +parentheses) while the virtual environment is active. + +.Notes +On Windows, it may be required to enable this Activate.ps1 script by setting the +execution policy for the user. You can do this by issuing the following PowerShell +command: + +PS C:\> Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser + +For more information on Execution Policies: +https://go.microsoft.com/fwlink/?LinkID=135170 + +#> +Param( + [Parameter(Mandatory = $false)] + [String] + $VenvDir, + [Parameter(Mandatory = $false)] + [String] + $Prompt +) + +<# Function declarations --------------------------------------------------- #> + +<# +.Synopsis +Remove all shell session elements added by the Activate script, including the +addition of the virtual environment's Python executable from the beginning of +the PATH variable. + +.Parameter NonDestructive +If present, do not remove this function from the global namespace for the +session. + +#> +function global:deactivate ([switch]$NonDestructive) { + # Revert to original values + + # The prior prompt: + if (Test-Path -Path Function:_OLD_VIRTUAL_PROMPT) { + Copy-Item -Path Function:_OLD_VIRTUAL_PROMPT -Destination Function:prompt + Remove-Item -Path Function:_OLD_VIRTUAL_PROMPT + } + + # The prior PYTHONHOME: + if (Test-Path -Path Env:_OLD_VIRTUAL_PYTHONHOME) { + Copy-Item -Path Env:_OLD_VIRTUAL_PYTHONHOME -Destination Env:PYTHONHOME + Remove-Item -Path Env:_OLD_VIRTUAL_PYTHONHOME + } + + # The prior PATH: + if (Test-Path -Path Env:_OLD_VIRTUAL_PATH) { + Copy-Item -Path Env:_OLD_VIRTUAL_PATH -Destination Env:PATH + Remove-Item -Path Env:_OLD_VIRTUAL_PATH + } + + # Just remove the VIRTUAL_ENV altogether: + if (Test-Path -Path Env:VIRTUAL_ENV) { + Remove-Item -Path env:VIRTUAL_ENV + } + + # Just remove VIRTUAL_ENV_PROMPT altogether. + if (Test-Path -Path Env:VIRTUAL_ENV_PROMPT) { + Remove-Item -Path env:VIRTUAL_ENV_PROMPT + } + + # Just remove the _PYTHON_VENV_PROMPT_PREFIX altogether: + if (Get-Variable -Name "_PYTHON_VENV_PROMPT_PREFIX" -ErrorAction SilentlyContinue) { + Remove-Variable -Name _PYTHON_VENV_PROMPT_PREFIX -Scope Global -Force + } + + # Leave deactivate function in the global namespace if requested: + if (-not $NonDestructive) { + Remove-Item -Path function:deactivate + } +} + +<# +.Description +Get-PyVenvConfig parses the values from the pyvenv.cfg file located in the +given folder, and returns them in a map. + +For each line in the pyvenv.cfg file, if that line can be parsed into exactly +two strings separated by `=` (with any amount of whitespace surrounding the =) +then it is considered a `key = value` line. The left hand string is the key, +the right hand is the value. + +If the value starts with a `'` or a `"` then the first and last character is +stripped from the value before being captured. + +.Parameter ConfigDir +Path to the directory that contains the `pyvenv.cfg` file. +#> +function Get-PyVenvConfig( + [String] + $ConfigDir +) { + Write-Verbose "Given ConfigDir=$ConfigDir, obtain values in pyvenv.cfg" + + # Ensure the file exists, and issue a warning if it doesn't (but still allow the function to continue). + $pyvenvConfigPath = Join-Path -Resolve -Path $ConfigDir -ChildPath 'pyvenv.cfg' -ErrorAction Continue + + # An empty map will be returned if no config file is found. + $pyvenvConfig = @{ } + + if ($pyvenvConfigPath) { + + Write-Verbose "File exists, parse `key = value` lines" + $pyvenvConfigContent = Get-Content -Path $pyvenvConfigPath + + $pyvenvConfigContent | ForEach-Object { + $keyval = $PSItem -split "\s*=\s*", 2 + if ($keyval[0] -and $keyval[1]) { + $val = $keyval[1] + + # Remove extraneous quotations around a string value. + if ("'""".Contains($val.Substring(0, 1))) { + $val = $val.Substring(1, $val.Length - 2) + } + + $pyvenvConfig[$keyval[0]] = $val + Write-Verbose "Adding Key: '$($keyval[0])'='$val'" + } + } + } + return $pyvenvConfig +} + + +<# Begin Activate script --------------------------------------------------- #> + +# Determine the containing directory of this script +$VenvExecPath = Split-Path -Parent $MyInvocation.MyCommand.Definition +$VenvExecDir = Get-Item -Path $VenvExecPath + +Write-Verbose "Activation script is located in path: '$VenvExecPath'" +Write-Verbose "VenvExecDir Fullname: '$($VenvExecDir.FullName)" +Write-Verbose "VenvExecDir Name: '$($VenvExecDir.Name)" + +# Set values required in priority: CmdLine, ConfigFile, Default +# First, get the location of the virtual environment, it might not be +# VenvExecDir if specified on the command line. +if ($VenvDir) { + Write-Verbose "VenvDir given as parameter, using '$VenvDir' to determine values" +} +else { + Write-Verbose "VenvDir not given as a parameter, using parent directory name as VenvDir." + $VenvDir = $VenvExecDir.Parent.FullName.TrimEnd("\\/") + Write-Verbose "VenvDir=$VenvDir" +} + +# Next, read the `pyvenv.cfg` file to determine any required value such +# as `prompt`. +$pyvenvCfg = Get-PyVenvConfig -ConfigDir $VenvDir + +# Next, set the prompt from the command line, or the config file, or +# just use the name of the virtual environment folder. +if ($Prompt) { + Write-Verbose "Prompt specified as argument, using '$Prompt'" +} +else { + Write-Verbose "Prompt not specified as argument to script, checking pyvenv.cfg value" + if ($pyvenvCfg -and $pyvenvCfg['prompt']) { + Write-Verbose " Setting based on value in pyvenv.cfg='$($pyvenvCfg['prompt'])'" + $Prompt = $pyvenvCfg['prompt']; + } + else { + Write-Verbose " Setting prompt based on parent's directory's name. (Is the directory name passed to venv module when creating the virtual environment)" + Write-Verbose " Got leaf-name of $VenvDir='$(Split-Path -Path $venvDir -Leaf)'" + $Prompt = Split-Path -Path $venvDir -Leaf + } +} + +Write-Verbose "Prompt = '$Prompt'" +Write-Verbose "VenvDir='$VenvDir'" + +# Deactivate any currently active virtual environment, but leave the +# deactivate function in place. +deactivate -nondestructive + +# Now set the environment variable VIRTUAL_ENV, used by many tools to determine +# that there is an activated venv. +$env:VIRTUAL_ENV = $VenvDir + +if (-not $Env:VIRTUAL_ENV_DISABLE_PROMPT) { + + Write-Verbose "Setting prompt to '$Prompt'" + + # Set the prompt to include the env name + # Make sure _OLD_VIRTUAL_PROMPT is global + function global:_OLD_VIRTUAL_PROMPT { "" } + Copy-Item -Path function:prompt -Destination function:_OLD_VIRTUAL_PROMPT + New-Variable -Name _PYTHON_VENV_PROMPT_PREFIX -Description "Python virtual environment prompt prefix" -Scope Global -Option ReadOnly -Visibility Public -Value $Prompt + + function global:prompt { + Write-Host -NoNewline -ForegroundColor Green "($_PYTHON_VENV_PROMPT_PREFIX) " + _OLD_VIRTUAL_PROMPT + } + $env:VIRTUAL_ENV_PROMPT = $Prompt +} + +# Clear PYTHONHOME +if (Test-Path -Path Env:PYTHONHOME) { + Copy-Item -Path Env:PYTHONHOME -Destination Env:_OLD_VIRTUAL_PYTHONHOME + Remove-Item -Path Env:PYTHONHOME +} + +# Add the venv to the PATH +Copy-Item -Path Env:PATH -Destination Env:_OLD_VIRTUAL_PATH +$Env:PATH = "$VenvExecDir$([System.IO.Path]::PathSeparator)$Env:PATH" diff --git a/presentation/.venv/bin/__pycache__/vba_extract.cpython-312.pyc b/presentation/.venv/bin/__pycache__/vba_extract.cpython-312.pyc new file mode 100644 index 0000000..14b1f3a Binary files /dev/null and b/presentation/.venv/bin/__pycache__/vba_extract.cpython-312.pyc differ diff --git a/presentation/.venv/bin/activate b/presentation/.venv/bin/activate new file mode 100644 index 0000000..b75ce97 --- /dev/null +++ b/presentation/.venv/bin/activate @@ -0,0 +1,70 @@ +# This file must be used with "source bin/activate" *from bash* +# You cannot run it directly + +deactivate () { + # reset old environment variables + if [ -n "${_OLD_VIRTUAL_PATH:-}" ] ; then + PATH="${_OLD_VIRTUAL_PATH:-}" + export PATH + unset _OLD_VIRTUAL_PATH + fi + if [ -n "${_OLD_VIRTUAL_PYTHONHOME:-}" ] ; then + PYTHONHOME="${_OLD_VIRTUAL_PYTHONHOME:-}" + export PYTHONHOME + unset _OLD_VIRTUAL_PYTHONHOME + fi + + # Call hash to forget past commands. Without forgetting + # past commands the $PATH changes we made may not be respected + hash -r 2> /dev/null + + if [ -n "${_OLD_VIRTUAL_PS1:-}" ] ; then + PS1="${_OLD_VIRTUAL_PS1:-}" + export PS1 + unset _OLD_VIRTUAL_PS1 + fi + + unset VIRTUAL_ENV + unset VIRTUAL_ENV_PROMPT + if [ ! "${1:-}" = "nondestructive" ] ; then + # Self destruct! + unset -f deactivate + fi +} + +# unset irrelevant variables +deactivate nondestructive + +# on Windows, a path can contain colons and backslashes and has to be converted: +if [ "${OSTYPE:-}" = "cygwin" ] || [ "${OSTYPE:-}" = "msys" ] ; then + # transform D:\path\to\venv to /d/path/to/venv on MSYS + # and to /cygdrive/d/path/to/venv on Cygwin + export VIRTUAL_ENV=$(cygpath /home/donghyeon/dev/Project-Infra/presentation/.venv) +else + # use the path as-is + export VIRTUAL_ENV=/home/donghyeon/dev/Project-Infra/presentation/.venv +fi + +_OLD_VIRTUAL_PATH="$PATH" +PATH="$VIRTUAL_ENV/"bin":$PATH" +export PATH + +# unset PYTHONHOME if set +# this will fail if PYTHONHOME is set to the empty string (which is bad anyway) +# could use `if (set -u; : $PYTHONHOME) ;` in bash +if [ -n "${PYTHONHOME:-}" ] ; then + _OLD_VIRTUAL_PYTHONHOME="${PYTHONHOME:-}" + unset PYTHONHOME +fi + +if [ -z "${VIRTUAL_ENV_DISABLE_PROMPT:-}" ] ; then + _OLD_VIRTUAL_PS1="${PS1:-}" + PS1='(.venv) '"${PS1:-}" + export PS1 + VIRTUAL_ENV_PROMPT='(.venv) ' + export VIRTUAL_ENV_PROMPT +fi + +# Call hash to forget past commands. Without forgetting +# past commands the $PATH changes we made may not be respected +hash -r 2> /dev/null diff --git a/presentation/.venv/bin/activate.csh b/presentation/.venv/bin/activate.csh new file mode 100644 index 0000000..5109e73 --- /dev/null +++ b/presentation/.venv/bin/activate.csh @@ -0,0 +1,27 @@ +# This file must be used with "source bin/activate.csh" *from csh*. +# You cannot run it directly. + +# Created by Davide Di Blasi <davidedb@gmail.com>. +# Ported to Python 3.3 venv by Andrew Svetlov <andrew.svetlov@gmail.com> + +alias deactivate 'test $?_OLD_VIRTUAL_PATH != 0 && setenv PATH "$_OLD_VIRTUAL_PATH" && unset _OLD_VIRTUAL_PATH; rehash; test $?_OLD_VIRTUAL_PROMPT != 0 && set prompt="$_OLD_VIRTUAL_PROMPT" && unset _OLD_VIRTUAL_PROMPT; unsetenv VIRTUAL_ENV; unsetenv VIRTUAL_ENV_PROMPT; test "\!:*" != "nondestructive" && unalias deactivate' + +# Unset irrelevant variables. +deactivate nondestructive + +setenv VIRTUAL_ENV /home/donghyeon/dev/Project-Infra/presentation/.venv + +set _OLD_VIRTUAL_PATH="$PATH" +setenv PATH "$VIRTUAL_ENV/"bin":$PATH" + + +set _OLD_VIRTUAL_PROMPT="$prompt" + +if (! "$?VIRTUAL_ENV_DISABLE_PROMPT") then + set prompt = '(.venv) '"$prompt" + setenv VIRTUAL_ENV_PROMPT '(.venv) ' +endif + +alias pydoc python -m pydoc + +rehash diff --git a/presentation/.venv/bin/activate.fish b/presentation/.venv/bin/activate.fish new file mode 100644 index 0000000..adf5580 --- /dev/null +++ b/presentation/.venv/bin/activate.fish @@ -0,0 +1,69 @@ +# This file must be used with "source <venv>/bin/activate.fish" *from fish* +# (https://fishshell.com/). You cannot run it directly. + +function deactivate -d "Exit virtual environment and return to normal shell environment" + # reset old environment variables + if test -n "$_OLD_VIRTUAL_PATH" + set -gx PATH $_OLD_VIRTUAL_PATH + set -e _OLD_VIRTUAL_PATH + end + if test -n "$_OLD_VIRTUAL_PYTHONHOME" + set -gx PYTHONHOME $_OLD_VIRTUAL_PYTHONHOME + set -e _OLD_VIRTUAL_PYTHONHOME + end + + if test -n "$_OLD_FISH_PROMPT_OVERRIDE" + set -e _OLD_FISH_PROMPT_OVERRIDE + # prevents error when using nested fish instances (Issue #93858) + if functions -q _old_fish_prompt + functions -e fish_prompt + functions -c _old_fish_prompt fish_prompt + functions -e _old_fish_prompt + end + end + + set -e VIRTUAL_ENV + set -e VIRTUAL_ENV_PROMPT + if test "$argv[1]" != "nondestructive" + # Self-destruct! + functions -e deactivate + end +end + +# Unset irrelevant variables. +deactivate nondestructive + +set -gx VIRTUAL_ENV /home/donghyeon/dev/Project-Infra/presentation/.venv + +set -gx _OLD_VIRTUAL_PATH $PATH +set -gx PATH "$VIRTUAL_ENV/"bin $PATH + +# Unset PYTHONHOME if set. +if set -q PYTHONHOME + set -gx _OLD_VIRTUAL_PYTHONHOME $PYTHONHOME + set -e PYTHONHOME +end + +if test -z "$VIRTUAL_ENV_DISABLE_PROMPT" + # fish uses a function instead of an env var to generate the prompt. + + # Save the current fish_prompt function as the function _old_fish_prompt. + functions -c fish_prompt _old_fish_prompt + + # With the original prompt function renamed, we can override with our own. + function fish_prompt + # Save the return status of the last command. + set -l old_status $status + + # Output the venv prompt; color taken from the blue of the Python logo. + printf "%s%s%s" (set_color 4B8BBE) '(.venv) ' (set_color normal) + + # Restore the return status of the previous command. + echo "exit $old_status" | . + # Output the original/"old" prompt. + _old_fish_prompt + end + + set -gx _OLD_FISH_PROMPT_OVERRIDE "$VIRTUAL_ENV" + set -gx VIRTUAL_ENV_PROMPT '(.venv) ' +end diff --git a/presentation/.venv/bin/pip b/presentation/.venv/bin/pip new file mode 100755 index 0000000..13b6b66 --- /dev/null +++ b/presentation/.venv/bin/pip @@ -0,0 +1,8 @@ +#!/home/donghyeon/dev/Project-Infra/presentation/.venv/bin/python3 +# -*- coding: utf-8 -*- +import re +import sys +from pip._internal.cli.main import main +if __name__ == '__main__': + sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) + sys.exit(main()) diff --git a/presentation/.venv/bin/pip3 b/presentation/.venv/bin/pip3 new file mode 100755 index 0000000..13b6b66 --- /dev/null +++ b/presentation/.venv/bin/pip3 @@ -0,0 +1,8 @@ +#!/home/donghyeon/dev/Project-Infra/presentation/.venv/bin/python3 +# -*- coding: utf-8 -*- +import re +import sys +from pip._internal.cli.main import main +if __name__ == '__main__': + sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) + sys.exit(main()) diff --git a/presentation/.venv/bin/pip3.12 b/presentation/.venv/bin/pip3.12 new file mode 100755 index 0000000..13b6b66 --- /dev/null +++ b/presentation/.venv/bin/pip3.12 @@ -0,0 +1,8 @@ +#!/home/donghyeon/dev/Project-Infra/presentation/.venv/bin/python3 +# -*- coding: utf-8 -*- +import re +import sys +from pip._internal.cli.main import main +if __name__ == '__main__': + sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) + sys.exit(main()) diff --git a/presentation/.venv/bin/python b/presentation/.venv/bin/python new file mode 120000 index 0000000..b8a0adb --- /dev/null +++ b/presentation/.venv/bin/python @@ -0,0 +1 @@ +python3 \ No newline at end of file diff --git a/presentation/.venv/bin/python3 b/presentation/.venv/bin/python3 new file mode 120000 index 0000000..ae65fda --- /dev/null +++ b/presentation/.venv/bin/python3 @@ -0,0 +1 @@ +/usr/bin/python3 \ No newline at end of file diff --git a/presentation/.venv/bin/python3.12 b/presentation/.venv/bin/python3.12 new file mode 120000 index 0000000..b8a0adb --- /dev/null +++ b/presentation/.venv/bin/python3.12 @@ -0,0 +1 @@ +python3 \ No newline at end of file diff --git a/presentation/.venv/bin/vba_extract.py b/presentation/.venv/bin/vba_extract.py new file mode 100755 index 0000000..44c53d4 --- /dev/null +++ b/presentation/.venv/bin/vba_extract.py @@ -0,0 +1,79 @@ +#!/home/donghyeon/dev/Project-Infra/presentation/.venv/bin/python3 + +############################################################################## +# +# vba_extract - A simple utility to extract a vbaProject.bin binary from an +# Excel 2007+ xlsm file for insertion into an XlsxWriter file. +# +# SPDX-License-Identifier: BSD-2-Clause +# +# Copyright (c) 2013-2025, John McNamara, jmcnamara@cpan.org +# + +import sys +from zipfile import BadZipFile, ZipFile + + +def extract_file(xlsm_zip, filename): + # Extract a single file from an Excel xlsm macro file. + data = xlsm_zip.read("xl/" + filename) + + # Write the data to a local file. + file = open(filename, "wb") + file.write(data) + file.close() + + +# The VBA project file and project signature file we want to extract. +vba_filename = "vbaProject.bin" +vba_signature_filename = "vbaProjectSignature.bin" + +# Get the xlsm file name from the commandline. +if len(sys.argv) > 1: + xlsm_file = sys.argv[1] +else: + print( + "\nUtility to extract a vbaProject.bin binary from an Excel 2007+ " + "xlsm macro file for insertion into an XlsxWriter file.\n" + "If the macros are digitally signed, extracts also a vbaProjectSignature.bin " + "file.\n" + "\n" + "See: https://xlsxwriter.readthedocs.io/working_with_macros.html\n" + "\n" + "Usage: vba_extract file.xlsm\n" + ) + sys.exit() + +try: + # Open the Excel xlsm file as a zip file. + xlsm_zip = ZipFile(xlsm_file, "r") + + # Read the xl/vbaProject.bin file. + extract_file(xlsm_zip, vba_filename) + print(f"Extracted: {vba_filename}") + + if "xl/" + vba_signature_filename in xlsm_zip.namelist(): + extract_file(xlsm_zip, vba_signature_filename) + print(f"Extracted: {vba_signature_filename}") + + +except IOError as e: + print(f"File error: {str(e)}") + sys.exit() + +except KeyError as e: + # Usually when there isn't a xl/vbaProject.bin member in the file. + print(f"File error: {str(e)}") + print(f"File may not be an Excel xlsm macro file: '{xlsm_file}'") + sys.exit() + +except BadZipFile as e: + # Usually if the file is an xls file and not an xlsm file. + print(f"File error: {str(e)}: '{xlsm_file}'") + print("File may not be an Excel xlsm macro file.") + sys.exit() + +except Exception as e: + # Catch any other exceptions. + print(f"File error: {str(e)}") + sys.exit() diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/AvifImagePlugin.py b/presentation/.venv/lib/python3.12/site-packages/PIL/AvifImagePlugin.py new file mode 100644 index 0000000..43c39a9 --- /dev/null +++ b/presentation/.venv/lib/python3.12/site-packages/PIL/AvifImagePlugin.py @@ -0,0 +1,293 @@ +from __future__ import annotations + +import os +from io import BytesIO +from typing import IO + +from . import ExifTags, Image, ImageFile + +try: + from . import _avif + + SUPPORTED = True +except ImportError: + SUPPORTED = False + +# Decoder options as module globals, until there is a way to pass parameters +# to Image.open (see https://github.com/python-pillow/Pillow/issues/569) +DECODE_CODEC_CHOICE = "auto" +DEFAULT_MAX_THREADS = 0 + + +def get_codec_version(codec_name: str) -> str | None: + versions = _avif.codec_versions() + for version in versions.split(", "): + if version.split(" [")[0] == codec_name: + return version.split(":")[-1].split(" ")[0] + return None + + +def _accept(prefix: bytes) -> bool | str: + if prefix[4:8] != b"ftyp": + return False + major_brand = prefix[8:12] + if major_brand in ( + # coding brands + b"avif", + b"avis", + # We accept files with AVIF container brands; we can't yet know if + # the ftyp box has the correct compatible brands, but if it doesn't + # then the plugin will raise a SyntaxError which Pillow will catch + # before moving on to the next plugin that accepts the file. + # + # Also, because this file might not actually be an AVIF file, we + # don't raise an error if AVIF support isn't properly compiled. + b"mif1", + b"msf1", + ): + if not SUPPORTED: + return ( + "image file could not be identified because AVIF support not installed" + ) + return True + return False + + +def _get_default_max_threads() -> int: + if DEFAULT_MAX_THREADS: + return DEFAULT_MAX_THREADS + if hasattr(os, "sched_getaffinity"): + return len(os.sched_getaffinity(0)) + else: + return os.cpu_count() or 1 + + +class AvifImageFile(ImageFile.ImageFile): + format = "AVIF" + format_description = "AVIF image" + __frame = -1 + + def _open(self) -> None: + if not SUPPORTED: + msg = "image file could not be opened because AVIF support not installed" + raise SyntaxError(msg) + + if DECODE_CODEC_CHOICE != "auto" and not _avif.decoder_codec_available( + DECODE_CODEC_CHOICE + ): + msg = "Invalid opening codec" + raise ValueError(msg) + + assert self.fp is not None + self._decoder = _avif.AvifDecoder( + self.fp.read(), + DECODE_CODEC_CHOICE, + _get_default_max_threads(), + ) + + # Get info from decoder + self._size, self.n_frames, self._mode, icc, exif, exif_orientation, xmp = ( + self._decoder.get_info() + ) + self.is_animated = self.n_frames > 1 + + if icc: + self.info["icc_profile"] = icc + if xmp: + self.info["xmp"] = xmp + + if exif_orientation != 1 or exif: + exif_data = Image.Exif() + if exif: + exif_data.load(exif) + original_orientation = exif_data.get(ExifTags.Base.Orientation, 1) + else: + original_orientation = 1 + if exif_orientation != original_orientation: + exif_data[ExifTags.Base.Orientation] = exif_orientation + exif = exif_data.tobytes() + if exif: + self.info["exif"] = exif + self.seek(0) + + def seek(self, frame: int) -> None: + if not self._seek_check(frame): + return + + # Set tile + self.__frame = frame + self.tile = [ImageFile._Tile("raw", (0, 0) + self.size, 0, self.mode)] + + def load(self) -> Image.core.PixelAccess | None: + if self.tile: + # We need to load the image data for this frame + data, timescale, pts_in_timescales, duration_in_timescales = ( + self._decoder.get_frame(self.__frame) + ) + self.info["timestamp"] = round(1000 * (pts_in_timescales / timescale)) + self.info["duration"] = round(1000 * (duration_in_timescales / timescale)) + + if self.fp and self._exclusive_fp: + self.fp.close() + self.fp = BytesIO(data) + + return super().load() + + def load_seek(self, pos: int) -> None: + pass + + def tell(self) -> int: + return self.__frame + + +def _save_all(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: + _save(im, fp, filename, save_all=True) + + +def _save( + im: Image.Image, fp: IO[bytes], filename: str | bytes, save_all: bool = False +) -> None: + info = im.encoderinfo.copy() + if save_all: + append_images = list(info.get("append_images", [])) + else: + append_images = [] + + total = 0 + for ims in [im] + append_images: + total += getattr(ims, "n_frames", 1) + + quality = info.get("quality", 75) + if not isinstance(quality, int) or quality < 0 or quality > 100: + msg = "Invalid quality setting" + raise ValueError(msg) + + duration = info.get("duration", 0) + subsampling = info.get("subsampling", "4:2:0") + speed = info.get("speed", 6) + max_threads = info.get("max_threads", _get_default_max_threads()) + codec = info.get("codec", "auto") + if codec != "auto" and not _avif.encoder_codec_available(codec): + msg = "Invalid saving codec" + raise ValueError(msg) + range_ = info.get("range", "full") + tile_rows_log2 = info.get("tile_rows", 0) + tile_cols_log2 = info.get("tile_cols", 0) + alpha_premultiplied = bool(info.get("alpha_premultiplied", False)) + autotiling = bool(info.get("autotiling", tile_rows_log2 == tile_cols_log2 == 0)) + + icc_profile = info.get("icc_profile", im.info.get("icc_profile")) + exif_orientation = 1 + if exif := info.get("exif"): + if isinstance(exif, Image.Exif): + exif_data = exif + else: + exif_data = Image.Exif() + exif_data.load(exif) + if ExifTags.Base.Orientation in exif_data: + exif_orientation = exif_data.pop(ExifTags.Base.Orientation) + exif = exif_data.tobytes() if exif_data else b"" + elif isinstance(exif, Image.Exif): + exif = exif_data.tobytes() + + xmp = info.get("xmp") + + if isinstance(xmp, str): + xmp = xmp.encode("utf-8") + + advanced = info.get("advanced") + if advanced is not None: + if isinstance(advanced, dict): + advanced = advanced.items() + try: + advanced = tuple(advanced) + except TypeError: + invalid = True + else: + invalid = any(not isinstance(v, tuple) or len(v) != 2 for v in advanced) + if invalid: + msg = ( + "advanced codec options must be a dict of key-value string " + "pairs or a series of key-value two-tuples" + ) + raise ValueError(msg) + + # Setup the AVIF encoder + enc = _avif.AvifEncoder( + im.size, + subsampling, + quality, + speed, + max_threads, + codec, + range_, + tile_rows_log2, + tile_cols_log2, + alpha_premultiplied, + autotiling, + icc_profile or b"", + exif or b"", + exif_orientation, + xmp or b"", + advanced, + ) + + # Add each frame + frame_idx = 0 + frame_duration = 0 + cur_idx = im.tell() + is_single_frame = total == 1 + try: + for ims in [im] + append_images: + # Get number of frames in this image + nfr = getattr(ims, "n_frames", 1) + + for idx in range(nfr): + ims.seek(idx) + + # Make sure image mode is supported + frame = ims + rawmode = ims.mode + if ims.mode not in {"RGB", "RGBA"}: + rawmode = "RGBA" if ims.has_transparency_data else "RGB" + frame = ims.convert(rawmode) + + # Update frame duration + if isinstance(duration, (list, tuple)): + frame_duration = duration[frame_idx] + else: + frame_duration = duration + + # Append the frame to the animation encoder + enc.add( + frame.tobytes("raw", rawmode), + frame_duration, + frame.size, + rawmode, + is_single_frame, + ) + + # Update frame index + frame_idx += 1 + + if not save_all: + break + + finally: + im.seek(cur_idx) + + # Get the final output from the encoder + data = enc.finish() + if data is None: + msg = "cannot write file as AVIF (encoder returned None)" + raise OSError(msg) + + fp.write(data) + + +Image.register_open(AvifImageFile.format, AvifImageFile, _accept) +if SUPPORTED: + Image.register_save(AvifImageFile.format, _save) + Image.register_save_all(AvifImageFile.format, _save_all) + Image.register_extensions(AvifImageFile.format, [".avif", ".avifs"]) + Image.register_mime(AvifImageFile.format, "image/avif") diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/BdfFontFile.py b/presentation/.venv/lib/python3.12/site-packages/PIL/BdfFontFile.py new file mode 100644 index 0000000..1c8c28f --- /dev/null +++ b/presentation/.venv/lib/python3.12/site-packages/PIL/BdfFontFile.py @@ -0,0 +1,123 @@ +# +# The Python Imaging Library +# $Id$ +# +# bitmap distribution font (bdf) file parser +# +# history: +# 1996-05-16 fl created (as bdf2pil) +# 1997-08-25 fl converted to FontFile driver +# 2001-05-25 fl removed bogus __init__ call +# 2002-11-20 fl robustification (from Kevin Cazabon, Dmitry Vasiliev) +# 2003-04-22 fl more robustification (from Graham Dumpleton) +# +# Copyright (c) 1997-2003 by Secret Labs AB. +# Copyright (c) 1997-2003 by Fredrik Lundh. +# +# See the README file for information on usage and redistribution. +# + +""" +Parse X Bitmap Distribution Format (BDF) +""" + +from __future__ import annotations + +from typing import BinaryIO + +from . import FontFile, Image + + +def bdf_char( + f: BinaryIO, +) -> ( + tuple[ + str, + int, + tuple[tuple[int, int], tuple[int, int, int, int], tuple[int, int, int, int]], + Image.Image, + ] + | None +): + # skip to STARTCHAR + while True: + s = f.readline() + if not s: + return None + if s.startswith(b"STARTCHAR"): + break + id = s[9:].strip().decode("ascii") + + # load symbol properties + props = {} + while True: + s = f.readline() + if not s or s.startswith(b"BITMAP"): + break + i = s.find(b" ") + props[s[:i].decode("ascii")] = s[i + 1 : -1].decode("ascii") + + # load bitmap + bitmap = bytearray() + while True: + s = f.readline() + if not s or s.startswith(b"ENDCHAR"): + break + bitmap += s[:-1] + + # The word BBX + # followed by the width in x (BBw), height in y (BBh), + # and x and y displacement (BBxoff0, BByoff0) + # of the lower left corner from the origin of the character. + width, height, x_disp, y_disp = (int(p) for p in props["BBX"].split()) + + # The word DWIDTH + # followed by the width in x and y of the character in device pixels. + dwx, dwy = (int(p) for p in props["DWIDTH"].split()) + + bbox = ( + (dwx, dwy), + (x_disp, -y_disp - height, width + x_disp, -y_disp), + (0, 0, width, height), + ) + + try: + im = Image.frombytes("1", (width, height), bitmap, "hex", "1") + except ValueError: + # deal with zero-width characters + im = Image.new("1", (width, height)) + + return id, int(props["ENCODING"]), bbox, im + + +class BdfFontFile(FontFile.FontFile): + """Font file plugin for the X11 BDF format.""" + + def __init__(self, fp: BinaryIO) -> None: + super().__init__() + + s = fp.readline() + if not s.startswith(b"STARTFONT 2.1"): + msg = "not a valid BDF file" + raise SyntaxError(msg) + + props = {} + comments = [] + + while True: + s = fp.readline() + if not s or s.startswith(b"ENDPROPERTIES"): + break + i = s.find(b" ") + props[s[:i].decode("ascii")] = s[i + 1 : -1].decode("ascii") + if s[:i] in [b"COMMENT", b"COPYRIGHT"]: + if s.find(b"LogicalFontDescription") < 0: + comments.append(s[i + 1 : -1].decode("ascii")) + + while True: + c = bdf_char(fp) + if not c: + break + id, ch, (xy, dst, src), im = c + if 0 <= ch < len(self.glyph): + self.glyph[ch] = xy, dst, src, im diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/BlpImagePlugin.py b/presentation/.venv/lib/python3.12/site-packages/PIL/BlpImagePlugin.py new file mode 100644 index 0000000..6bb92ed --- /dev/null +++ b/presentation/.venv/lib/python3.12/site-packages/PIL/BlpImagePlugin.py @@ -0,0 +1,498 @@ +""" +Blizzard Mipmap Format (.blp) +Jerome Leclanche <jerome@leclan.ch> + +The contents of this file are hereby released in the public domain (CC0) +Full text of the CC0 license: + https://creativecommons.org/publicdomain/zero/1.0/ + +BLP1 files, used mostly in Warcraft III, are not fully supported. +All types of BLP2 files used in World of Warcraft are supported. + +The BLP file structure consists of a header, up to 16 mipmaps of the +texture + +Texture sizes must be powers of two, though the two dimensions do +not have to be equal; 512x256 is valid, but 512x200 is not. +The first mipmap (mipmap #0) is the full size image; each subsequent +mipmap halves both dimensions. The final mipmap should be 1x1. + +BLP files come in many different flavours: +* JPEG-compressed (type == 0) - only supported for BLP1. +* RAW images (type == 1, encoding == 1). Each mipmap is stored as an + array of 8-bit values, one per pixel, left to right, top to bottom. + Each value is an index to the palette. +* DXT-compressed (type == 1, encoding == 2): +- DXT1 compression is used if alpha_encoding == 0. + - An additional alpha bit is used if alpha_depth == 1. + - DXT3 compression is used if alpha_encoding == 1. + - DXT5 compression is used if alpha_encoding == 7. +""" + +from __future__ import annotations + +import abc +import os +import struct +from enum import IntEnum +from io import BytesIO +from typing import IO + +from . import Image, ImageFile + + +class Format(IntEnum): + JPEG = 0 + + +class Encoding(IntEnum): + UNCOMPRESSED = 1 + DXT = 2 + UNCOMPRESSED_RAW_BGRA = 3 + + +class AlphaEncoding(IntEnum): + DXT1 = 0 + DXT3 = 1 + DXT5 = 7 + + +def unpack_565(i: int) -> tuple[int, int, int]: + return ((i >> 11) & 0x1F) << 3, ((i >> 5) & 0x3F) << 2, (i & 0x1F) << 3 + + +def decode_dxt1( + data: bytes, alpha: bool = False +) -> tuple[bytearray, bytearray, bytearray, bytearray]: + """ + input: one "row" of data (i.e. will produce 4*width pixels) + """ + + blocks = len(data) // 8 # number of blocks in row + ret = (bytearray(), bytearray(), bytearray(), bytearray()) + + for block_index in range(blocks): + # Decode next 8-byte block. + idx = block_index * 8 + color0, color1, bits = struct.unpack_from("<HHI", data, idx) + + r0, g0, b0 = unpack_565(color0) + r1, g1, b1 = unpack_565(color1) + + # Decode this block into 4x4 pixels + # Accumulate the results onto our 4 row accumulators + for j in range(4): + for i in range(4): + # get next control op and generate a pixel + + control = bits & 3 + bits = bits >> 2 + + a = 0xFF + if control == 0: + r, g, b = r0, g0, b0 + elif control == 1: + r, g, b = r1, g1, b1 + elif control == 2: + if color0 > color1: + r = (2 * r0 + r1) // 3 + g = (2 * g0 + g1) // 3 + b = (2 * b0 + b1) // 3 + else: + r = (r0 + r1) // 2 + g = (g0 + g1) // 2 + b = (b0 + b1) // 2 + elif control == 3: + if color0 > color1: + r = (2 * r1 + r0) // 3 + g = (2 * g1 + g0) // 3 + b = (2 * b1 + b0) // 3 + else: + r, g, b, a = 0, 0, 0, 0 + + if alpha: + ret[j].extend([r, g, b, a]) + else: + ret[j].extend([r, g, b]) + + return ret + + +def decode_dxt3(data: bytes) -> tuple[bytearray, bytearray, bytearray, bytearray]: + """ + input: one "row" of data (i.e. will produce 4*width pixels) + """ + + blocks = len(data) // 16 # number of blocks in row + ret = (bytearray(), bytearray(), bytearray(), bytearray()) + + for block_index in range(blocks): + idx = block_index * 16 + block = data[idx : idx + 16] + # Decode next 16-byte block. + bits = struct.unpack_from("<8B", block) + color0, color1 = struct.unpack_from("<HH", block, 8) + + (code,) = struct.unpack_from("<I", block, 12) + + r0, g0, b0 = unpack_565(color0) + r1, g1, b1 = unpack_565(color1) + + for j in range(4): + high = False # Do we want the higher bits? + for i in range(4): + alphacode_index = (4 * j + i) // 2 + a = bits[alphacode_index] + if high: + high = False + a >>= 4 + else: + high = True + a &= 0xF + a *= 17 # We get a value between 0 and 15 + + color_code = (code >> 2 * (4 * j + i)) & 0x03 + + if color_code == 0: + r, g, b = r0, g0, b0 + elif color_code == 1: + r, g, b = r1, g1, b1 + elif color_code == 2: + r = (2 * r0 + r1) // 3 + g = (2 * g0 + g1) // 3 + b = (2 * b0 + b1) // 3 + elif color_code == 3: + r = (2 * r1 + r0) // 3 + g = (2 * g1 + g0) // 3 + b = (2 * b1 + b0) // 3 + + ret[j].extend([r, g, b, a]) + + return ret + + +def decode_dxt5(data: bytes) -> tuple[bytearray, bytearray, bytearray, bytearray]: + """ + input: one "row" of data (i.e. will produce 4 * width pixels) + """ + + blocks = len(data) // 16 # number of blocks in row + ret = (bytearray(), bytearray(), bytearray(), bytearray()) + + for block_index in range(blocks): + idx = block_index * 16 + block = data[idx : idx + 16] + # Decode next 16-byte block. + a0, a1 = struct.unpack_from("<BB", block) + + bits = struct.unpack_from("<6B", block, 2) + alphacode1 = bits[2] | (bits[3] << 8) | (bits[4] << 16) | (bits[5] << 24) + alphacode2 = bits[0] | (bits[1] << 8) + + color0, color1 = struct.unpack_from("<HH", block, 8) + + (code,) = struct.unpack_from("<I", block, 12) + + r0, g0, b0 = unpack_565(color0) + r1, g1, b1 = unpack_565(color1) + + for j in range(4): + for i in range(4): + # get next control op and generate a pixel + alphacode_index = 3 * (4 * j + i) + + if alphacode_index <= 12: + alphacode = (alphacode2 >> alphacode_index) & 0x07 + elif alphacode_index == 15: + alphacode = (alphacode2 >> 15) | ((alphacode1 << 1) & 0x06) + else: # alphacode_index >= 18 and alphacode_index <= 45 + alphacode = (alphacode1 >> (alphacode_index - 16)) & 0x07 + + if alphacode == 0: + a = a0 + elif alphacode == 1: + a = a1 + elif a0 > a1: + a = ((8 - alphacode) * a0 + (alphacode - 1) * a1) // 7 + elif alphacode == 6: + a = 0 + elif alphacode == 7: + a = 255 + else: + a = ((6 - alphacode) * a0 + (alphacode - 1) * a1) // 5 + + color_code = (code >> 2 * (4 * j + i)) & 0x03 + + if color_code == 0: + r, g, b = r0, g0, b0 + elif color_code == 1: + r, g, b = r1, g1, b1 + elif color_code == 2: + r = (2 * r0 + r1) // 3 + g = (2 * g0 + g1) // 3 + b = (2 * b0 + b1) // 3 + elif color_code == 3: + r = (2 * r1 + r0) // 3 + g = (2 * g1 + g0) // 3 + b = (2 * b1 + b0) // 3 + + ret[j].extend([r, g, b, a]) + + return ret + + +class BLPFormatError(NotImplementedError): + pass + + +def _accept(prefix: bytes) -> bool: + return prefix.startswith((b"BLP1", b"BLP2")) + + +class BlpImageFile(ImageFile.ImageFile): + """ + Blizzard Mipmap Format + """ + + format = "BLP" + format_description = "Blizzard Mipmap Format" + + def _open(self) -> None: + assert self.fp is not None + self.magic = self.fp.read(4) + if not _accept(self.magic): + msg = f"Bad BLP magic {repr(self.magic)}" + raise BLPFormatError(msg) + + compression = struct.unpack("<i", self.fp.read(4))[0] + if self.magic == b"BLP1": + alpha = struct.unpack("<I", self.fp.read(4))[0] != 0 + else: + encoding = struct.unpack("<b", self.fp.read(1))[0] + alpha = struct.unpack("<b", self.fp.read(1))[0] != 0 + alpha_encoding = struct.unpack("<b", self.fp.read(1))[0] + self.fp.seek(1, os.SEEK_CUR) # mips + + self._size = struct.unpack("<II", self.fp.read(8)) + + args: tuple[int, int, bool] | tuple[int, int, bool, int] + if self.magic == b"BLP1": + encoding = struct.unpack("<i", self.fp.read(4))[0] + self.fp.seek(4, os.SEEK_CUR) # subtype + + args = (compression, encoding, alpha) + offset = 28 + else: + args = (compression, encoding, alpha, alpha_encoding) + offset = 20 + + decoder = self.magic.decode() + + self._mode = "RGBA" if alpha else "RGB" + self.tile = [ImageFile._Tile(decoder, (0, 0) + self.size, offset, args)] + + +class _BLPBaseDecoder(abc.ABC, ImageFile.PyDecoder): + _pulls_fd = True + + def decode(self, buffer: bytes | Image.SupportsArrayInterface) -> tuple[int, int]: + try: + self._read_header() + self._load() + except struct.error as e: + msg = "Truncated BLP file" + raise OSError(msg) from e + return -1, 0 + + @abc.abstractmethod + def _load(self) -> None: + pass + + def _read_header(self) -> None: + self._offsets = struct.unpack("<16I", self._safe_read(16 * 4)) + self._lengths = struct.unpack("<16I", self._safe_read(16 * 4)) + + def _safe_read(self, length: int) -> bytes: + assert self.fd is not None + return ImageFile._safe_read(self.fd, length) + + def _read_palette(self) -> list[tuple[int, int, int, int]]: + ret = [] + for i in range(256): + try: + b, g, r, a = struct.unpack("<4B", self._safe_read(4)) + except struct.error: + break + ret.append((b, g, r, a)) + return ret + + def _read_bgra( + self, palette: list[tuple[int, int, int, int]], alpha: bool + ) -> bytearray: + data = bytearray() + _data = BytesIO(self._safe_read(self._lengths[0])) + while True: + try: + (offset,) = struct.unpack("<B", _data.read(1)) + except struct.error: + break + b, g, r, a = palette[offset] + d: tuple[int, ...] = (r, g, b) + if alpha: + d += (a,) + data.extend(d) + return data + + +class BLP1Decoder(_BLPBaseDecoder): + def _load(self) -> None: + self._compression, self._encoding, alpha = self.args + + if self._compression == Format.JPEG: + self._decode_jpeg_stream() + + elif self._compression == 1: + if self._encoding in (4, 5): + palette = self._read_palette() + data = self._read_bgra(palette, alpha) + self.set_as_raw(data) + else: + msg = f"Unsupported BLP encoding {repr(self._encoding)}" + raise BLPFormatError(msg) + else: + msg = f"Unsupported BLP compression {repr(self._encoding)}" + raise BLPFormatError(msg) + + def _decode_jpeg_stream(self) -> None: + from .JpegImagePlugin import JpegImageFile + + (jpeg_header_size,) = struct.unpack("<I", self._safe_read(4)) + jpeg_header = self._safe_read(jpeg_header_size) + assert self.fd is not None + self._safe_read(self._offsets[0] - self.fd.tell()) # What IS this? + data = self._safe_read(self._lengths[0]) + data = jpeg_header + data + image = JpegImageFile(BytesIO(data)) + Image._decompression_bomb_check(image.size) + if image.mode == "CMYK": + args = image.tile[0].args + assert isinstance(args, tuple) + image.tile = [image.tile[0]._replace(args=(args[0], "CMYK"))] + self.set_as_raw(image.convert("RGB").tobytes(), "BGR") + + +class BLP2Decoder(_BLPBaseDecoder): + def _load(self) -> None: + self._compression, self._encoding, alpha, self._alpha_encoding = self.args + + palette = self._read_palette() + + assert self.fd is not None + self.fd.seek(self._offsets[0]) + + if self._compression == 1: + # Uncompressed or DirectX compression + + if self._encoding == Encoding.UNCOMPRESSED: + data = self._read_bgra(palette, alpha) + + elif self._encoding == Encoding.DXT: + data = bytearray() + if self._alpha_encoding == AlphaEncoding.DXT1: + linesize = (self.state.xsize + 3) // 4 * 8 + for yb in range((self.state.ysize + 3) // 4): + for d in decode_dxt1(self._safe_read(linesize), alpha): + data += d + + elif self._alpha_encoding == AlphaEncoding.DXT3: + linesize = (self.state.xsize + 3) // 4 * 16 + for yb in range((self.state.ysize + 3) // 4): + for d in decode_dxt3(self._safe_read(linesize)): + data += d + + elif self._alpha_encoding == AlphaEncoding.DXT5: + linesize = (self.state.xsize + 3) // 4 * 16 + for yb in range((self.state.ysize + 3) // 4): + for d in decode_dxt5(self._safe_read(linesize)): + data += d + else: + msg = f"Unsupported alpha encoding {repr(self._alpha_encoding)}" + raise BLPFormatError(msg) + else: + msg = f"Unknown BLP encoding {repr(self._encoding)}" + raise BLPFormatError(msg) + + else: + msg = f"Unknown BLP compression {repr(self._compression)}" + raise BLPFormatError(msg) + + self.set_as_raw(data) + + +class BLPEncoder(ImageFile.PyEncoder): + _pushes_fd = True + + def _write_palette(self) -> bytes: + data = b"" + assert self.im is not None + palette = self.im.getpalette("RGBA", "RGBA") + for i in range(len(palette) // 4): + r, g, b, a = palette[i * 4 : (i + 1) * 4] + data += struct.pack("<4B", b, g, r, a) + while len(data) < 256 * 4: + data += b"\x00" * 4 + return data + + def encode(self, bufsize: int) -> tuple[int, int, bytes]: + palette_data = self._write_palette() + + offset = 20 + 16 * 4 * 2 + len(palette_data) + data = struct.pack("<16I", offset, *((0,) * 15)) + + assert self.im is not None + w, h = self.im.size + data += struct.pack("<16I", w * h, *((0,) * 15)) + + data += palette_data + + for y in range(h): + for x in range(w): + data += struct.pack("<B", self.im.getpixel((x, y))) + + return len(data), 0, data + + +def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: + if im.mode != "P": + msg = "Unsupported BLP image mode" + raise ValueError(msg) + + magic = b"BLP1" if im.encoderinfo.get("blp_version") == "BLP1" else b"BLP2" + fp.write(magic) + + assert im.palette is not None + fp.write(struct.pack("<i", 1)) # Uncompressed or DirectX compression + + alpha_depth = 1 if im.palette.mode == "RGBA" else 0 + if magic == b"BLP1": + fp.write(struct.pack("<L", alpha_depth)) + else: + fp.write(struct.pack("<b", Encoding.UNCOMPRESSED)) + fp.write(struct.pack("<b", alpha_depth)) + fp.write(struct.pack("<b", 0)) # alpha encoding + fp.write(struct.pack("<b", 0)) # mips + fp.write(struct.pack("<II", *im.size)) + if magic == b"BLP1": + fp.write(struct.pack("<i", 5)) + fp.write(struct.pack("<i", 0)) + + ImageFile._save(im, fp, [ImageFile._Tile("BLP", (0, 0) + im.size, 0, im.mode)]) + + +Image.register_open(BlpImageFile.format, BlpImageFile, _accept) +Image.register_extension(BlpImageFile.format, ".blp") +Image.register_decoder("BLP1", BLP1Decoder) +Image.register_decoder("BLP2", BLP2Decoder) + +Image.register_save(BlpImageFile.format, _save) +Image.register_encoder("BLP", BLPEncoder) diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/BmpImagePlugin.py b/presentation/.venv/lib/python3.12/site-packages/PIL/BmpImagePlugin.py new file mode 100644 index 0000000..a6724ca --- /dev/null +++ b/presentation/.venv/lib/python3.12/site-packages/PIL/BmpImagePlugin.py @@ -0,0 +1,514 @@ +# +# The Python Imaging Library. +# $Id$ +# +# BMP file handler +# +# Windows (and OS/2) native bitmap storage format. +# +# history: +# 1995-09-01 fl Created +# 1996-04-30 fl Added save +# 1997-08-27 fl Fixed save of 1-bit images +# 1998-03-06 fl Load P images as L where possible +# 1998-07-03 fl Load P images as 1 where possible +# 1998-12-29 fl Handle small palettes +# 2002-12-30 fl Fixed load of 1-bit palette images +# 2003-04-21 fl Fixed load of 1-bit monochrome images +# 2003-04-23 fl Added limited support for BI_BITFIELDS compression +# +# Copyright (c) 1997-2003 by Secret Labs AB +# Copyright (c) 1995-2003 by Fredrik Lundh +# +# See the README file for information on usage and redistribution. +# +from __future__ import annotations + +import os +from typing import IO, Any + +from . import Image, ImageFile, ImagePalette +from ._binary import i16le as i16 +from ._binary import i32le as i32 +from ._binary import o8 +from ._binary import o16le as o16 +from ._binary import o32le as o32 + +# +# -------------------------------------------------------------------- +# Read BMP file + +BIT2MODE = { + # bits => mode, rawmode + 1: ("P", "P;1"), + 4: ("P", "P;4"), + 8: ("P", "P"), + 16: ("RGB", "BGR;15"), + 24: ("RGB", "BGR"), + 32: ("RGB", "BGRX"), +} + +USE_RAW_ALPHA = False + + +def _accept(prefix: bytes) -> bool: + return prefix.startswith(b"BM") + + +def _dib_accept(prefix: bytes) -> bool: + return i32(prefix) in [12, 40, 52, 56, 64, 108, 124] + + +# ============================================================================= +# Image plugin for the Windows BMP format. +# ============================================================================= +class BmpImageFile(ImageFile.ImageFile): + """Image plugin for the Windows Bitmap format (BMP)""" + + # ------------------------------------------------------------- Description + format_description = "Windows Bitmap" + format = "BMP" + + # -------------------------------------------------- BMP Compression values + COMPRESSIONS = {"RAW": 0, "RLE8": 1, "RLE4": 2, "BITFIELDS": 3, "JPEG": 4, "PNG": 5} + for k, v in COMPRESSIONS.items(): + vars()[k] = v + + def _bitmap(self, header: int = 0, offset: int = 0) -> None: + """Read relevant info about the BMP""" + assert self.fp is not None + read, seek = self.fp.read, self.fp.seek + if header: + seek(header) + # read bmp header size @offset 14 (this is part of the header size) + file_info: dict[str, bool | int | tuple[int, ...]] = { + "header_size": i32(read(4)), + "direction": -1, + } + + # -------------------- If requested, read header at a specific position + # read the rest of the bmp header, without its size + assert isinstance(file_info["header_size"], int) + header_data = ImageFile._safe_read(self.fp, file_info["header_size"] - 4) + + # ------------------------------- Windows Bitmap v2, IBM OS/2 Bitmap v1 + # ----- This format has different offsets because of width/height types + # 12: BITMAPCOREHEADER/OS21XBITMAPHEADER + if file_info["header_size"] == 12: + file_info["width"] = i16(header_data, 0) + file_info["height"] = i16(header_data, 2) + file_info["planes"] = i16(header_data, 4) + file_info["bits"] = i16(header_data, 6) + file_info["compression"] = self.COMPRESSIONS["RAW"] + file_info["palette_padding"] = 3 + + # --------------------------------------------- Windows Bitmap v3 to v5 + # 40: BITMAPINFOHEADER + # 52: BITMAPV2HEADER + # 56: BITMAPV3HEADER + # 64: BITMAPCOREHEADER2/OS22XBITMAPHEADER + # 108: BITMAPV4HEADER + # 124: BITMAPV5HEADER + elif file_info["header_size"] in (40, 52, 56, 64, 108, 124): + file_info["y_flip"] = header_data[7] == 0xFF + file_info["direction"] = 1 if file_info["y_flip"] else -1 + file_info["width"] = i32(header_data, 0) + file_info["height"] = ( + i32(header_data, 4) + if not file_info["y_flip"] + else 2**32 - i32(header_data, 4) + ) + file_info["planes"] = i16(header_data, 8) + file_info["bits"] = i16(header_data, 10) + file_info["compression"] = i32(header_data, 12) + # byte size of pixel data + file_info["data_size"] = i32(header_data, 16) + file_info["pixels_per_meter"] = ( + i32(header_data, 20), + i32(header_data, 24), + ) + file_info["colors"] = i32(header_data, 28) + file_info["palette_padding"] = 4 + assert isinstance(file_info["pixels_per_meter"], tuple) + self.info["dpi"] = tuple(x / 39.3701 for x in file_info["pixels_per_meter"]) + if file_info["compression"] == self.COMPRESSIONS["BITFIELDS"]: + masks = ["r_mask", "g_mask", "b_mask"] + if len(header_data) >= 48: + if len(header_data) >= 52: + masks.append("a_mask") + else: + file_info["a_mask"] = 0x0 + for idx, mask in enumerate(masks): + file_info[mask] = i32(header_data, 36 + idx * 4) + else: + # 40 byte headers only have the three components in the + # bitfields masks, ref: + # https://msdn.microsoft.com/en-us/library/windows/desktop/dd183376(v=vs.85).aspx + # See also + # https://github.com/python-pillow/Pillow/issues/1293 + # There is a 4th component in the RGBQuad, in the alpha + # location, but it is listed as a reserved component, + # and it is not generally an alpha channel + file_info["a_mask"] = 0x0 + for mask in masks: + file_info[mask] = i32(read(4)) + assert isinstance(file_info["r_mask"], int) + assert isinstance(file_info["g_mask"], int) + assert isinstance(file_info["b_mask"], int) + assert isinstance(file_info["a_mask"], int) + file_info["rgb_mask"] = ( + file_info["r_mask"], + file_info["g_mask"], + file_info["b_mask"], + ) + file_info["rgba_mask"] = ( + file_info["r_mask"], + file_info["g_mask"], + file_info["b_mask"], + file_info["a_mask"], + ) + else: + msg = f"Unsupported BMP header type ({file_info['header_size']})" + raise OSError(msg) + + # ------------------ Special case : header is reported 40, which + # ---------------------- is shorter than real size for bpp >= 16 + assert isinstance(file_info["width"], int) + assert isinstance(file_info["height"], int) + self._size = file_info["width"], file_info["height"] + + # ------- If color count was not found in the header, compute from bits + assert isinstance(file_info["bits"], int) + if not file_info.get("colors", 0): + file_info["colors"] = 1 << file_info["bits"] + assert isinstance(file_info["palette_padding"], int) + assert isinstance(file_info["colors"], int) + if offset == 14 + file_info["header_size"] and file_info["bits"] <= 8: + offset += file_info["palette_padding"] * file_info["colors"] + + # ---------------------- Check bit depth for unusual unsupported values + self._mode, raw_mode = BIT2MODE.get(file_info["bits"], ("", "")) + if not self.mode: + msg = f"Unsupported BMP pixel depth ({file_info['bits']})" + raise OSError(msg) + + # ---------------- Process BMP with Bitfields compression (not palette) + decoder_name = "raw" + if file_info["compression"] == self.COMPRESSIONS["BITFIELDS"]: + SUPPORTED: dict[int, list[tuple[int, ...]]] = { + 32: [ + (0xFF0000, 0xFF00, 0xFF, 0x0), + (0xFF000000, 0xFF0000, 0xFF00, 0x0), + (0xFF000000, 0xFF00, 0xFF, 0x0), + (0xFF000000, 0xFF0000, 0xFF00, 0xFF), + (0xFF, 0xFF00, 0xFF0000, 0xFF000000), + (0xFF0000, 0xFF00, 0xFF, 0xFF000000), + (0xFF000000, 0xFF00, 0xFF, 0xFF0000), + (0x0, 0x0, 0x0, 0x0), + ], + 24: [(0xFF0000, 0xFF00, 0xFF)], + 16: [(0xF800, 0x7E0, 0x1F), (0x7C00, 0x3E0, 0x1F)], + } + MASK_MODES = { + (32, (0xFF0000, 0xFF00, 0xFF, 0x0)): "BGRX", + (32, (0xFF000000, 0xFF0000, 0xFF00, 0x0)): "XBGR", + (32, (0xFF000000, 0xFF00, 0xFF, 0x0)): "BGXR", + (32, (0xFF000000, 0xFF0000, 0xFF00, 0xFF)): "ABGR", + (32, (0xFF, 0xFF00, 0xFF0000, 0xFF000000)): "RGBA", + (32, (0xFF0000, 0xFF00, 0xFF, 0xFF000000)): "BGRA", + (32, (0xFF000000, 0xFF00, 0xFF, 0xFF0000)): "BGAR", + (32, (0x0, 0x0, 0x0, 0x0)): "BGRA", + (24, (0xFF0000, 0xFF00, 0xFF)): "BGR", + (16, (0xF800, 0x7E0, 0x1F)): "BGR;16", + (16, (0x7C00, 0x3E0, 0x1F)): "BGR;15", + } + if file_info["bits"] in SUPPORTED: + if ( + file_info["bits"] == 32 + and file_info["rgba_mask"] in SUPPORTED[file_info["bits"]] + ): + assert isinstance(file_info["rgba_mask"], tuple) + raw_mode = MASK_MODES[(file_info["bits"], file_info["rgba_mask"])] + self._mode = "RGBA" if "A" in raw_mode else self.mode + elif ( + file_info["bits"] in (24, 16) + and file_info["rgb_mask"] in SUPPORTED[file_info["bits"]] + ): + assert isinstance(file_info["rgb_mask"], tuple) + raw_mode = MASK_MODES[(file_info["bits"], file_info["rgb_mask"])] + else: + msg = "Unsupported BMP bitfields layout" + raise OSError(msg) + else: + msg = "Unsupported BMP bitfields layout" + raise OSError(msg) + elif file_info["compression"] == self.COMPRESSIONS["RAW"]: + if file_info["bits"] == 32 and ( + header == 22 or USE_RAW_ALPHA # 32-bit .cur offset + ): + raw_mode, self._mode = "BGRA", "RGBA" + elif file_info["compression"] in ( + self.COMPRESSIONS["RLE8"], + self.COMPRESSIONS["RLE4"], + ): + decoder_name = "bmp_rle" + else: + msg = f"Unsupported BMP compression ({file_info['compression']})" + raise OSError(msg) + + # --------------- Once the header is processed, process the palette/LUT + if self.mode == "P": # Paletted for 1, 4 and 8 bit images + # ---------------------------------------------------- 1-bit images + if not (0 < file_info["colors"] <= 65536): + msg = f"Unsupported BMP Palette size ({file_info['colors']})" + raise OSError(msg) + else: + padding = file_info["palette_padding"] + palette = read(padding * file_info["colors"]) + grayscale = True + indices = ( + (0, 255) + if file_info["colors"] == 2 + else list(range(file_info["colors"])) + ) + + # ----------------- Check if grayscale and ignore palette if so + for ind, val in enumerate(indices): + rgb = palette[ind * padding : ind * padding + 3] + if rgb != o8(val) * 3: + grayscale = False + + # ------- If all colors are gray, white or black, ditch palette + if grayscale: + self._mode = "1" if file_info["colors"] == 2 else "L" + raw_mode = self.mode + else: + self._mode = "P" + self.palette = ImagePalette.raw( + "BGRX" if padding == 4 else "BGR", palette + ) + + # ---------------------------- Finally set the tile data for the plugin + self.info["compression"] = file_info["compression"] + args: list[Any] = [raw_mode] + if decoder_name == "bmp_rle": + args.append(file_info["compression"] == self.COMPRESSIONS["RLE4"]) + else: + assert isinstance(file_info["width"], int) + args.append(((file_info["width"] * file_info["bits"] + 31) >> 3) & (~3)) + args.append(file_info["direction"]) + self.tile = [ + ImageFile._Tile( + decoder_name, + (0, 0, file_info["width"], file_info["height"]), + offset or self.fp.tell(), + tuple(args), + ) + ] + + def _open(self) -> None: + """Open file, check magic number and read header""" + # read 14 bytes: magic number, filesize, reserved, header final offset + assert self.fp is not None + head_data = self.fp.read(14) + # choke if the file does not have the required magic bytes + if not _accept(head_data): + msg = "Not a BMP file" + raise SyntaxError(msg) + # read the start position of the BMP image data (u32) + offset = i32(head_data, 10) + # load bitmap information (offset=raster info) + self._bitmap(offset=offset) + + +class BmpRleDecoder(ImageFile.PyDecoder): + _pulls_fd = True + + def decode(self, buffer: bytes | Image.SupportsArrayInterface) -> tuple[int, int]: + assert self.fd is not None + rle4 = self.args[1] + data = bytearray() + x = 0 + dest_length = self.state.xsize * self.state.ysize + while len(data) < dest_length: + pixels = self.fd.read(1) + byte = self.fd.read(1) + if not pixels or not byte: + break + num_pixels = pixels[0] + if num_pixels: + # encoded mode + if x + num_pixels > self.state.xsize: + # Too much data for row + num_pixels = max(0, self.state.xsize - x) + if rle4: + first_pixel = o8(byte[0] >> 4) + second_pixel = o8(byte[0] & 0x0F) + for index in range(num_pixels): + if index % 2 == 0: + data += first_pixel + else: + data += second_pixel + else: + data += byte * num_pixels + x += num_pixels + else: + if byte[0] == 0: + # end of line + while len(data) % self.state.xsize != 0: + data += b"\x00" + x = 0 + elif byte[0] == 1: + # end of bitmap + break + elif byte[0] == 2: + # delta + bytes_read = self.fd.read(2) + if len(bytes_read) < 2: + break + right, up = bytes_read + data += b"\x00" * (right + up * self.state.xsize) + x = len(data) % self.state.xsize + else: + # absolute mode + if rle4: + # 2 pixels per byte + byte_count = byte[0] // 2 + bytes_read = self.fd.read(byte_count) + for byte_read in bytes_read: + data += o8(byte_read >> 4) + data += o8(byte_read & 0x0F) + else: + byte_count = byte[0] + bytes_read = self.fd.read(byte_count) + data += bytes_read + if len(bytes_read) < byte_count: + break + x += byte[0] + + # align to 16-bit word boundary + if self.fd.tell() % 2 != 0: + self.fd.seek(1, os.SEEK_CUR) + rawmode = "L" if self.mode == "L" else "P" + self.set_as_raw(bytes(data), rawmode, (0, self.args[-1])) + return -1, 0 + + +# ============================================================================= +# Image plugin for the DIB format (BMP alias) +# ============================================================================= +class DibImageFile(BmpImageFile): + format = "DIB" + format_description = "Windows Bitmap" + + def _open(self) -> None: + self._bitmap() + + +# +# -------------------------------------------------------------------- +# Write BMP file + + +SAVE = { + "1": ("1", 1, 2), + "L": ("L", 8, 256), + "P": ("P", 8, 256), + "RGB": ("BGR", 24, 0), + "RGBA": ("BGRA", 32, 0), +} + + +def _dib_save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: + _save(im, fp, filename, False) + + +def _save( + im: Image.Image, fp: IO[bytes], filename: str | bytes, bitmap_header: bool = True +) -> None: + try: + rawmode, bits, colors = SAVE[im.mode] + except KeyError as e: + msg = f"cannot write mode {im.mode} as BMP" + raise OSError(msg) from e + + info = im.encoderinfo + + dpi = info.get("dpi", (96, 96)) + + # 1 meter == 39.3701 inches + ppm = tuple(int(x * 39.3701 + 0.5) for x in dpi) + + stride = ((im.size[0] * bits + 7) // 8 + 3) & (~3) + header = 40 # or 64 for OS/2 version 2 + image = stride * im.size[1] + + if im.mode == "1": + palette = b"".join(o8(i) * 3 + b"\x00" for i in (0, 255)) + elif im.mode == "L": + palette = b"".join(o8(i) * 3 + b"\x00" for i in range(256)) + elif im.mode == "P": + palette = im.im.getpalette("RGB", "BGRX") + colors = len(palette) // 4 + else: + palette = None + + # bitmap header + if bitmap_header: + offset = 14 + header + colors * 4 + file_size = offset + image + if file_size > 2**32 - 1: + msg = "File size is too large for the BMP format" + raise ValueError(msg) + fp.write( + b"BM" # file type (magic) + + o32(file_size) # file size + + o32(0) # reserved + + o32(offset) # image data offset + ) + + # bitmap info header + fp.write( + o32(header) # info header size + + o32(im.size[0]) # width + + o32(im.size[1]) # height + + o16(1) # planes + + o16(bits) # depth + + o32(0) # compression (0=uncompressed) + + o32(image) # size of bitmap + + o32(ppm[0]) # resolution + + o32(ppm[1]) # resolution + + o32(colors) # colors used + + o32(colors) # colors important + ) + + fp.write(b"\0" * (header - 40)) # padding (for OS/2 format) + + if palette: + fp.write(palette) + + ImageFile._save( + im, fp, [ImageFile._Tile("raw", (0, 0) + im.size, 0, (rawmode, stride, -1))] + ) + + +# +# -------------------------------------------------------------------- +# Registry + + +Image.register_open(BmpImageFile.format, BmpImageFile, _accept) +Image.register_save(BmpImageFile.format, _save) + +Image.register_extension(BmpImageFile.format, ".bmp") + +Image.register_mime(BmpImageFile.format, "image/bmp") + +Image.register_decoder("bmp_rle", BmpRleDecoder) + +Image.register_open(DibImageFile.format, DibImageFile, _dib_accept) +Image.register_save(DibImageFile.format, _dib_save) + +Image.register_extension(DibImageFile.format, ".dib") + +Image.register_mime(DibImageFile.format, "image/bmp") diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/BufrStubImagePlugin.py b/presentation/.venv/lib/python3.12/site-packages/PIL/BufrStubImagePlugin.py new file mode 100644 index 0000000..d82c4c7 --- /dev/null +++ b/presentation/.venv/lib/python3.12/site-packages/PIL/BufrStubImagePlugin.py @@ -0,0 +1,72 @@ +# +# The Python Imaging Library +# $Id$ +# +# BUFR stub adapter +# +# Copyright (c) 1996-2003 by Fredrik Lundh +# +# See the README file for information on usage and redistribution. +# +from __future__ import annotations + +import os +from typing import IO + +from . import Image, ImageFile + +_handler = None + + +def register_handler(handler: ImageFile.StubHandler | None) -> None: + """ + Install application-specific BUFR image handler. + + :param handler: Handler object. + """ + global _handler + _handler = handler + + +# -------------------------------------------------------------------- +# Image adapter + + +def _accept(prefix: bytes) -> bool: + return prefix.startswith((b"BUFR", b"ZCZC")) + + +class BufrStubImageFile(ImageFile.StubImageFile): + format = "BUFR" + format_description = "BUFR" + + def _open(self) -> None: + assert self.fp is not None + if not _accept(self.fp.read(4)): + msg = "Not a BUFR file" + raise SyntaxError(msg) + + self.fp.seek(-4, os.SEEK_CUR) + + # make something up + self._mode = "F" + self._size = 1, 1 + + def _load(self) -> ImageFile.StubHandler | None: + return _handler + + +def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: + if _handler is None or not hasattr(_handler, "save"): + msg = "BUFR save handler not installed" + raise OSError(msg) + _handler.save(im, fp, filename) + + +# -------------------------------------------------------------------- +# Registry + +Image.register_open(BufrStubImageFile.format, BufrStubImageFile, _accept) +Image.register_save(BufrStubImageFile.format, _save) + +Image.register_extension(BufrStubImageFile.format, ".bufr") diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/ContainerIO.py b/presentation/.venv/lib/python3.12/site-packages/PIL/ContainerIO.py new file mode 100644 index 0000000..ec9e66c --- /dev/null +++ b/presentation/.venv/lib/python3.12/site-packages/PIL/ContainerIO.py @@ -0,0 +1,173 @@ +# +# The Python Imaging Library. +# $Id$ +# +# a class to read from a container file +# +# History: +# 1995-06-18 fl Created +# 1995-09-07 fl Added readline(), readlines() +# +# Copyright (c) 1997-2001 by Secret Labs AB +# Copyright (c) 1995 by Fredrik Lundh +# +# See the README file for information on usage and redistribution. +# +from __future__ import annotations + +import io +from collections.abc import Iterable +from typing import IO, AnyStr, NoReturn + + +class ContainerIO(IO[AnyStr]): + """ + A file object that provides read access to a part of an existing + file (for example a TAR file). + """ + + def __init__(self, file: IO[AnyStr], offset: int, length: int) -> None: + """ + Create file object. + + :param file: Existing file. + :param offset: Start of region, in bytes. + :param length: Size of region, in bytes. + """ + self.fh: IO[AnyStr] = file + self.pos = 0 + self.offset = offset + self.length = length + self.fh.seek(offset) + + ## + # Always false. + + def isatty(self) -> bool: + return False + + def seekable(self) -> bool: + return True + + def seek(self, offset: int, mode: int = io.SEEK_SET) -> int: + """ + Move file pointer. + + :param offset: Offset in bytes. + :param mode: Starting position. Use 0 for beginning of region, 1 + for current offset, and 2 for end of region. You cannot move + the pointer outside the defined region. + :returns: Offset from start of region, in bytes. + """ + if mode == 1: + self.pos = self.pos + offset + elif mode == 2: + self.pos = self.length + offset + else: + self.pos = offset + # clamp + self.pos = max(0, min(self.pos, self.length)) + self.fh.seek(self.offset + self.pos) + return self.pos + + def tell(self) -> int: + """ + Get current file pointer. + + :returns: Offset from start of region, in bytes. + """ + return self.pos + + def readable(self) -> bool: + return True + + def read(self, n: int = -1) -> AnyStr: + """ + Read data. + + :param n: Number of bytes to read. If omitted, zero or negative, + read until end of region. + :returns: An 8-bit string. + """ + if n > 0: + n = min(n, self.length - self.pos) + else: + n = self.length - self.pos + if n <= 0: # EOF + return b"" if "b" in self.fh.mode else "" # type: ignore[return-value] + self.pos = self.pos + n + return self.fh.read(n) + + def readline(self, n: int = -1) -> AnyStr: + """ + Read a line of text. + + :param n: Number of bytes to read. If omitted, zero or negative, + read until end of line. + :returns: An 8-bit string. + """ + s: AnyStr = b"" if "b" in self.fh.mode else "" # type: ignore[assignment] + newline_character = b"\n" if "b" in self.fh.mode else "\n" + while True: + c = self.read(1) + if not c: + break + s = s + c + if c == newline_character or len(s) == n: + break + return s + + def readlines(self, n: int | None = -1) -> list[AnyStr]: + """ + Read multiple lines of text. + + :param n: Number of lines to read. If omitted, zero, negative or None, + read until end of region. + :returns: A list of 8-bit strings. + """ + lines = [] + while True: + s = self.readline() + if not s: + break + lines.append(s) + if len(lines) == n: + break + return lines + + def writable(self) -> bool: + return False + + def write(self, b: AnyStr) -> NoReturn: + raise NotImplementedError() + + def writelines(self, lines: Iterable[AnyStr]) -> NoReturn: + raise NotImplementedError() + + def truncate(self, size: int | None = None) -> int: + raise NotImplementedError() + + def __enter__(self) -> ContainerIO[AnyStr]: + return self + + def __exit__(self, *args: object) -> None: + self.close() + + def __iter__(self) -> ContainerIO[AnyStr]: + return self + + def __next__(self) -> AnyStr: + line = self.readline() + if not line: + msg = "end of region" + raise StopIteration(msg) + return line + + def fileno(self) -> int: + return self.fh.fileno() + + def flush(self) -> None: + self.fh.flush() + + def close(self) -> None: + self.fh.close() diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/CurImagePlugin.py b/presentation/.venv/lib/python3.12/site-packages/PIL/CurImagePlugin.py new file mode 100644 index 0000000..9c188e0 --- /dev/null +++ b/presentation/.venv/lib/python3.12/site-packages/PIL/CurImagePlugin.py @@ -0,0 +1,75 @@ +# +# The Python Imaging Library. +# $Id$ +# +# Windows Cursor support for PIL +# +# notes: +# uses BmpImagePlugin.py to read the bitmap data. +# +# history: +# 96-05-27 fl Created +# +# Copyright (c) Secret Labs AB 1997. +# Copyright (c) Fredrik Lundh 1996. +# +# See the README file for information on usage and redistribution. +# +from __future__ import annotations + +from . import BmpImagePlugin, Image +from ._binary import i16le as i16 +from ._binary import i32le as i32 + +# +# -------------------------------------------------------------------- + + +def _accept(prefix: bytes) -> bool: + return prefix.startswith(b"\0\0\2\0") + + +## +# Image plugin for Windows Cursor files. + + +class CurImageFile(BmpImagePlugin.BmpImageFile): + format = "CUR" + format_description = "Windows Cursor" + + def _open(self) -> None: + assert self.fp is not None + offset = self.fp.tell() + + # check magic + s = self.fp.read(6) + if not _accept(s): + msg = "not a CUR file" + raise SyntaxError(msg) + + # pick the largest cursor in the file + m = b"" + for i in range(i16(s, 4)): + s = self.fp.read(16) + if not m: + m = s + elif s[0] > m[0] and s[1] > m[1]: + m = s + if not m: + msg = "No cursors were found" + raise TypeError(msg) + + # load as bitmap + self._bitmap(i32(m, 12) + offset) + + # patch up the bitmap height + self._size = self.size[0], self.size[1] // 2 + self.tile = [self.tile[0]._replace(extents=(0, 0) + self.size)] + + +# +# -------------------------------------------------------------------- + +Image.register_open(CurImageFile.format, CurImageFile, _accept) + +Image.register_extension(CurImageFile.format, ".cur") diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/DcxImagePlugin.py b/presentation/.venv/lib/python3.12/site-packages/PIL/DcxImagePlugin.py new file mode 100644 index 0000000..d3f456d --- /dev/null +++ b/presentation/.venv/lib/python3.12/site-packages/PIL/DcxImagePlugin.py @@ -0,0 +1,84 @@ +# +# The Python Imaging Library. +# $Id$ +# +# DCX file handling +# +# DCX is a container file format defined by Intel, commonly used +# for fax applications. Each DCX file consists of a directory +# (a list of file offsets) followed by a set of (usually 1-bit) +# PCX files. +# +# History: +# 1995-09-09 fl Created +# 1996-03-20 fl Properly derived from PcxImageFile. +# 1998-07-15 fl Renamed offset attribute to avoid name clash +# 2002-07-30 fl Fixed file handling +# +# Copyright (c) 1997-98 by Secret Labs AB. +# Copyright (c) 1995-96 by Fredrik Lundh. +# +# See the README file for information on usage and redistribution. +# +from __future__ import annotations + +from . import Image +from ._binary import i32le as i32 +from ._util import DeferredError +from .PcxImagePlugin import PcxImageFile + +MAGIC = 0x3ADE68B1 # QUIZ: what's this value, then? + + +def _accept(prefix: bytes) -> bool: + return len(prefix) >= 4 and i32(prefix) == MAGIC + + +## +# Image plugin for the Intel DCX format. + + +class DcxImageFile(PcxImageFile): + format = "DCX" + format_description = "Intel DCX" + _close_exclusive_fp_after_loading = False + + def _open(self) -> None: + # Header + assert self.fp is not None + s = self.fp.read(4) + if not _accept(s): + msg = "not a DCX file" + raise SyntaxError(msg) + + # Component directory + self._offset = [] + for i in range(1024): + offset = i32(self.fp.read(4)) + if not offset: + break + self._offset.append(offset) + + self._fp = self.fp + self.frame = -1 + self.n_frames = len(self._offset) + self.is_animated = self.n_frames > 1 + self.seek(0) + + def seek(self, frame: int) -> None: + if not self._seek_check(frame): + return + if isinstance(self._fp, DeferredError): + raise self._fp.ex + self.frame = frame + self.fp = self._fp + self.fp.seek(self._offset[frame]) + PcxImageFile._open(self) + + def tell(self) -> int: + return self.frame + + +Image.register_open(DcxImageFile.format, DcxImageFile, _accept) + +Image.register_extension(DcxImageFile.format, ".dcx") diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/DdsImagePlugin.py b/presentation/.venv/lib/python3.12/site-packages/PIL/DdsImagePlugin.py new file mode 100644 index 0000000..312f602 --- /dev/null +++ b/presentation/.venv/lib/python3.12/site-packages/PIL/DdsImagePlugin.py @@ -0,0 +1,625 @@ +""" +A Pillow plugin for .dds files (S3TC-compressed aka DXTC) +Jerome Leclanche <jerome@leclan.ch> + +Documentation: +https://web.archive.org/web/20170802060935/http://oss.sgi.com/projects/ogl-sample/registry/EXT/texture_compression_s3tc.txt + +The contents of this file are hereby released in the public domain (CC0) +Full text of the CC0 license: +https://creativecommons.org/publicdomain/zero/1.0/ +""" + +from __future__ import annotations + +import struct +import sys +from enum import IntEnum, IntFlag +from typing import IO + +from . import Image, ImageFile, ImagePalette +from ._binary import i32le as i32 +from ._binary import o8 +from ._binary import o32le as o32 + +# Magic ("DDS ") +DDS_MAGIC = 0x20534444 + + +# DDS flags +class DDSD(IntFlag): + CAPS = 0x1 + HEIGHT = 0x2 + WIDTH = 0x4 + PITCH = 0x8 + PIXELFORMAT = 0x1000 + MIPMAPCOUNT = 0x20000 + LINEARSIZE = 0x80000 + DEPTH = 0x800000 + + +# DDS caps +class DDSCAPS(IntFlag): + COMPLEX = 0x8 + TEXTURE = 0x1000 + MIPMAP = 0x400000 + + +class DDSCAPS2(IntFlag): + CUBEMAP = 0x200 + CUBEMAP_POSITIVEX = 0x400 + CUBEMAP_NEGATIVEX = 0x800 + CUBEMAP_POSITIVEY = 0x1000 + CUBEMAP_NEGATIVEY = 0x2000 + CUBEMAP_POSITIVEZ = 0x4000 + CUBEMAP_NEGATIVEZ = 0x8000 + VOLUME = 0x200000 + + +# Pixel Format +class DDPF(IntFlag): + ALPHAPIXELS = 0x1 + ALPHA = 0x2 + FOURCC = 0x4 + PALETTEINDEXED8 = 0x20 + RGB = 0x40 + LUMINANCE = 0x20000 + + +# dxgiformat.h +class DXGI_FORMAT(IntEnum): + UNKNOWN = 0 + R32G32B32A32_TYPELESS = 1 + R32G32B32A32_FLOAT = 2 + R32G32B32A32_UINT = 3 + R32G32B32A32_SINT = 4 + R32G32B32_TYPELESS = 5 + R32G32B32_FLOAT = 6 + R32G32B32_UINT = 7 + R32G32B32_SINT = 8 + R16G16B16A16_TYPELESS = 9 + R16G16B16A16_FLOAT = 10 + R16G16B16A16_UNORM = 11 + R16G16B16A16_UINT = 12 + R16G16B16A16_SNORM = 13 + R16G16B16A16_SINT = 14 + R32G32_TYPELESS = 15 + R32G32_FLOAT = 16 + R32G32_UINT = 17 + R32G32_SINT = 18 + R32G8X24_TYPELESS = 19 + D32_FLOAT_S8X24_UINT = 20 + R32_FLOAT_X8X24_TYPELESS = 21 + X32_TYPELESS_G8X24_UINT = 22 + R10G10B10A2_TYPELESS = 23 + R10G10B10A2_UNORM = 24 + R10G10B10A2_UINT = 25 + R11G11B10_FLOAT = 26 + R8G8B8A8_TYPELESS = 27 + R8G8B8A8_UNORM = 28 + R8G8B8A8_UNORM_SRGB = 29 + R8G8B8A8_UINT = 30 + R8G8B8A8_SNORM = 31 + R8G8B8A8_SINT = 32 + R16G16_TYPELESS = 33 + R16G16_FLOAT = 34 + R16G16_UNORM = 35 + R16G16_UINT = 36 + R16G16_SNORM = 37 + R16G16_SINT = 38 + R32_TYPELESS = 39 + D32_FLOAT = 40 + R32_FLOAT = 41 + R32_UINT = 42 + R32_SINT = 43 + R24G8_TYPELESS = 44 + D24_UNORM_S8_UINT = 45 + R24_UNORM_X8_TYPELESS = 46 + X24_TYPELESS_G8_UINT = 47 + R8G8_TYPELESS = 48 + R8G8_UNORM = 49 + R8G8_UINT = 50 + R8G8_SNORM = 51 + R8G8_SINT = 52 + R16_TYPELESS = 53 + R16_FLOAT = 54 + D16_UNORM = 55 + R16_UNORM = 56 + R16_UINT = 57 + R16_SNORM = 58 + R16_SINT = 59 + R8_TYPELESS = 60 + R8_UNORM = 61 + R8_UINT = 62 + R8_SNORM = 63 + R8_SINT = 64 + A8_UNORM = 65 + R1_UNORM = 66 + R9G9B9E5_SHAREDEXP = 67 + R8G8_B8G8_UNORM = 68 + G8R8_G8B8_UNORM = 69 + BC1_TYPELESS = 70 + BC1_UNORM = 71 + BC1_UNORM_SRGB = 72 + BC2_TYPELESS = 73 + BC2_UNORM = 74 + BC2_UNORM_SRGB = 75 + BC3_TYPELESS = 76 + BC3_UNORM = 77 + BC3_UNORM_SRGB = 78 + BC4_TYPELESS = 79 + BC4_UNORM = 80 + BC4_SNORM = 81 + BC5_TYPELESS = 82 + BC5_UNORM = 83 + BC5_SNORM = 84 + B5G6R5_UNORM = 85 + B5G5R5A1_UNORM = 86 + B8G8R8A8_UNORM = 87 + B8G8R8X8_UNORM = 88 + R10G10B10_XR_BIAS_A2_UNORM = 89 + B8G8R8A8_TYPELESS = 90 + B8G8R8A8_UNORM_SRGB = 91 + B8G8R8X8_TYPELESS = 92 + B8G8R8X8_UNORM_SRGB = 93 + BC6H_TYPELESS = 94 + BC6H_UF16 = 95 + BC6H_SF16 = 96 + BC7_TYPELESS = 97 + BC7_UNORM = 98 + BC7_UNORM_SRGB = 99 + AYUV = 100 + Y410 = 101 + Y416 = 102 + NV12 = 103 + P010 = 104 + P016 = 105 + OPAQUE_420 = 106 + YUY2 = 107 + Y210 = 108 + Y216 = 109 + NV11 = 110 + AI44 = 111 + IA44 = 112 + P8 = 113 + A8P8 = 114 + B4G4R4A4_UNORM = 115 + P208 = 130 + V208 = 131 + V408 = 132 + SAMPLER_FEEDBACK_MIN_MIP_OPAQUE = 189 + SAMPLER_FEEDBACK_MIP_REGION_USED_OPAQUE = 190 + + +class D3DFMT(IntEnum): + UNKNOWN = 0 + R8G8B8 = 20 + A8R8G8B8 = 21 + X8R8G8B8 = 22 + R5G6B5 = 23 + X1R5G5B5 = 24 + A1R5G5B5 = 25 + A4R4G4B4 = 26 + R3G3B2 = 27 + A8 = 28 + A8R3G3B2 = 29 + X4R4G4B4 = 30 + A2B10G10R10 = 31 + A8B8G8R8 = 32 + X8B8G8R8 = 33 + G16R16 = 34 + A2R10G10B10 = 35 + A16B16G16R16 = 36 + A8P8 = 40 + P8 = 41 + L8 = 50 + A8L8 = 51 + A4L4 = 52 + V8U8 = 60 + L6V5U5 = 61 + X8L8V8U8 = 62 + Q8W8V8U8 = 63 + V16U16 = 64 + A2W10V10U10 = 67 + D16_LOCKABLE = 70 + D32 = 71 + D15S1 = 73 + D24S8 = 75 + D24X8 = 77 + D24X4S4 = 79 + D16 = 80 + D32F_LOCKABLE = 82 + D24FS8 = 83 + D32_LOCKABLE = 84 + S8_LOCKABLE = 85 + L16 = 81 + VERTEXDATA = 100 + INDEX16 = 101 + INDEX32 = 102 + Q16W16V16U16 = 110 + R16F = 111 + G16R16F = 112 + A16B16G16R16F = 113 + R32F = 114 + G32R32F = 115 + A32B32G32R32F = 116 + CxV8U8 = 117 + A1 = 118 + A2B10G10R10_XR_BIAS = 119 + BINARYBUFFER = 199 + + UYVY = i32(b"UYVY") + R8G8_B8G8 = i32(b"RGBG") + YUY2 = i32(b"YUY2") + G8R8_G8B8 = i32(b"GRGB") + DXT1 = i32(b"DXT1") + DXT2 = i32(b"DXT2") + DXT3 = i32(b"DXT3") + DXT4 = i32(b"DXT4") + DXT5 = i32(b"DXT5") + DX10 = i32(b"DX10") + BC4S = i32(b"BC4S") + BC4U = i32(b"BC4U") + BC5S = i32(b"BC5S") + BC5U = i32(b"BC5U") + ATI1 = i32(b"ATI1") + ATI2 = i32(b"ATI2") + MULTI2_ARGB8 = i32(b"MET1") + + +# Backward compatibility layer +module = sys.modules[__name__] +for item in DDSD: + assert item.name is not None + setattr(module, f"DDSD_{item.name}", item.value) +for item1 in DDSCAPS: + assert item1.name is not None + setattr(module, f"DDSCAPS_{item1.name}", item1.value) +for item2 in DDSCAPS2: + assert item2.name is not None + setattr(module, f"DDSCAPS2_{item2.name}", item2.value) +for item3 in DDPF: + assert item3.name is not None + setattr(module, f"DDPF_{item3.name}", item3.value) + +DDS_FOURCC = DDPF.FOURCC +DDS_RGB = DDPF.RGB +DDS_RGBA = DDPF.RGB | DDPF.ALPHAPIXELS +DDS_LUMINANCE = DDPF.LUMINANCE +DDS_LUMINANCEA = DDPF.LUMINANCE | DDPF.ALPHAPIXELS +DDS_ALPHA = DDPF.ALPHA +DDS_PAL8 = DDPF.PALETTEINDEXED8 + +DDS_HEADER_FLAGS_TEXTURE = DDSD.CAPS | DDSD.HEIGHT | DDSD.WIDTH | DDSD.PIXELFORMAT +DDS_HEADER_FLAGS_MIPMAP = DDSD.MIPMAPCOUNT +DDS_HEADER_FLAGS_VOLUME = DDSD.DEPTH +DDS_HEADER_FLAGS_PITCH = DDSD.PITCH +DDS_HEADER_FLAGS_LINEARSIZE = DDSD.LINEARSIZE + +DDS_HEIGHT = DDSD.HEIGHT +DDS_WIDTH = DDSD.WIDTH + +DDS_SURFACE_FLAGS_TEXTURE = DDSCAPS.TEXTURE +DDS_SURFACE_FLAGS_MIPMAP = DDSCAPS.COMPLEX | DDSCAPS.MIPMAP +DDS_SURFACE_FLAGS_CUBEMAP = DDSCAPS.COMPLEX + +DDS_CUBEMAP_POSITIVEX = DDSCAPS2.CUBEMAP | DDSCAPS2.CUBEMAP_POSITIVEX +DDS_CUBEMAP_NEGATIVEX = DDSCAPS2.CUBEMAP | DDSCAPS2.CUBEMAP_NEGATIVEX +DDS_CUBEMAP_POSITIVEY = DDSCAPS2.CUBEMAP | DDSCAPS2.CUBEMAP_POSITIVEY +DDS_CUBEMAP_NEGATIVEY = DDSCAPS2.CUBEMAP | DDSCAPS2.CUBEMAP_NEGATIVEY +DDS_CUBEMAP_POSITIVEZ = DDSCAPS2.CUBEMAP | DDSCAPS2.CUBEMAP_POSITIVEZ +DDS_CUBEMAP_NEGATIVEZ = DDSCAPS2.CUBEMAP | DDSCAPS2.CUBEMAP_NEGATIVEZ + +DXT1_FOURCC = D3DFMT.DXT1 +DXT3_FOURCC = D3DFMT.DXT3 +DXT5_FOURCC = D3DFMT.DXT5 + +DXGI_FORMAT_R8G8B8A8_TYPELESS = DXGI_FORMAT.R8G8B8A8_TYPELESS +DXGI_FORMAT_R8G8B8A8_UNORM = DXGI_FORMAT.R8G8B8A8_UNORM +DXGI_FORMAT_R8G8B8A8_UNORM_SRGB = DXGI_FORMAT.R8G8B8A8_UNORM_SRGB +DXGI_FORMAT_BC5_TYPELESS = DXGI_FORMAT.BC5_TYPELESS +DXGI_FORMAT_BC5_UNORM = DXGI_FORMAT.BC5_UNORM +DXGI_FORMAT_BC5_SNORM = DXGI_FORMAT.BC5_SNORM +DXGI_FORMAT_BC6H_UF16 = DXGI_FORMAT.BC6H_UF16 +DXGI_FORMAT_BC6H_SF16 = DXGI_FORMAT.BC6H_SF16 +DXGI_FORMAT_BC7_TYPELESS = DXGI_FORMAT.BC7_TYPELESS +DXGI_FORMAT_BC7_UNORM = DXGI_FORMAT.BC7_UNORM +DXGI_FORMAT_BC7_UNORM_SRGB = DXGI_FORMAT.BC7_UNORM_SRGB + + +class DdsImageFile(ImageFile.ImageFile): + format = "DDS" + format_description = "DirectDraw Surface" + + def _open(self) -> None: + assert self.fp is not None + if not _accept(self.fp.read(4)): + msg = "not a DDS file" + raise SyntaxError(msg) + (header_size,) = struct.unpack("<I", self.fp.read(4)) + if header_size != 124: + msg = f"Unsupported header size {repr(header_size)}" + raise OSError(msg) + header = self.fp.read(header_size - 4) + if len(header) != 120: + msg = f"Incomplete header: {len(header)} bytes" + raise OSError(msg) + + flags, height, width = struct.unpack("<3I", header[:12]) + self._size = (width, height) + extents = (0, 0) + self.size + + pitch, depth, mipmaps = struct.unpack("<3I", header[12:24]) + struct.unpack("<11I", header[24:68]) # reserved + + # pixel format + pfsize, pfflags, fourcc, bitcount = struct.unpack("<4I", header[68:84]) + n = 0 + rawmode = None + if pfflags & DDPF.RGB: + # Texture contains uncompressed RGB data + if pfflags & DDPF.ALPHAPIXELS: + self._mode = "RGBA" + mask_count = 4 + else: + self._mode = "RGB" + mask_count = 3 + + masks = struct.unpack(f"<{mask_count}I", header[84 : 84 + mask_count * 4]) + self.tile = [ImageFile._Tile("dds_rgb", extents, 0, (bitcount, masks))] + return + elif pfflags & DDPF.LUMINANCE: + if bitcount == 8: + self._mode = "L" + elif bitcount == 16 and pfflags & DDPF.ALPHAPIXELS: + self._mode = "LA" + else: + msg = f"Unsupported bitcount {bitcount} for {pfflags}" + raise OSError(msg) + elif pfflags & DDPF.PALETTEINDEXED8: + self._mode = "P" + self.palette = ImagePalette.raw("RGBA", self.fp.read(1024)) + self.palette.mode = "RGBA" + elif pfflags & DDPF.FOURCC: + offset = header_size + 4 + if fourcc == D3DFMT.DXT1: + self._mode = "RGBA" + self.pixel_format = "DXT1" + n = 1 + elif fourcc == D3DFMT.DXT3: + self._mode = "RGBA" + self.pixel_format = "DXT3" + n = 2 + elif fourcc == D3DFMT.DXT5: + self._mode = "RGBA" + self.pixel_format = "DXT5" + n = 3 + elif fourcc in (D3DFMT.BC4U, D3DFMT.ATI1): + self._mode = "L" + self.pixel_format = "BC4" + n = 4 + elif fourcc == D3DFMT.BC5S: + self._mode = "RGB" + self.pixel_format = "BC5S" + n = 5 + elif fourcc in (D3DFMT.BC5U, D3DFMT.ATI2): + self._mode = "RGB" + self.pixel_format = "BC5" + n = 5 + elif fourcc == D3DFMT.DX10: + offset += 20 + # ignoring flags which pertain to volume textures and cubemaps + (dxgi_format,) = struct.unpack("<I", self.fp.read(4)) + self.fp.read(16) + if dxgi_format in ( + DXGI_FORMAT.BC1_UNORM, + DXGI_FORMAT.BC1_TYPELESS, + ): + self._mode = "RGBA" + self.pixel_format = "BC1" + n = 1 + elif dxgi_format in (DXGI_FORMAT.BC2_TYPELESS, DXGI_FORMAT.BC2_UNORM): + self._mode = "RGBA" + self.pixel_format = "BC2" + n = 2 + elif dxgi_format in (DXGI_FORMAT.BC3_TYPELESS, DXGI_FORMAT.BC3_UNORM): + self._mode = "RGBA" + self.pixel_format = "BC3" + n = 3 + elif dxgi_format in (DXGI_FORMAT.BC4_TYPELESS, DXGI_FORMAT.BC4_UNORM): + self._mode = "L" + self.pixel_format = "BC4" + n = 4 + elif dxgi_format in (DXGI_FORMAT.BC5_TYPELESS, DXGI_FORMAT.BC5_UNORM): + self._mode = "RGB" + self.pixel_format = "BC5" + n = 5 + elif dxgi_format == DXGI_FORMAT.BC5_SNORM: + self._mode = "RGB" + self.pixel_format = "BC5S" + n = 5 + elif dxgi_format == DXGI_FORMAT.BC6H_UF16: + self._mode = "RGB" + self.pixel_format = "BC6H" + n = 6 + elif dxgi_format == DXGI_FORMAT.BC6H_SF16: + self._mode = "RGB" + self.pixel_format = "BC6HS" + n = 6 + elif dxgi_format in ( + DXGI_FORMAT.BC7_TYPELESS, + DXGI_FORMAT.BC7_UNORM, + DXGI_FORMAT.BC7_UNORM_SRGB, + ): + self._mode = "RGBA" + self.pixel_format = "BC7" + n = 7 + if dxgi_format == DXGI_FORMAT.BC7_UNORM_SRGB: + self.info["gamma"] = 1 / 2.2 + elif dxgi_format in ( + DXGI_FORMAT.R8G8B8A8_TYPELESS, + DXGI_FORMAT.R8G8B8A8_UNORM, + DXGI_FORMAT.R8G8B8A8_UNORM_SRGB, + ): + self._mode = "RGBA" + if dxgi_format == DXGI_FORMAT.R8G8B8A8_UNORM_SRGB: + self.info["gamma"] = 1 / 2.2 + else: + msg = f"Unimplemented DXGI format {dxgi_format}" + raise NotImplementedError(msg) + else: + msg = f"Unimplemented pixel format {repr(fourcc)}" + raise NotImplementedError(msg) + else: + msg = f"Unknown pixel format flags {pfflags}" + raise NotImplementedError(msg) + + if n: + self.tile = [ + ImageFile._Tile("bcn", extents, offset, (n, self.pixel_format)) + ] + else: + self.tile = [ImageFile._Tile("raw", extents, 0, rawmode or self.mode)] + + def load_seek(self, pos: int) -> None: + pass + + +class DdsRgbDecoder(ImageFile.PyDecoder): + _pulls_fd = True + + def decode(self, buffer: bytes | Image.SupportsArrayInterface) -> tuple[int, int]: + assert self.fd is not None + bitcount, masks = self.args + + # Some masks will be padded with zeros, e.g. R 0b11 G 0b1100 + # Calculate how many zeros each mask is padded with + mask_offsets = [] + # And the maximum value of each channel without the padding + mask_totals = [] + for mask in masks: + offset = 0 + if mask != 0: + while mask >> (offset + 1) << (offset + 1) == mask: + offset += 1 + mask_offsets.append(offset) + mask_totals.append(mask >> offset) + + data = bytearray() + bytecount = bitcount // 8 + dest_length = self.state.xsize * self.state.ysize * len(masks) + while len(data) < dest_length: + value = int.from_bytes(self.fd.read(bytecount), "little") + for i, mask in enumerate(masks): + masked_value = value & mask + # Remove the zero padding, and scale it to 8 bits + data += o8( + int(((masked_value >> mask_offsets[i]) / mask_totals[i]) * 255) + if mask_totals[i] + else 0 + ) + self.set_as_raw(data) + return -1, 0 + + +def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: + if im.mode not in ("RGB", "RGBA", "L", "LA"): + msg = f"cannot write mode {im.mode} as DDS" + raise OSError(msg) + + flags = DDSD.CAPS | DDSD.HEIGHT | DDSD.WIDTH | DDSD.PIXELFORMAT + bitcount = len(im.getbands()) * 8 + pixel_format = im.encoderinfo.get("pixel_format") + args: tuple[int] | str + if pixel_format: + codec_name = "bcn" + flags |= DDSD.LINEARSIZE + pitch = (im.width + 3) * 4 + rgba_mask = [0, 0, 0, 0] + pixel_flags = DDPF.FOURCC + if pixel_format == "DXT1": + fourcc = D3DFMT.DXT1 + args = (1,) + elif pixel_format == "DXT3": + fourcc = D3DFMT.DXT3 + args = (2,) + elif pixel_format == "DXT5": + fourcc = D3DFMT.DXT5 + args = (3,) + else: + fourcc = D3DFMT.DX10 + if pixel_format == "BC2": + args = (2,) + dxgi_format = DXGI_FORMAT.BC2_TYPELESS + elif pixel_format == "BC3": + args = (3,) + dxgi_format = DXGI_FORMAT.BC3_TYPELESS + elif pixel_format == "BC5": + args = (5,) + dxgi_format = DXGI_FORMAT.BC5_TYPELESS + if im.mode != "RGB": + msg = "only RGB mode can be written as BC5" + raise OSError(msg) + else: + msg = f"cannot write pixel format {pixel_format}" + raise OSError(msg) + else: + codec_name = "raw" + flags |= DDSD.PITCH + pitch = (im.width * bitcount + 7) // 8 + + alpha = im.mode[-1] == "A" + if im.mode[0] == "L": + pixel_flags = DDPF.LUMINANCE + args = im.mode + if alpha: + rgba_mask = [0x000000FF, 0x000000FF, 0x000000FF] + else: + rgba_mask = [0xFF000000, 0xFF000000, 0xFF000000] + else: + pixel_flags = DDPF.RGB + args = im.mode[::-1] + rgba_mask = [0x00FF0000, 0x0000FF00, 0x000000FF] + + if alpha: + r, g, b, a = im.split() + im = Image.merge("RGBA", (a, r, g, b)) + if alpha: + pixel_flags |= DDPF.ALPHAPIXELS + rgba_mask.append(0xFF000000 if alpha else 0) + + fourcc = D3DFMT.UNKNOWN + fp.write( + o32(DDS_MAGIC) + + struct.pack( + "<7I", + 124, # header size + flags, # flags + im.height, + im.width, + pitch, + 0, # depth + 0, # mipmaps + ) + + struct.pack("11I", *((0,) * 11)) # reserved + # pfsize, pfflags, fourcc, bitcount + + struct.pack("<4I", 32, pixel_flags, fourcc, bitcount) + + struct.pack("<4I", *rgba_mask) # dwRGBABitMask + + struct.pack("<5I", DDSCAPS.TEXTURE, 0, 0, 0, 0) + ) + if fourcc == D3DFMT.DX10: + fp.write( + # dxgi_format, 2D resource, misc, array size, straight alpha + struct.pack("<5I", dxgi_format, 3, 0, 0, 1) + ) + ImageFile._save(im, fp, [ImageFile._Tile(codec_name, (0, 0) + im.size, 0, args)]) + + +def _accept(prefix: bytes) -> bool: + return prefix.startswith(b"DDS ") + + +Image.register_open(DdsImageFile.format, DdsImageFile, _accept) +Image.register_decoder("dds_rgb", DdsRgbDecoder) +Image.register_save(DdsImageFile.format, _save) +Image.register_extension(DdsImageFile.format, ".dds") diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/EpsImagePlugin.py b/presentation/.venv/lib/python3.12/site-packages/PIL/EpsImagePlugin.py new file mode 100644 index 0000000..aeb7b0c --- /dev/null +++ b/presentation/.venv/lib/python3.12/site-packages/PIL/EpsImagePlugin.py @@ -0,0 +1,481 @@ +# +# The Python Imaging Library. +# $Id$ +# +# EPS file handling +# +# History: +# 1995-09-01 fl Created (0.1) +# 1996-05-18 fl Don't choke on "atend" fields, Ghostscript interface (0.2) +# 1996-08-22 fl Don't choke on floating point BoundingBox values +# 1996-08-23 fl Handle files from Macintosh (0.3) +# 2001-02-17 fl Use 're' instead of 'regex' (Python 2.1) (0.4) +# 2003-09-07 fl Check gs.close status (from Federico Di Gregorio) (0.5) +# 2014-05-07 e Handling of EPS with binary preview and fixed resolution +# resizing +# +# Copyright (c) 1997-2003 by Secret Labs AB. +# Copyright (c) 1995-2003 by Fredrik Lundh +# +# See the README file for information on usage and redistribution. +# +from __future__ import annotations + +import io +import os +import re +import subprocess +import sys +import tempfile +from typing import IO + +from . import Image, ImageFile +from ._binary import i32le as i32 + +# -------------------------------------------------------------------- + + +split = re.compile(r"^%%([^:]*):[ \t]*(.*)[ \t]*$") +field = re.compile(r"^%[%!\w]([^:]*)[ \t]*$") + +gs_binary: str | bool | None = None +gs_windows_binary = None + + +def has_ghostscript() -> bool: + global gs_binary, gs_windows_binary + if gs_binary is None: + if sys.platform.startswith("win"): + if gs_windows_binary is None: + import shutil + + for binary in ("gswin32c", "gswin64c", "gs"): + if shutil.which(binary) is not None: + gs_windows_binary = binary + break + else: + gs_windows_binary = False + gs_binary = gs_windows_binary + else: + try: + subprocess.check_call(["gs", "--version"], stdout=subprocess.DEVNULL) + gs_binary = "gs" + except OSError: + gs_binary = False + return gs_binary is not False + + +def Ghostscript( + tile: list[ImageFile._Tile], + size: tuple[int, int], + fp: IO[bytes], + scale: int = 1, + transparency: bool = False, +) -> Image.core.ImagingCore: + """Render an image using Ghostscript""" + global gs_binary + if not has_ghostscript(): + msg = "Unable to locate Ghostscript on paths" + raise OSError(msg) + assert isinstance(gs_binary, str) + + # Unpack decoder tile + args = tile[0].args + assert isinstance(args, tuple) + length, bbox = args + + # Hack to support hi-res rendering + scale = int(scale) or 1 + width = size[0] * scale + height = size[1] * scale + # resolution is dependent on bbox and size + res_x = 72.0 * width / (bbox[2] - bbox[0]) + res_y = 72.0 * height / (bbox[3] - bbox[1]) + + out_fd, outfile = tempfile.mkstemp() + os.close(out_fd) + + infile_temp = None + if hasattr(fp, "name") and os.path.exists(fp.name): + infile = fp.name + else: + in_fd, infile_temp = tempfile.mkstemp() + os.close(in_fd) + infile = infile_temp + + # Ignore length and offset! + # Ghostscript can read it + # Copy whole file to read in Ghostscript + with open(infile_temp, "wb") as f: + # fetch length of fp + fp.seek(0, io.SEEK_END) + fsize = fp.tell() + # ensure start position + # go back + fp.seek(0) + lengthfile = fsize + while lengthfile > 0: + s = fp.read(min(lengthfile, 100 * 1024)) + if not s: + break + lengthfile -= len(s) + f.write(s) + + if transparency: + # "RGBA" + device = "pngalpha" + else: + # "pnmraw" automatically chooses between + # PBM ("1"), PGM ("L"), and PPM ("RGB"). + device = "pnmraw" + + # Build Ghostscript command + command = [ + gs_binary, + "-q", # quiet mode + f"-g{width:d}x{height:d}", # set output geometry (pixels) + f"-r{res_x:f}x{res_y:f}", # set input DPI (dots per inch) + "-dBATCH", # exit after processing + "-dNOPAUSE", # don't pause between pages + "-dSAFER", # safe mode + f"-sDEVICE={device}", + f"-sOutputFile={outfile}", # output file + # adjust for image origin + "-c", + f"{-bbox[0]} {-bbox[1]} translate", + "-f", + infile, # input file + # showpage (see https://bugs.ghostscript.com/show_bug.cgi?id=698272) + "-c", + "showpage", + ] + + # push data through Ghostscript + try: + startupinfo = None + if sys.platform.startswith("win"): + startupinfo = subprocess.STARTUPINFO() + startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW + subprocess.check_call(command, startupinfo=startupinfo) + with Image.open(outfile) as out_im: + out_im.load() + return out_im.im.copy() + finally: + try: + os.unlink(outfile) + if infile_temp: + os.unlink(infile_temp) + except OSError: + pass + + +def _accept(prefix: bytes) -> bool: + return prefix.startswith(b"%!PS") or ( + len(prefix) >= 4 and i32(prefix) == 0xC6D3D0C5 + ) + + +## +# Image plugin for Encapsulated PostScript. This plugin supports only +# a few variants of this format. + + +class EpsImageFile(ImageFile.ImageFile): + """EPS File Parser for the Python Imaging Library""" + + format = "EPS" + format_description = "Encapsulated Postscript" + + mode_map = {1: "L", 2: "LAB", 3: "RGB", 4: "CMYK"} + + def _open(self) -> None: + assert self.fp is not None + length, offset = self._find_offset(self.fp) + + # go to offset - start of "%!PS" + self.fp.seek(offset) + + self._mode = "RGB" + + # When reading header comments, the first comment is used. + # When reading trailer comments, the last comment is used. + bounding_box: list[int] | None = None + imagedata_size: tuple[int, int] | None = None + + byte_arr = bytearray(255) + bytes_mv = memoryview(byte_arr) + bytes_read = 0 + reading_header_comments = True + reading_trailer_comments = False + trailer_reached = False + + def check_required_header_comments() -> None: + """ + The EPS specification requires that some headers exist. + This should be checked when the header comments formally end, + when image data starts, or when the file ends, whichever comes first. + """ + if "PS-Adobe" not in self.info: + msg = 'EPS header missing "%!PS-Adobe" comment' + raise SyntaxError(msg) + if "BoundingBox" not in self.info: + msg = 'EPS header missing "%%BoundingBox" comment' + raise SyntaxError(msg) + + def read_comment(s: str) -> bool: + nonlocal bounding_box, reading_trailer_comments + try: + m = split.match(s) + except re.error as e: + msg = "not an EPS file" + raise SyntaxError(msg) from e + + if not m: + return False + + k, v = m.group(1, 2) + self.info[k] = v + if k == "BoundingBox": + if v == "(atend)": + reading_trailer_comments = True + elif not bounding_box or (trailer_reached and reading_trailer_comments): + try: + # Note: The DSC spec says that BoundingBox + # fields should be integers, but some drivers + # put floating point values there anyway. + bounding_box = [int(float(i)) for i in v.split()] + except Exception: + pass + return True + + while True: + byte = self.fp.read(1) + if byte == b"": + # if we didn't read a byte we must be at the end of the file + if bytes_read == 0: + if reading_header_comments: + check_required_header_comments() + break + elif byte in b"\r\n": + # if we read a line ending character, ignore it and parse what + # we have already read. if we haven't read any other characters, + # continue reading + if bytes_read == 0: + continue + else: + # ASCII/hexadecimal lines in an EPS file must not exceed + # 255 characters, not including line ending characters + if bytes_read >= 255: + # only enforce this for lines starting with a "%", + # otherwise assume it's binary data + if byte_arr[0] == ord("%"): + msg = "not an EPS file" + raise SyntaxError(msg) + else: + if reading_header_comments: + check_required_header_comments() + reading_header_comments = False + # reset bytes_read so we can keep reading + # data until the end of the line + bytes_read = 0 + byte_arr[bytes_read] = byte[0] + bytes_read += 1 + continue + + if reading_header_comments: + # Load EPS header + + # if this line doesn't start with a "%", + # or does start with "%%EndComments", + # then we've reached the end of the header/comments + if byte_arr[0] != ord("%") or bytes_mv[:13] == b"%%EndComments": + check_required_header_comments() + reading_header_comments = False + continue + + s = str(bytes_mv[:bytes_read], "latin-1") + if not read_comment(s): + m = field.match(s) + if m: + k = m.group(1) + if k.startswith("PS-Adobe"): + self.info["PS-Adobe"] = k[9:] + else: + self.info[k] = "" + elif s[0] == "%": + # handle non-DSC PostScript comments that some + # tools mistakenly put in the Comments section + pass + else: + msg = "bad EPS header" + raise OSError(msg) + elif bytes_mv[:11] == b"%ImageData:": + # Check for an "ImageData" descriptor + # https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#50577413_pgfId-1035096 + + # If we've already read an "ImageData" descriptor, + # don't read another one. + if imagedata_size: + bytes_read = 0 + continue + + # Values: + # columns + # rows + # bit depth (1 or 8) + # mode (1: L, 2: LAB, 3: RGB, 4: CMYK) + # number of padding channels + # block size (number of bytes per row per channel) + # binary/ascii (1: binary, 2: ascii) + # data start identifier (the image data follows after a single line + # consisting only of this quoted value) + image_data_values = byte_arr[11:bytes_read].split(None, 7) + columns, rows, bit_depth, mode_id = ( + int(value) for value in image_data_values[:4] + ) + + if bit_depth == 1: + self._mode = "1" + elif bit_depth == 8: + try: + self._mode = self.mode_map[mode_id] + except ValueError: + break + else: + break + + # Parse the columns and rows after checking the bit depth and mode + # in case the bit depth and/or mode are invalid. + imagedata_size = columns, rows + elif bytes_mv[:5] == b"%%EOF": + break + elif trailer_reached and reading_trailer_comments: + # Load EPS trailer + s = str(bytes_mv[:bytes_read], "latin-1") + read_comment(s) + elif bytes_mv[:9] == b"%%Trailer": + trailer_reached = True + elif bytes_mv[:14] == b"%%BeginBinary:": + bytecount = int(byte_arr[14:bytes_read]) + self.fp.seek(bytecount, os.SEEK_CUR) + bytes_read = 0 + + # A "BoundingBox" is always required, + # even if an "ImageData" descriptor size exists. + if not bounding_box: + msg = "cannot determine EPS bounding box" + raise OSError(msg) + + # An "ImageData" size takes precedence over the "BoundingBox". + self._size = imagedata_size or ( + bounding_box[2] - bounding_box[0], + bounding_box[3] - bounding_box[1], + ) + + self.tile = [ + ImageFile._Tile("eps", (0, 0) + self.size, offset, (length, bounding_box)) + ] + + def _find_offset(self, fp: IO[bytes]) -> tuple[int, int]: + s = fp.read(4) + + if s == b"%!PS": + # for HEAD without binary preview + fp.seek(0, io.SEEK_END) + length = fp.tell() + offset = 0 + elif i32(s) == 0xC6D3D0C5: + # FIX for: Some EPS file not handled correctly / issue #302 + # EPS can contain binary data + # or start directly with latin coding + # more info see: + # https://web.archive.org/web/20160528181353/http://partners.adobe.com/public/developer/en/ps/5002.EPSF_Spec.pdf + s = fp.read(8) + offset = i32(s) + length = i32(s, 4) + else: + msg = "not an EPS file" + raise SyntaxError(msg) + + return length, offset + + def load( + self, scale: int = 1, transparency: bool = False + ) -> Image.core.PixelAccess | None: + # Load EPS via Ghostscript + if self.tile: + assert self.fp is not None + self.im = Ghostscript(self.tile, self.size, self.fp, scale, transparency) + self._mode = self.im.mode + self._size = self.im.size + self.tile = [] + return Image.Image.load(self) + + def load_seek(self, pos: int) -> None: + # we can't incrementally load, so force ImageFile.parser to + # use our custom load method by defining this method. + pass + + +# -------------------------------------------------------------------- + + +def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes, eps: int = 1) -> None: + """EPS Writer for the Python Imaging Library.""" + + # make sure image data is available + im.load() + + # determine PostScript image mode + if im.mode == "L": + operator = (8, 1, b"image") + elif im.mode == "RGB": + operator = (8, 3, b"false 3 colorimage") + elif im.mode == "CMYK": + operator = (8, 4, b"false 4 colorimage") + else: + msg = "image mode is not supported" + raise ValueError(msg) + + if eps: + # write EPS header + fp.write(b"%!PS-Adobe-3.0 EPSF-3.0\n") + fp.write(b"%%Creator: PIL 0.1 EpsEncode\n") + # fp.write("%%CreationDate: %s"...) + fp.write(b"%%%%BoundingBox: 0 0 %d %d\n" % im.size) + fp.write(b"%%Pages: 1\n") + fp.write(b"%%EndComments\n") + fp.write(b"%%Page: 1 1\n") + fp.write(b"%%ImageData: %d %d " % im.size) + fp.write(b'%d %d 0 1 1 "%s"\n' % operator) + + # image header + fp.write(b"gsave\n") + fp.write(b"10 dict begin\n") + fp.write(b"/buf %d string def\n" % (im.size[0] * operator[1])) + fp.write(b"%d %d scale\n" % im.size) + fp.write(b"%d %d 8\n" % im.size) # <= bits + fp.write(b"[%d 0 0 -%d 0 %d]\n" % (im.size[0], im.size[1], im.size[1])) + fp.write(b"{ currentfile buf readhexstring pop } bind\n") + fp.write(operator[2] + b"\n") + if hasattr(fp, "flush"): + fp.flush() + + ImageFile._save(im, fp, [ImageFile._Tile("eps", (0, 0) + im.size)]) + + fp.write(b"\n%%%%EndBinary\n") + fp.write(b"grestore end\n") + if hasattr(fp, "flush"): + fp.flush() + + +# -------------------------------------------------------------------- + + +Image.register_open(EpsImageFile.format, EpsImageFile, _accept) + +Image.register_save(EpsImageFile.format, _save) + +Image.register_extensions(EpsImageFile.format, [".ps", ".eps"]) + +Image.register_mime(EpsImageFile.format, "application/postscript") diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/ExifTags.py b/presentation/.venv/lib/python3.12/site-packages/PIL/ExifTags.py new file mode 100644 index 0000000..a9522e7 --- /dev/null +++ b/presentation/.venv/lib/python3.12/site-packages/PIL/ExifTags.py @@ -0,0 +1,384 @@ +# +# The Python Imaging Library. +# $Id$ +# +# EXIF tags +# +# Copyright (c) 2003 by Secret Labs AB +# +# See the README file for information on usage and redistribution. +# + +""" +This module provides constants and clear-text names for various +well-known EXIF tags. +""" + +from __future__ import annotations + +from enum import IntEnum + + +class Base(IntEnum): + # possibly incomplete + InteropIndex = 0x0001 + ProcessingSoftware = 0x000B + NewSubfileType = 0x00FE + SubfileType = 0x00FF + ImageWidth = 0x0100 + ImageLength = 0x0101 + BitsPerSample = 0x0102 + Compression = 0x0103 + PhotometricInterpretation = 0x0106 + Thresholding = 0x0107 + CellWidth = 0x0108 + CellLength = 0x0109 + FillOrder = 0x010A + DocumentName = 0x010D + ImageDescription = 0x010E + Make = 0x010F + Model = 0x0110 + StripOffsets = 0x0111 + Orientation = 0x0112 + SamplesPerPixel = 0x0115 + RowsPerStrip = 0x0116 + StripByteCounts = 0x0117 + MinSampleValue = 0x0118 + MaxSampleValue = 0x0119 + XResolution = 0x011A + YResolution = 0x011B + PlanarConfiguration = 0x011C + PageName = 0x011D + FreeOffsets = 0x0120 + FreeByteCounts = 0x0121 + GrayResponseUnit = 0x0122 + GrayResponseCurve = 0x0123 + T4Options = 0x0124 + T6Options = 0x0125 + ResolutionUnit = 0x0128 + PageNumber = 0x0129 + TransferFunction = 0x012D + Software = 0x0131 + DateTime = 0x0132 + Artist = 0x013B + HostComputer = 0x013C + Predictor = 0x013D + WhitePoint = 0x013E + PrimaryChromaticities = 0x013F + ColorMap = 0x0140 + HalftoneHints = 0x0141 + TileWidth = 0x0142 + TileLength = 0x0143 + TileOffsets = 0x0144 + TileByteCounts = 0x0145 + SubIFDs = 0x014A + InkSet = 0x014C + InkNames = 0x014D + NumberOfInks = 0x014E + DotRange = 0x0150 + TargetPrinter = 0x0151 + ExtraSamples = 0x0152 + SampleFormat = 0x0153 + SMinSampleValue = 0x0154 + SMaxSampleValue = 0x0155 + TransferRange = 0x0156 + ClipPath = 0x0157 + XClipPathUnits = 0x0158 + YClipPathUnits = 0x0159 + Indexed = 0x015A + JPEGTables = 0x015B + OPIProxy = 0x015F + JPEGProc = 0x0200 + JpegIFOffset = 0x0201 + JpegIFByteCount = 0x0202 + JpegRestartInterval = 0x0203 + JpegLosslessPredictors = 0x0205 + JpegPointTransforms = 0x0206 + JpegQTables = 0x0207 + JpegDCTables = 0x0208 + JpegACTables = 0x0209 + YCbCrCoefficients = 0x0211 + YCbCrSubSampling = 0x0212 + YCbCrPositioning = 0x0213 + ReferenceBlackWhite = 0x0214 + XMLPacket = 0x02BC + RelatedImageFileFormat = 0x1000 + RelatedImageWidth = 0x1001 + RelatedImageLength = 0x1002 + Rating = 0x4746 + RatingPercent = 0x4749 + ImageID = 0x800D + CFARepeatPatternDim = 0x828D + BatteryLevel = 0x828F + Copyright = 0x8298 + ExposureTime = 0x829A + FNumber = 0x829D + IPTCNAA = 0x83BB + ImageResources = 0x8649 + ExifOffset = 0x8769 + InterColorProfile = 0x8773 + ExposureProgram = 0x8822 + SpectralSensitivity = 0x8824 + GPSInfo = 0x8825 + ISOSpeedRatings = 0x8827 + OECF = 0x8828 + Interlace = 0x8829 + TimeZoneOffset = 0x882A + SelfTimerMode = 0x882B + SensitivityType = 0x8830 + StandardOutputSensitivity = 0x8831 + RecommendedExposureIndex = 0x8832 + ISOSpeed = 0x8833 + ISOSpeedLatitudeyyy = 0x8834 + ISOSpeedLatitudezzz = 0x8835 + ExifVersion = 0x9000 + DateTimeOriginal = 0x9003 + DateTimeDigitized = 0x9004 + OffsetTime = 0x9010 + OffsetTimeOriginal = 0x9011 + OffsetTimeDigitized = 0x9012 + ComponentsConfiguration = 0x9101 + CompressedBitsPerPixel = 0x9102 + ShutterSpeedValue = 0x9201 + ApertureValue = 0x9202 + BrightnessValue = 0x9203 + ExposureBiasValue = 0x9204 + MaxApertureValue = 0x9205 + SubjectDistance = 0x9206 + MeteringMode = 0x9207 + LightSource = 0x9208 + Flash = 0x9209 + FocalLength = 0x920A + Noise = 0x920D + ImageNumber = 0x9211 + SecurityClassification = 0x9212 + ImageHistory = 0x9213 + TIFFEPStandardID = 0x9216 + MakerNote = 0x927C + UserComment = 0x9286 + SubsecTime = 0x9290 + SubsecTimeOriginal = 0x9291 + SubsecTimeDigitized = 0x9292 + AmbientTemperature = 0x9400 + Humidity = 0x9401 + Pressure = 0x9402 + WaterDepth = 0x9403 + Acceleration = 0x9404 + CameraElevationAngle = 0x9405 + XPTitle = 0x9C9B + XPComment = 0x9C9C + XPAuthor = 0x9C9D + XPKeywords = 0x9C9E + XPSubject = 0x9C9F + FlashPixVersion = 0xA000 + ColorSpace = 0xA001 + ExifImageWidth = 0xA002 + ExifImageHeight = 0xA003 + RelatedSoundFile = 0xA004 + ExifInteroperabilityOffset = 0xA005 + FlashEnergy = 0xA20B + SpatialFrequencyResponse = 0xA20C + FocalPlaneXResolution = 0xA20E + FocalPlaneYResolution = 0xA20F + FocalPlaneResolutionUnit = 0xA210 + SubjectLocation = 0xA214 + ExposureIndex = 0xA215 + SensingMethod = 0xA217 + FileSource = 0xA300 + SceneType = 0xA301 + CFAPattern = 0xA302 + CustomRendered = 0xA401 + ExposureMode = 0xA402 + WhiteBalance = 0xA403 + DigitalZoomRatio = 0xA404 + FocalLengthIn35mmFilm = 0xA405 + SceneCaptureType = 0xA406 + GainControl = 0xA407 + Contrast = 0xA408 + Saturation = 0xA409 + Sharpness = 0xA40A + DeviceSettingDescription = 0xA40B + SubjectDistanceRange = 0xA40C + ImageUniqueID = 0xA420 + CameraOwnerName = 0xA430 + BodySerialNumber = 0xA431 + LensSpecification = 0xA432 + LensMake = 0xA433 + LensModel = 0xA434 + LensSerialNumber = 0xA435 + CompositeImage = 0xA460 + CompositeImageCount = 0xA461 + CompositeImageExposureTimes = 0xA462 + Gamma = 0xA500 + PrintImageMatching = 0xC4A5 + DNGVersion = 0xC612 + DNGBackwardVersion = 0xC613 + UniqueCameraModel = 0xC614 + LocalizedCameraModel = 0xC615 + CFAPlaneColor = 0xC616 + CFALayout = 0xC617 + LinearizationTable = 0xC618 + BlackLevelRepeatDim = 0xC619 + BlackLevel = 0xC61A + BlackLevelDeltaH = 0xC61B + BlackLevelDeltaV = 0xC61C + WhiteLevel = 0xC61D + DefaultScale = 0xC61E + DefaultCropOrigin = 0xC61F + DefaultCropSize = 0xC620 + ColorMatrix1 = 0xC621 + ColorMatrix2 = 0xC622 + CameraCalibration1 = 0xC623 + CameraCalibration2 = 0xC624 + ReductionMatrix1 = 0xC625 + ReductionMatrix2 = 0xC626 + AnalogBalance = 0xC627 + AsShotNeutral = 0xC628 + AsShotWhiteXY = 0xC629 + BaselineExposure = 0xC62A + BaselineNoise = 0xC62B + BaselineSharpness = 0xC62C + BayerGreenSplit = 0xC62D + LinearResponseLimit = 0xC62E + CameraSerialNumber = 0xC62F + LensInfo = 0xC630 + ChromaBlurRadius = 0xC631 + AntiAliasStrength = 0xC632 + ShadowScale = 0xC633 + DNGPrivateData = 0xC634 + MakerNoteSafety = 0xC635 + CalibrationIlluminant1 = 0xC65A + CalibrationIlluminant2 = 0xC65B + BestQualityScale = 0xC65C + RawDataUniqueID = 0xC65D + OriginalRawFileName = 0xC68B + OriginalRawFileData = 0xC68C + ActiveArea = 0xC68D + MaskedAreas = 0xC68E + AsShotICCProfile = 0xC68F + AsShotPreProfileMatrix = 0xC690 + CurrentICCProfile = 0xC691 + CurrentPreProfileMatrix = 0xC692 + ColorimetricReference = 0xC6BF + CameraCalibrationSignature = 0xC6F3 + ProfileCalibrationSignature = 0xC6F4 + AsShotProfileName = 0xC6F6 + NoiseReductionApplied = 0xC6F7 + ProfileName = 0xC6F8 + ProfileHueSatMapDims = 0xC6F9 + ProfileHueSatMapData1 = 0xC6FA + ProfileHueSatMapData2 = 0xC6FB + ProfileToneCurve = 0xC6FC + ProfileEmbedPolicy = 0xC6FD + ProfileCopyright = 0xC6FE + ForwardMatrix1 = 0xC714 + ForwardMatrix2 = 0xC715 + PreviewApplicationName = 0xC716 + PreviewApplicationVersion = 0xC717 + PreviewSettingsName = 0xC718 + PreviewSettingsDigest = 0xC719 + PreviewColorSpace = 0xC71A + PreviewDateTime = 0xC71B + RawImageDigest = 0xC71C + OriginalRawFileDigest = 0xC71D + SubTileBlockSize = 0xC71E + RowInterleaveFactor = 0xC71F + ProfileLookTableDims = 0xC725 + ProfileLookTableData = 0xC726 + OpcodeList1 = 0xC740 + OpcodeList2 = 0xC741 + OpcodeList3 = 0xC74E + NoiseProfile = 0xC761 + FrameRate = 0xC764 + + +"""Maps EXIF tags to tag names.""" +TAGS = { + **{i.value: i.name for i in Base}, + 0x920C: "SpatialFrequencyResponse", + 0x9214: "SubjectLocation", + 0x9215: "ExposureIndex", + 0x828E: "CFAPattern", + 0x920B: "FlashEnergy", + 0x9216: "TIFF/EPStandardID", +} + + +class GPS(IntEnum): + GPSVersionID = 0x00 + GPSLatitudeRef = 0x01 + GPSLatitude = 0x02 + GPSLongitudeRef = 0x03 + GPSLongitude = 0x04 + GPSAltitudeRef = 0x05 + GPSAltitude = 0x06 + GPSTimeStamp = 0x07 + GPSSatellites = 0x08 + GPSStatus = 0x09 + GPSMeasureMode = 0x0A + GPSDOP = 0x0B + GPSSpeedRef = 0x0C + GPSSpeed = 0x0D + GPSTrackRef = 0x0E + GPSTrack = 0x0F + GPSImgDirectionRef = 0x10 + GPSImgDirection = 0x11 + GPSMapDatum = 0x12 + GPSDestLatitudeRef = 0x13 + GPSDestLatitude = 0x14 + GPSDestLongitudeRef = 0x15 + GPSDestLongitude = 0x16 + GPSDestBearingRef = 0x17 + GPSDestBearing = 0x18 + GPSDestDistanceRef = 0x19 + GPSDestDistance = 0x1A + GPSProcessingMethod = 0x1B + GPSAreaInformation = 0x1C + GPSDateStamp = 0x1D + GPSDifferential = 0x1E + GPSHPositioningError = 0x1F + + +"""Maps EXIF GPS tags to tag names.""" +GPSTAGS = {i.value: i.name for i in GPS} + + +class Interop(IntEnum): + InteropIndex = 0x0001 + InteropVersion = 0x0002 + RelatedImageFileFormat = 0x1000 + RelatedImageWidth = 0x1001 + RelatedImageHeight = 0x1002 + + +class IFD(IntEnum): + Exif = 0x8769 + GPSInfo = 0x8825 + MakerNote = 0x927C + Makernote = 0x927C # Deprecated + Interop = 0xA005 + IFD1 = -1 + + +class LightSource(IntEnum): + Unknown = 0x00 + Daylight = 0x01 + Fluorescent = 0x02 + Tungsten = 0x03 + Flash = 0x04 + Fine = 0x09 + Cloudy = 0x0A + Shade = 0x0B + DaylightFluorescent = 0x0C + DayWhiteFluorescent = 0x0D + CoolWhiteFluorescent = 0x0E + WhiteFluorescent = 0x0F + StandardLightA = 0x11 + StandardLightB = 0x12 + StandardLightC = 0x13 + D55 = 0x14 + D65 = 0x15 + D75 = 0x16 + D50 = 0x17 + ISO = 0x18 + Other = 0xFF diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/FitsImagePlugin.py b/presentation/.venv/lib/python3.12/site-packages/PIL/FitsImagePlugin.py new file mode 100644 index 0000000..e918407 --- /dev/null +++ b/presentation/.venv/lib/python3.12/site-packages/PIL/FitsImagePlugin.py @@ -0,0 +1,153 @@ +# +# The Python Imaging Library +# $Id$ +# +# FITS file handling +# +# Copyright (c) 1998-2003 by Fredrik Lundh +# +# See the README file for information on usage and redistribution. +# +from __future__ import annotations + +import gzip +import math + +from . import Image, ImageFile + + +def _accept(prefix: bytes) -> bool: + return prefix.startswith(b"SIMPLE") + + +class FitsImageFile(ImageFile.ImageFile): + format = "FITS" + format_description = "FITS" + + def _open(self) -> None: + assert self.fp is not None + + headers: dict[bytes, bytes] = {} + header_in_progress = False + decoder_name = "" + while True: + header = self.fp.read(80) + if not header: + msg = "Truncated FITS file" + raise OSError(msg) + keyword = header[:8].strip() + if keyword in (b"SIMPLE", b"XTENSION"): + header_in_progress = True + elif headers and not header_in_progress: + # This is now a data unit + break + elif keyword == b"END": + # Seek to the end of the header unit + self.fp.seek(math.ceil(self.fp.tell() / 2880) * 2880) + if not decoder_name: + decoder_name, offset, args = self._parse_headers(headers) + + header_in_progress = False + continue + + if decoder_name: + # Keep going to read past the headers + continue + + value = header[8:].split(b"/")[0].strip() + if value.startswith(b"="): + value = value[1:].strip() + if not headers and (not _accept(keyword) or value != b"T"): + msg = "Not a FITS file" + raise SyntaxError(msg) + headers[keyword] = value + + if not decoder_name: + msg = "No image data" + raise ValueError(msg) + + offset += self.fp.tell() - 80 + self.tile = [ImageFile._Tile(decoder_name, (0, 0) + self.size, offset, args)] + + def _get_size( + self, headers: dict[bytes, bytes], prefix: bytes + ) -> tuple[int, int] | None: + naxis = int(headers[prefix + b"NAXIS"]) + if naxis == 0: + return None + + if naxis == 1: + return 1, int(headers[prefix + b"NAXIS1"]) + else: + return int(headers[prefix + b"NAXIS1"]), int(headers[prefix + b"NAXIS2"]) + + def _parse_headers( + self, headers: dict[bytes, bytes] + ) -> tuple[str, int, tuple[str | int, ...]]: + prefix = b"" + decoder_name = "raw" + offset = 0 + if ( + headers.get(b"XTENSION") == b"'BINTABLE'" + and headers.get(b"ZIMAGE") == b"T" + and headers[b"ZCMPTYPE"] == b"'GZIP_1 '" + ): + no_prefix_size = self._get_size(headers, prefix) or (0, 0) + number_of_bits = int(headers[b"BITPIX"]) + offset = no_prefix_size[0] * no_prefix_size[1] * (number_of_bits // 8) + + prefix = b"Z" + decoder_name = "fits_gzip" + + size = self._get_size(headers, prefix) + if not size: + return "", 0, () + + self._size = size + + number_of_bits = int(headers[prefix + b"BITPIX"]) + if number_of_bits == 8: + self._mode = "L" + elif number_of_bits == 16: + self._mode = "I;16" + elif number_of_bits == 32: + self._mode = "I" + elif number_of_bits in (-32, -64): + self._mode = "F" + + args: tuple[str | int, ...] + if decoder_name == "raw": + args = (self.mode, 0, -1) + else: + args = (number_of_bits,) + return decoder_name, offset, args + + +class FitsGzipDecoder(ImageFile.PyDecoder): + _pulls_fd = True + + def decode(self, buffer: bytes | Image.SupportsArrayInterface) -> tuple[int, int]: + assert self.fd is not None + with gzip.open(self.fd) as fp: + value = fp.read(self.state.xsize * self.state.ysize * 4) + + rows = [] + offset = 0 + number_of_bits = min(self.args[0] // 8, 4) + for y in range(self.state.ysize): + row = bytearray() + for x in range(self.state.xsize): + row += value[offset + (4 - number_of_bits) : offset + 4] + offset += 4 + rows.append(row) + self.set_as_raw(bytes([pixel for row in rows[::-1] for pixel in row])) + return -1, 0 + + +# -------------------------------------------------------------------- +# Registry + +Image.register_open(FitsImageFile.format, FitsImageFile, _accept) +Image.register_decoder("fits_gzip", FitsGzipDecoder) + +Image.register_extensions(FitsImageFile.format, [".fit", ".fits"]) diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/FliImagePlugin.py b/presentation/.venv/lib/python3.12/site-packages/PIL/FliImagePlugin.py new file mode 100644 index 0000000..da1e8e9 --- /dev/null +++ b/presentation/.venv/lib/python3.12/site-packages/PIL/FliImagePlugin.py @@ -0,0 +1,184 @@ +# +# The Python Imaging Library. +# $Id$ +# +# FLI/FLC file handling. +# +# History: +# 95-09-01 fl Created +# 97-01-03 fl Fixed parser, setup decoder tile +# 98-07-15 fl Renamed offset attribute to avoid name clash +# +# Copyright (c) Secret Labs AB 1997-98. +# Copyright (c) Fredrik Lundh 1995-97. +# +# See the README file for information on usage and redistribution. +# +from __future__ import annotations + +import os + +from . import Image, ImageFile, ImagePalette +from ._binary import i16le as i16 +from ._binary import i32le as i32 +from ._binary import o8 +from ._util import DeferredError + +# +# decoder + + +def _accept(prefix: bytes) -> bool: + return ( + len(prefix) >= 16 + and i16(prefix, 4) in [0xAF11, 0xAF12] + and i16(prefix, 14) in [0, 3] # flags + ) + + +## +# Image plugin for the FLI/FLC animation format. Use the <b>seek</b> +# method to load individual frames. + + +class FliImageFile(ImageFile.ImageFile): + format = "FLI" + format_description = "Autodesk FLI/FLC Animation" + _close_exclusive_fp_after_loading = False + + def _open(self) -> None: + # HEAD + assert self.fp is not None + s = self.fp.read(128) + if not ( + _accept(s) + and s[20:22] == b"\x00" * 2 + and s[42:80] == b"\x00" * 38 + and s[88:] == b"\x00" * 40 + ): + msg = "not an FLI/FLC file" + raise SyntaxError(msg) + + # frames + self.n_frames = i16(s, 6) + self.is_animated = self.n_frames > 1 + + # image characteristics + self._mode = "P" + self._size = i16(s, 8), i16(s, 10) + + # animation speed + duration = i32(s, 16) + magic = i16(s, 4) + if magic == 0xAF11: + duration = (duration * 1000) // 70 + self.info["duration"] = duration + + # look for palette + palette = [(a, a, a) for a in range(256)] + + s = self.fp.read(16) + + self.__offset = 128 + + if i16(s, 4) == 0xF100: + # prefix chunk; ignore it + self.fp.seek(self.__offset + i32(s)) + s = self.fp.read(16) + + if i16(s, 4) == 0xF1FA: + # look for palette chunk + number_of_subchunks = i16(s, 6) + chunk_size: int | None = None + for _ in range(number_of_subchunks): + if chunk_size is not None: + self.fp.seek(chunk_size - 6, os.SEEK_CUR) + s = self.fp.read(6) + chunk_type = i16(s, 4) + if chunk_type in (4, 11): + self._palette(palette, 2 if chunk_type == 11 else 0) + break + chunk_size = i32(s) + if not chunk_size: + break + + self.palette = ImagePalette.raw( + "RGB", b"".join(o8(r) + o8(g) + o8(b) for (r, g, b) in palette) + ) + + # set things up to decode first frame + self.__frame = -1 + self._fp = self.fp + self.__rewind = self.fp.tell() + self.seek(0) + + def _palette(self, palette: list[tuple[int, int, int]], shift: int) -> None: + # load palette + + i = 0 + assert self.fp is not None + for e in range(i16(self.fp.read(2))): + s = self.fp.read(2) + i = i + s[0] + n = s[1] + if n == 0: + n = 256 + s = self.fp.read(n * 3) + for n in range(0, len(s), 3): + r = s[n] << shift + g = s[n + 1] << shift + b = s[n + 2] << shift + palette[i] = (r, g, b) + i += 1 + + def seek(self, frame: int) -> None: + if not self._seek_check(frame): + return + if frame < self.__frame: + self._seek(0) + + for f in range(self.__frame + 1, frame + 1): + self._seek(f) + + def _seek(self, frame: int) -> None: + if isinstance(self._fp, DeferredError): + raise self._fp.ex + if frame == 0: + self.__frame = -1 + self._fp.seek(self.__rewind) + self.__offset = 128 + else: + # ensure that the previous frame was loaded + self.load() + + if frame != self.__frame + 1: + msg = f"cannot seek to frame {frame}" + raise ValueError(msg) + self.__frame = frame + + # move to next frame + self.fp = self._fp + self.fp.seek(self.__offset) + + s = self.fp.read(4) + if not s: + msg = "missing frame size" + raise EOFError(msg) + + framesize = i32(s) + + self.decodermaxblock = framesize + self.tile = [ImageFile._Tile("fli", (0, 0) + self.size, self.__offset)] + + self.__offset += framesize + + def tell(self) -> int: + return self.__frame + + +# +# registry + +Image.register_open(FliImageFile.format, FliImageFile, _accept) + +Image.register_extensions(FliImageFile.format, [".fli", ".flc"]) diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/FontFile.py b/presentation/.venv/lib/python3.12/site-packages/PIL/FontFile.py new file mode 100644 index 0000000..341431d --- /dev/null +++ b/presentation/.venv/lib/python3.12/site-packages/PIL/FontFile.py @@ -0,0 +1,159 @@ +# +# The Python Imaging Library +# $Id$ +# +# base class for raster font file parsers +# +# history: +# 1997-06-05 fl created +# 1997-08-19 fl restrict image width +# +# Copyright (c) 1997-1998 by Secret Labs AB +# Copyright (c) 1997-1998 by Fredrik Lundh +# +# See the README file for information on usage and redistribution. +# +from __future__ import annotations + +import os +from typing import BinaryIO + +from . import Image, ImageFont, _binary + +WIDTH = 800 + + +def puti16( + fp: BinaryIO, values: tuple[int, int, int, int, int, int, int, int, int, int] +) -> None: + """Write network order (big-endian) 16-bit sequence""" + for v in values: + if v < 0: + v += 65536 + fp.write(_binary.o16be(v)) + + +class FontFile: + """Base class for raster font file handlers.""" + + bitmap: Image.Image | None = None + + def __init__(self) -> None: + self.info: dict[bytes, bytes | int] = {} + self.glyph: list[ + tuple[ + tuple[int, int], + tuple[int, int, int, int], + tuple[int, int, int, int], + Image.Image, + ] + | None + ] = [None] * 256 + + def __getitem__(self, ix: int) -> ( + tuple[ + tuple[int, int], + tuple[int, int, int, int], + tuple[int, int, int, int], + Image.Image, + ] + | None + ): + return self.glyph[ix] + + def compile(self) -> None: + """Create metrics and bitmap""" + + if self.bitmap: + return + + # create bitmap large enough to hold all data + h = w = maxwidth = 0 + lines = 1 + for glyph in self.glyph: + if glyph: + d, dst, src, im = glyph + h = max(h, src[3] - src[1]) + w = w + (src[2] - src[0]) + if w > WIDTH: + lines += 1 + w = src[2] - src[0] + maxwidth = max(maxwidth, w) + + xsize = maxwidth + ysize = lines * h + + if xsize == 0 and ysize == 0: + return + + self.ysize = h + + # paste glyphs into bitmap + self.bitmap = Image.new("1", (xsize, ysize)) + self.metrics: list[ + tuple[tuple[int, int], tuple[int, int, int, int], tuple[int, int, int, int]] + | None + ] = [None] * 256 + x = y = 0 + for i in range(256): + glyph = self[i] + if glyph: + d, dst, src, im = glyph + xx = src[2] - src[0] + x0, y0 = x, y + x = x + xx + if x > WIDTH: + x, y = 0, y + h + x0, y0 = x, y + x = xx + s = src[0] + x0, src[1] + y0, src[2] + x0, src[3] + y0 + self.bitmap.paste(im.crop(src), s) + self.metrics[i] = d, dst, s + + def _encode_metrics(self) -> bytes: + values: list[int] = [] + for i in range(256): + m = self.metrics[i] + if m: + values.extend(m[0] + m[1] + m[2]) + else: + values.extend((0,) * 10) + + data = bytearray() + for v in values: + if v < 0: + v += 65536 + data += _binary.o16be(v) + return bytes(data) + + def save(self, filename: str) -> None: + """Save font""" + + self.compile() + + # font data + if not self.bitmap: + msg = "No bitmap created" + raise ValueError(msg) + self.bitmap.save(os.path.splitext(filename)[0] + ".pbm", "PNG") + + # font metrics + with open(os.path.splitext(filename)[0] + ".pil", "wb") as fp: + fp.write(b"PILfont\n") + fp.write(f";;;;;;{self.ysize};\n".encode("ascii")) # HACK!!! + fp.write(b"DATA\n") + fp.write(self._encode_metrics()) + + def to_imagefont(self) -> ImageFont.ImageFont: + """Convert to ImageFont""" + + self.compile() + + # font data + if not self.bitmap: + msg = "No bitmap created" + raise ValueError(msg) + + imagefont = ImageFont.ImageFont() + imagefont._load(self.bitmap, self._encode_metrics()) + return imagefont diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/FpxImagePlugin.py b/presentation/.venv/lib/python3.12/site-packages/PIL/FpxImagePlugin.py new file mode 100644 index 0000000..0b06aac --- /dev/null +++ b/presentation/.venv/lib/python3.12/site-packages/PIL/FpxImagePlugin.py @@ -0,0 +1,258 @@ +# +# THIS IS WORK IN PROGRESS +# +# The Python Imaging Library. +# $Id$ +# +# FlashPix support for PIL +# +# History: +# 97-01-25 fl Created (reads uncompressed RGB images only) +# +# Copyright (c) Secret Labs AB 1997. +# Copyright (c) Fredrik Lundh 1997. +# +# See the README file for information on usage and redistribution. +# +from __future__ import annotations + +import olefile + +from . import Image, ImageFile +from ._binary import i32le as i32 + +# we map from colour field tuples to (mode, rawmode) descriptors +MODES = { + # opacity + (0x00007FFE,): ("A", "L"), + # monochrome + (0x00010000,): ("L", "L"), + (0x00018000, 0x00017FFE): ("RGBA", "LA"), + # photo YCC + (0x00020000, 0x00020001, 0x00020002): ("RGB", "YCC;P"), + (0x00028000, 0x00028001, 0x00028002, 0x00027FFE): ("RGBA", "YCCA;P"), + # standard RGB (NIFRGB) + (0x00030000, 0x00030001, 0x00030002): ("RGB", "RGB"), + (0x00038000, 0x00038001, 0x00038002, 0x00037FFE): ("RGBA", "RGBA"), +} + + +# +# -------------------------------------------------------------------- + + +def _accept(prefix: bytes) -> bool: + return prefix.startswith(olefile.MAGIC) + + +## +# Image plugin for the FlashPix images. + + +class FpxImageFile(ImageFile.ImageFile): + format = "FPX" + format_description = "FlashPix" + + def _open(self) -> None: + # + # read the OLE directory and see if this is a likely + # to be a FlashPix file + + assert self.fp is not None + try: + self.ole = olefile.OleFileIO(self.fp) + except OSError as e: + msg = "not an FPX file; invalid OLE file" + raise SyntaxError(msg) from e + + root = self.ole.root + if not root or root.clsid != "56616700-C154-11CE-8553-00AA00A1F95B": + msg = "not an FPX file; bad root CLSID" + raise SyntaxError(msg) + + self._open_index(1) + + def _open_index(self, index: int = 1) -> None: + # + # get the Image Contents Property Set + + prop = self.ole.getproperties( + [f"Data Object Store {index:06d}", "\005Image Contents"] + ) + + # size (highest resolution) + + assert isinstance(prop[0x1000002], int) + assert isinstance(prop[0x1000003], int) + self._size = prop[0x1000002], prop[0x1000003] + + size = max(self.size) + i = 1 + while size > 64: + size = size // 2 + i += 1 + self.maxid = i - 1 + + # mode. instead of using a single field for this, flashpix + # requires you to specify the mode for each channel in each + # resolution subimage, and leaves it to the decoder to make + # sure that they all match. for now, we'll cheat and assume + # that this is always the case. + + id = self.maxid << 16 + + s = prop[0x2000002 | id] + + if not isinstance(s, bytes) or (bands := i32(s, 4)) > 4: + msg = "Invalid number of bands" + raise OSError(msg) + + # note: for now, we ignore the "uncalibrated" flag + colors = tuple(i32(s, 8 + i * 4) & 0x7FFFFFFF for i in range(bands)) + + self._mode, self.rawmode = MODES[colors] + + # load JPEG tables, if any + self.jpeg = {} + for i in range(256): + id = 0x3000001 | (i << 16) + if id in prop: + self.jpeg[i] = prop[id] + + self._open_subimage(1, self.maxid) + + def _open_subimage(self, index: int = 1, subimage: int = 0) -> None: + # + # setup tile descriptors for a given subimage + + stream = [ + f"Data Object Store {index:06d}", + f"Resolution {subimage:04d}", + "Subimage 0000 Header", + ] + + fp = self.ole.openstream(stream) + + # skip prefix + fp.read(28) + + # header stream + s = fp.read(36) + + size = i32(s, 4), i32(s, 8) + # tilecount = i32(s, 12) + xtile, ytile = i32(s, 16), i32(s, 20) + # channels = i32(s, 24) + offset = i32(s, 28) + length = i32(s, 32) + + if size != self.size: + msg = "subimage mismatch" + raise OSError(msg) + + # get tile descriptors + fp.seek(28 + offset) + s = fp.read(i32(s, 12) * length) + + x = y = 0 + xsize, ysize = size + self.tile = [] + + for i in range(0, len(s), length): + x1 = min(xsize, x + xtile) + y1 = min(ysize, y + ytile) + + compression = i32(s, i + 8) + + if compression == 0: + self.tile.append( + ImageFile._Tile( + "raw", + (x, y, x1, y1), + i32(s, i) + 28, + self.rawmode, + ) + ) + + elif compression == 1: + # FIXME: the fill decoder is not implemented + self.tile.append( + ImageFile._Tile( + "fill", + (x, y, x1, y1), + i32(s, i) + 28, + (self.rawmode, s[12:16]), + ) + ) + + elif compression == 2: + internal_color_conversion = s[14] + jpeg_tables = s[15] + rawmode = self.rawmode + + if internal_color_conversion: + # The image is stored as usual (usually YCbCr). + if rawmode == "RGBA": + # For "RGBA", data is stored as YCbCrA based on + # negative RGB. The following trick works around + # this problem : + jpegmode, rawmode = "YCbCrK", "CMYK" + else: + jpegmode = None # let the decoder decide + + else: + # The image is stored as defined by rawmode + jpegmode = rawmode + + self.tile.append( + ImageFile._Tile( + "jpeg", + (x, y, x1, y1), + i32(s, i) + 28, + (rawmode, jpegmode), + ) + ) + + # FIXME: jpeg tables are tile dependent; the prefix + # data must be placed in the tile descriptor itself! + + if jpeg_tables: + self.tile_prefix = self.jpeg[jpeg_tables] + + else: + msg = "unknown/invalid compression" + raise OSError(msg) + + x += xtile + if x >= xsize: + x, y = 0, y + ytile + if y >= ysize: + break # isn't really required + + assert self.fp is not None + self.stream = stream + self._fp = self.fp + self.fp = None + + def load(self) -> Image.core.PixelAccess | None: + if not self.fp: + self.fp = self.ole.openstream(self.stream[:2] + ["Subimage 0000 Data"]) + + return ImageFile.ImageFile.load(self) + + def close(self) -> None: + self.ole.close() + super().close() + + def __exit__(self, *args: object) -> None: + self.ole.close() + super().__exit__() + + +# +# -------------------------------------------------------------------- + + +Image.register_open(FpxImageFile.format, FpxImageFile, _accept) + +Image.register_extension(FpxImageFile.format, ".fpx") diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/FtexImagePlugin.py b/presentation/.venv/lib/python3.12/site-packages/PIL/FtexImagePlugin.py new file mode 100644 index 0000000..e4d836c --- /dev/null +++ b/presentation/.venv/lib/python3.12/site-packages/PIL/FtexImagePlugin.py @@ -0,0 +1,115 @@ +""" +A Pillow loader for .ftc and .ftu files (FTEX) +Jerome Leclanche <jerome@leclan.ch> + +The contents of this file are hereby released in the public domain (CC0) +Full text of the CC0 license: + https://creativecommons.org/publicdomain/zero/1.0/ + +Independence War 2: Edge Of Chaos - Texture File Format - 16 October 2001 + +The textures used for 3D objects in Independence War 2: Edge Of Chaos are in a +packed custom format called FTEX. This file format uses file extensions FTC +and FTU. +* FTC files are compressed textures (using standard texture compression). +* FTU files are not compressed. +Texture File Format +The FTC and FTU texture files both use the same format. This +has the following structure: +{header} +{format_directory} +{data} +Where: +{header} = { + u32:magic, + u32:version, + u32:width, + u32:height, + u32:mipmap_count, + u32:format_count +} + +* The "magic" number is "FTEX". +* "width" and "height" are the dimensions of the texture. +* "mipmap_count" is the number of mipmaps in the texture. +* "format_count" is the number of texture formats (different versions of the +same texture) in this file. + +{format_directory} = format_count * { u32:format, u32:where } + +The format value is 0 for DXT1 compressed textures and 1 for 24-bit RGB +uncompressed textures. +The texture data for a format starts at the position "where" in the file. + +Each set of texture data in the file has the following structure: +{data} = format_count * { u32:mipmap_size, mipmap_size * { u8 } } +* "mipmap_size" is the number of bytes in that mip level. For compressed +textures this is the size of the texture data compressed with DXT1. For 24 bit +uncompressed textures, this is 3 * width * height. Following this are the image +bytes for that mipmap level. + +Note: All data is stored in little-Endian (Intel) byte order. +""" + +from __future__ import annotations + +import struct +from enum import IntEnum +from io import BytesIO + +from . import Image, ImageFile + +MAGIC = b"FTEX" + + +class Format(IntEnum): + DXT1 = 0 + UNCOMPRESSED = 1 + + +class FtexImageFile(ImageFile.ImageFile): + format = "FTEX" + format_description = "Texture File Format (IW2:EOC)" + + def _open(self) -> None: + assert self.fp is not None + if not _accept(self.fp.read(4)): + msg = "not an FTEX file" + raise SyntaxError(msg) + struct.unpack("<i", self.fp.read(4)) # version + self._size = struct.unpack("<2i", self.fp.read(8)) + mipmap_count, format_count = struct.unpack("<2i", self.fp.read(8)) + + # Only support single-format files. + # I don't know of any multi-format file. + assert format_count == 1 + + format, where = struct.unpack("<2i", self.fp.read(8)) + self.fp.seek(where) + (mipmap_size,) = struct.unpack("<i", self.fp.read(4)) + + data = self.fp.read(mipmap_size) + + if format == Format.DXT1: + self._mode = "RGBA" + self.tile = [ImageFile._Tile("bcn", (0, 0) + self.size, 0, (1,))] + elif format == Format.UNCOMPRESSED: + self._mode = "RGB" + self.tile = [ImageFile._Tile("raw", (0, 0) + self.size, 0, "RGB")] + else: + msg = f"Invalid texture compression format: {repr(format)}" + raise ValueError(msg) + + self.fp.close() + self.fp = BytesIO(data) + + def load_seek(self, pos: int) -> None: + pass + + +def _accept(prefix: bytes) -> bool: + return prefix.startswith(MAGIC) + + +Image.register_open(FtexImageFile.format, FtexImageFile, _accept) +Image.register_extensions(FtexImageFile.format, [".ftc", ".ftu"]) diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/GbrImagePlugin.py b/presentation/.venv/lib/python3.12/site-packages/PIL/GbrImagePlugin.py new file mode 100644 index 0000000..ec666c8 --- /dev/null +++ b/presentation/.venv/lib/python3.12/site-packages/PIL/GbrImagePlugin.py @@ -0,0 +1,103 @@ +# +# The Python Imaging Library +# +# load a GIMP brush file +# +# History: +# 96-03-14 fl Created +# 16-01-08 es Version 2 +# +# Copyright (c) Secret Labs AB 1997. +# Copyright (c) Fredrik Lundh 1996. +# Copyright (c) Eric Soroos 2016. +# +# See the README file for information on usage and redistribution. +# +# +# See https://github.com/GNOME/gimp/blob/mainline/devel-docs/gbr.txt for +# format documentation. +# +# This code Interprets version 1 and 2 .gbr files. +# Version 1 files are obsolete, and should not be used for new +# brushes. +# Version 2 files are saved by GIMP v2.8 (at least) +# Version 3 files have a format specifier of 18 for 16bit floats in +# the color depth field. This is currently unsupported by Pillow. +from __future__ import annotations + +from . import Image, ImageFile +from ._binary import i32be as i32 + + +def _accept(prefix: bytes) -> bool: + return len(prefix) >= 8 and i32(prefix, 0) >= 20 and i32(prefix, 4) in (1, 2) + + +## +# Image plugin for the GIMP brush format. + + +class GbrImageFile(ImageFile.ImageFile): + format = "GBR" + format_description = "GIMP brush file" + + def _open(self) -> None: + assert self.fp is not None + header_size = i32(self.fp.read(4)) + if header_size < 20: + msg = "not a GIMP brush" + raise SyntaxError(msg) + version = i32(self.fp.read(4)) + if version not in (1, 2): + msg = f"Unsupported GIMP brush version: {version}" + raise SyntaxError(msg) + + width = i32(self.fp.read(4)) + height = i32(self.fp.read(4)) + color_depth = i32(self.fp.read(4)) + if width == 0 or height == 0: + msg = "not a GIMP brush" + raise SyntaxError(msg) + if color_depth not in (1, 4): + msg = f"Unsupported GIMP brush color depth: {color_depth}" + raise SyntaxError(msg) + + if version == 1: + comment_length = header_size - 20 + else: + comment_length = header_size - 28 + magic_number = self.fp.read(4) + if magic_number != b"GIMP": + msg = "not a GIMP brush, bad magic number" + raise SyntaxError(msg) + self.info["spacing"] = i32(self.fp.read(4)) + + self.info["comment"] = self.fp.read(comment_length)[:-1] + + if color_depth == 1: + self._mode = "L" + else: + self._mode = "RGBA" + + self._size = width, height + + # Image might not be small + Image._decompression_bomb_check(self.size) + + # Data is an uncompressed block of w * h * bytes/pixel + self._data_size = width * height * color_depth + + def load(self) -> Image.core.PixelAccess | None: + if self._im is None: + assert self.fp is not None + self.im = Image.core.new(self.mode, self.size) + self.frombytes(self.fp.read(self._data_size)) + return Image.Image.load(self) + + +# +# registry + + +Image.register_open(GbrImageFile.format, GbrImageFile, _accept) +Image.register_extension(GbrImageFile.format, ".gbr") diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/GdImageFile.py b/presentation/.venv/lib/python3.12/site-packages/PIL/GdImageFile.py new file mode 100644 index 0000000..d73bc19 --- /dev/null +++ b/presentation/.venv/lib/python3.12/site-packages/PIL/GdImageFile.py @@ -0,0 +1,103 @@ +# +# The Python Imaging Library. +# $Id$ +# +# GD file handling +# +# History: +# 1996-04-12 fl Created +# +# Copyright (c) 1997 by Secret Labs AB. +# Copyright (c) 1996 by Fredrik Lundh. +# +# See the README file for information on usage and redistribution. +# + + +""" +.. note:: + This format cannot be automatically recognized, so the + class is not registered for use with :py:func:`PIL.Image.open()`. To open a + gd file, use the :py:func:`PIL.GdImageFile.open()` function instead. + +.. warning:: + THE GD FORMAT IS NOT DESIGNED FOR DATA INTERCHANGE. This + implementation is provided for convenience and demonstrational + purposes only. +""" + +from __future__ import annotations + +from typing import IO + +from . import ImageFile, ImagePalette, UnidentifiedImageError +from ._binary import i16be as i16 +from ._binary import i32be as i32 +from ._typing import StrOrBytesPath + + +class GdImageFile(ImageFile.ImageFile): + """ + Image plugin for the GD uncompressed format. Note that this format + is not supported by the standard :py:func:`PIL.Image.open()` function. To use + this plugin, you have to import the :py:mod:`PIL.GdImageFile` module and + use the :py:func:`PIL.GdImageFile.open()` function. + """ + + format = "GD" + format_description = "GD uncompressed images" + + def _open(self) -> None: + # Header + assert self.fp is not None + + s = self.fp.read(1037) + + if i16(s) not in [65534, 65535]: + msg = "Not a valid GD 2.x .gd file" + raise SyntaxError(msg) + + self._mode = "P" + self._size = i16(s, 2), i16(s, 4) + + true_color = s[6] + true_color_offset = 2 if true_color else 0 + + # transparency index + tindex = i32(s, 7 + true_color_offset) + if tindex < 256: + self.info["transparency"] = tindex + + self.palette = ImagePalette.raw( + "RGBX", s[7 + true_color_offset + 6 : 7 + true_color_offset + 6 + 256 * 4] + ) + + self.tile = [ + ImageFile._Tile( + "raw", + (0, 0) + self.size, + 7 + true_color_offset + 6 + 256 * 4, + "L", + ) + ] + + +def open(fp: StrOrBytesPath | IO[bytes], mode: str = "r") -> GdImageFile: + """ + Load texture from a GD image file. + + :param fp: GD file name, or an opened file handle. + :param mode: Optional mode. In this version, if the mode argument + is given, it must be "r". + :returns: An image instance. + :raises OSError: If the image could not be read. + """ + if mode != "r": + msg = "bad mode" + raise ValueError(msg) + + try: + return GdImageFile(fp) + except SyntaxError as e: + msg = "cannot identify this image file" + raise UnidentifiedImageError(msg) from e diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/GifImagePlugin.py b/presentation/.venv/lib/python3.12/site-packages/PIL/GifImagePlugin.py new file mode 100644 index 0000000..b8db5d8 --- /dev/null +++ b/presentation/.venv/lib/python3.12/site-packages/PIL/GifImagePlugin.py @@ -0,0 +1,1223 @@ +# +# The Python Imaging Library. +# $Id$ +# +# GIF file handling +# +# History: +# 1995-09-01 fl Created +# 1996-12-14 fl Added interlace support +# 1996-12-30 fl Added animation support +# 1997-01-05 fl Added write support, fixed local colour map bug +# 1997-02-23 fl Make sure to load raster data in getdata() +# 1997-07-05 fl Support external decoder (0.4) +# 1998-07-09 fl Handle all modes when saving (0.5) +# 1998-07-15 fl Renamed offset attribute to avoid name clash +# 2001-04-16 fl Added rewind support (seek to frame 0) (0.6) +# 2001-04-17 fl Added palette optimization (0.7) +# 2002-06-06 fl Added transparency support for save (0.8) +# 2004-02-24 fl Disable interlacing for small images +# +# Copyright (c) 1997-2004 by Secret Labs AB +# Copyright (c) 1995-2004 by Fredrik Lundh +# +# See the README file for information on usage and redistribution. +# +from __future__ import annotations + +import itertools +import math +import os +import subprocess +from enum import IntEnum +from functools import cached_property +from typing import Any, NamedTuple, cast + +from . import ( + Image, + ImageChops, + ImageFile, + ImageMath, + ImageOps, + ImagePalette, + ImageSequence, +) +from ._binary import i16le as i16 +from ._binary import o8 +from ._binary import o16le as o16 +from ._util import DeferredError + +TYPE_CHECKING = False +if TYPE_CHECKING: + from typing import IO, Literal + + from . import _imaging + from ._typing import Buffer + + +class LoadingStrategy(IntEnum): + """.. versionadded:: 9.1.0""" + + RGB_AFTER_FIRST = 0 + RGB_AFTER_DIFFERENT_PALETTE_ONLY = 1 + RGB_ALWAYS = 2 + + +#: .. versionadded:: 9.1.0 +LOADING_STRATEGY = LoadingStrategy.RGB_AFTER_FIRST + +# -------------------------------------------------------------------- +# Identify/read GIF files + + +def _accept(prefix: bytes) -> bool: + return prefix.startswith((b"GIF87a", b"GIF89a")) + + +## +# Image plugin for GIF images. This plugin supports both GIF87 and +# GIF89 images. + + +class GifImageFile(ImageFile.ImageFile): + format = "GIF" + format_description = "Compuserve GIF" + _close_exclusive_fp_after_loading = False + + global_palette = None + + def data(self) -> bytes | None: + assert self.fp is not None + s = self.fp.read(1) + if s and s[0]: + return self.fp.read(s[0]) + return None + + def _is_palette_needed(self, p: bytes) -> bool: + for i in range(0, len(p), 3): + if not (i // 3 == p[i] == p[i + 1] == p[i + 2]): + return True + return False + + def _open(self) -> None: + # Screen + assert self.fp is not None + s = self.fp.read(13) + if not _accept(s): + msg = "not a GIF file" + raise SyntaxError(msg) + + self.info["version"] = s[:6] + self._size = i16(s, 6), i16(s, 8) + flags = s[10] + bits = (flags & 7) + 1 + + if flags & 128: + # get global palette + self.info["background"] = s[11] + # check if palette contains colour indices + p = self.fp.read(3 << bits) + if self._is_palette_needed(p): + palette = ImagePalette.raw("RGB", p) + self.global_palette = self.palette = palette + + self._fp = self.fp # FIXME: hack + self.__rewind = self.fp.tell() + self._n_frames: int | None = None + self._seek(0) # get ready to read first frame + + @property + def n_frames(self) -> int: + if self._n_frames is None: + current = self.tell() + try: + while True: + self._seek(self.tell() + 1, False) + except EOFError: + self._n_frames = self.tell() + 1 + self.seek(current) + return self._n_frames + + @cached_property + def is_animated(self) -> bool: + if self._n_frames is not None: + return self._n_frames != 1 + + current = self.tell() + if current: + return True + + try: + self._seek(1, False) + is_animated = True + except EOFError: + is_animated = False + + self.seek(current) + return is_animated + + def seek(self, frame: int) -> None: + if not self._seek_check(frame): + return + if frame < self.__frame: + self._im = None + self._seek(0) + + last_frame = self.__frame + try: + for f in range(self.__frame + 1, frame + 1): + self._seek(f) + except EOFError as e: + self.seek(last_frame) + msg = "no more images in GIF file" + raise EOFError(msg) from e + + def _seek(self, frame: int, update_image: bool = True) -> None: + if isinstance(self._fp, DeferredError): + raise self._fp.ex + if frame == 0: + # rewind + self.__offset = 0 + self.dispose: _imaging.ImagingCore | None = None + self.__frame = -1 + self._fp.seek(self.__rewind) + self.disposal_method = 0 + if "comment" in self.info: + del self.info["comment"] + else: + # ensure that the previous frame was loaded + if self.tile and update_image: + self.load() + + if frame != self.__frame + 1: + msg = f"cannot seek to frame {frame}" + raise ValueError(msg) + + self.fp = self._fp + if self.__offset: + # backup to last frame + self.fp.seek(self.__offset) + while self.data(): + pass + self.__offset = 0 + + s = self.fp.read(1) + if not s or s == b";": + msg = "no more images in GIF file" + raise EOFError(msg) + + palette: ImagePalette.ImagePalette | Literal[False] | None = None + + info: dict[str, Any] = {} + frame_transparency = None + interlace = None + frame_dispose_extent = None + while True: + if not s: + s = self.fp.read(1) + if not s or s == b";": + break + + elif s == b"!": + # + # extensions + # + s = self.fp.read(1) + block = self.data() + if s[0] == 249 and block is not None: + # + # graphic control extension + # + flags = block[0] + if flags & 1: + frame_transparency = block[3] + info["duration"] = i16(block, 1) * 10 + + # disposal method - find the value of bits 4 - 6 + dispose_bits = 0b00011100 & flags + dispose_bits = dispose_bits >> 2 + if dispose_bits: + # only set the dispose if it is not + # unspecified. I'm not sure if this is + # correct, but it seems to prevent the last + # frame from looking odd for some animations + self.disposal_method = dispose_bits + elif s[0] == 254: + # + # comment extension + # + comment = b"" + + # Read this comment block + while block: + comment += block + block = self.data() + + if "comment" in info: + # If multiple comment blocks in frame, separate with \n + info["comment"] += b"\n" + comment + else: + info["comment"] = comment + s = b"" + continue + elif s[0] == 255 and frame == 0 and block is not None: + # + # application extension + # + info["extension"] = block, self.fp.tell() + if block.startswith(b"NETSCAPE2.0"): + block = self.data() + if block and len(block) >= 3 and block[0] == 1: + self.info["loop"] = i16(block, 1) + while self.data(): + pass + + elif s == b",": + # + # local image + # + s = self.fp.read(9) + + # extent + x0, y0 = i16(s, 0), i16(s, 2) + x1, y1 = x0 + i16(s, 4), y0 + i16(s, 6) + if (x1 > self.size[0] or y1 > self.size[1]) and update_image: + self._size = max(x1, self.size[0]), max(y1, self.size[1]) + Image._decompression_bomb_check(self._size) + frame_dispose_extent = x0, y0, x1, y1 + flags = s[8] + + interlace = (flags & 64) != 0 + + if flags & 128: + bits = (flags & 7) + 1 + p = self.fp.read(3 << bits) + if self._is_palette_needed(p): + palette = ImagePalette.raw("RGB", p) + else: + palette = False + + # image data + bits = self.fp.read(1)[0] + self.__offset = self.fp.tell() + break + s = b"" + + if interlace is None: + msg = "image not found in GIF frame" + raise EOFError(msg) + + self.__frame = frame + if not update_image: + return + + self.tile = [] + + if self.dispose: + self.im.paste(self.dispose, self.dispose_extent) + + self._frame_palette = palette if palette is not None else self.global_palette + self._frame_transparency = frame_transparency + if frame == 0: + if self._frame_palette: + if LOADING_STRATEGY == LoadingStrategy.RGB_ALWAYS: + self._mode = "RGBA" if frame_transparency is not None else "RGB" + else: + self._mode = "P" + else: + self._mode = "L" + + if palette: + self.palette = palette + elif self.global_palette: + from copy import copy + + self.palette = copy(self.global_palette) + else: + self.palette = None + else: + if self.mode == "P": + if ( + LOADING_STRATEGY != LoadingStrategy.RGB_AFTER_DIFFERENT_PALETTE_ONLY + or palette + ): + if "transparency" in self.info: + self.im.putpalettealpha(self.info["transparency"], 0) + self.im = self.im.convert("RGBA", Image.Dither.FLOYDSTEINBERG) + self._mode = "RGBA" + del self.info["transparency"] + else: + self._mode = "RGB" + self.im = self.im.convert("RGB", Image.Dither.FLOYDSTEINBERG) + + def _rgb(color: int) -> tuple[int, int, int]: + if self._frame_palette: + if color * 3 + 3 > len(self._frame_palette.palette): + color = 0 + return cast( + tuple[int, int, int], + tuple(self._frame_palette.palette[color * 3 : color * 3 + 3]), + ) + else: + return (color, color, color) + + self.dispose = None + self.dispose_extent: tuple[int, int, int, int] | None = frame_dispose_extent + if self.dispose_extent and self.disposal_method >= 2: + try: + if self.disposal_method == 2: + # replace with background colour + + # only dispose the extent in this frame + x0, y0, x1, y1 = self.dispose_extent + dispose_size = (x1 - x0, y1 - y0) + + Image._decompression_bomb_check(dispose_size) + + # by convention, attempt to use transparency first + dispose_mode = "P" + color = self.info.get("transparency", frame_transparency) + if color is not None: + if self.mode in ("RGB", "RGBA"): + dispose_mode = "RGBA" + color = _rgb(color) + (0,) + else: + color = self.info.get("background", 0) + if self.mode in ("RGB", "RGBA"): + dispose_mode = "RGB" + color = _rgb(color) + self.dispose = Image.core.fill(dispose_mode, dispose_size, color) + else: + # replace with previous contents + if self._im is not None: + # only dispose the extent in this frame + self.dispose = self._crop(self.im, self.dispose_extent) + elif frame_transparency is not None: + x0, y0, x1, y1 = self.dispose_extent + dispose_size = (x1 - x0, y1 - y0) + + Image._decompression_bomb_check(dispose_size) + dispose_mode = "P" + color = frame_transparency + if self.mode in ("RGB", "RGBA"): + dispose_mode = "RGBA" + color = _rgb(frame_transparency) + (0,) + self.dispose = Image.core.fill( + dispose_mode, dispose_size, color + ) + except AttributeError: + pass + + if interlace is not None: + transparency = -1 + if frame_transparency is not None: + if frame == 0: + if LOADING_STRATEGY != LoadingStrategy.RGB_ALWAYS: + self.info["transparency"] = frame_transparency + elif self.mode not in ("RGB", "RGBA"): + transparency = frame_transparency + self.tile = [ + ImageFile._Tile( + "gif", + (x0, y0, x1, y1), + self.__offset, + (bits, interlace, transparency), + ) + ] + + if info.get("comment"): + self.info["comment"] = info["comment"] + for k in ["duration", "extension"]: + if k in info: + self.info[k] = info[k] + elif k in self.info: + del self.info[k] + + def load_prepare(self) -> None: + temp_mode = "P" if self._frame_palette else "L" + self._prev_im = None + if self.__frame == 0: + if self._frame_transparency is not None: + self.im = Image.core.fill( + temp_mode, self.size, self._frame_transparency + ) + elif self.mode in ("RGB", "RGBA"): + self._prev_im = self.im + if self._frame_palette: + self.im = Image.core.fill("P", self.size, self._frame_transparency or 0) + self.im.putpalette("RGB", *self._frame_palette.getdata()) + else: + self._im = None + if not self._prev_im and self._im is not None and self.size != self.im.size: + expanded_im = Image.core.fill(self.im.mode, self.size) + if self._frame_palette: + expanded_im.putpalette("RGB", *self._frame_palette.getdata()) + expanded_im.paste(self.im, (0, 0) + self.im.size) + + self.im = expanded_im + self._mode = temp_mode + self._frame_palette = None + + super().load_prepare() + + def load_end(self) -> None: + if self.__frame == 0: + if self.mode == "P" and LOADING_STRATEGY == LoadingStrategy.RGB_ALWAYS: + if self._frame_transparency is not None: + self.im.putpalettealpha(self._frame_transparency, 0) + self._mode = "RGBA" + else: + self._mode = "RGB" + self.im = self.im.convert(self.mode, Image.Dither.FLOYDSTEINBERG) + return + if not self._prev_im: + return + if self.size != self._prev_im.size: + if self._frame_transparency is not None: + expanded_im = Image.core.fill("RGBA", self.size) + else: + expanded_im = Image.core.fill("P", self.size) + expanded_im.putpalette("RGB", "RGB", self.im.getpalette()) + expanded_im = expanded_im.convert("RGB") + expanded_im.paste(self._prev_im, (0, 0) + self._prev_im.size) + + self._prev_im = expanded_im + assert self._prev_im is not None + if self._frame_transparency is not None: + if self.mode == "L": + frame_im = self.im.convert_transparent("LA", self._frame_transparency) + else: + self.im.putpalettealpha(self._frame_transparency, 0) + frame_im = self.im.convert("RGBA") + else: + frame_im = self.im.convert("RGB") + + assert self.dispose_extent is not None + frame_im = self._crop(frame_im, self.dispose_extent) + + self.im = self._prev_im + self._mode = self.im.mode + if frame_im.mode in ("LA", "RGBA"): + self.im.paste(frame_im, self.dispose_extent, frame_im) + else: + self.im.paste(frame_im, self.dispose_extent) + + def tell(self) -> int: + return self.__frame + + +# -------------------------------------------------------------------- +# Write GIF files + + +RAWMODE = {"1": "L", "L": "L", "P": "P"} + + +def _normalize_mode(im: Image.Image) -> Image.Image: + """ + Takes an image (or frame), returns an image in a mode that is appropriate + for saving in a Gif. + + It may return the original image, or it may return an image converted to + palette or 'L' mode. + + :param im: Image object + :returns: Image object + """ + if im.mode in RAWMODE: + im.load() + return im + if Image.getmodebase(im.mode) == "RGB": + im = im.convert("P", palette=Image.Palette.ADAPTIVE) + assert im.palette is not None + if im.palette.mode == "RGBA": + for rgba in im.palette.colors: + if rgba[3] == 0: + im.info["transparency"] = im.palette.colors[rgba] + break + return im + return im.convert("L") + + +_Palette = bytes | bytearray | list[int] | ImagePalette.ImagePalette + + +def _normalize_palette( + im: Image.Image, palette: _Palette | None, info: dict[str, Any] +) -> Image.Image: + """ + Normalizes the palette for image. + - Sets the palette to the incoming palette, if provided. + - Ensures that there's a palette for L mode images + - Optimizes the palette if necessary/desired. + + :param im: Image object + :param palette: bytes object containing the source palette, or .... + :param info: encoderinfo + :returns: Image object + """ + source_palette = None + if palette: + # a bytes palette + if isinstance(palette, (bytes, bytearray, list)): + source_palette = bytearray(palette[:768]) + if isinstance(palette, ImagePalette.ImagePalette): + source_palette = bytearray(palette.palette) + + if im.mode == "P": + if not source_palette: + im_palette = im.getpalette(None) + assert im_palette is not None + source_palette = bytearray(im_palette) + else: # L-mode + if not source_palette: + source_palette = bytearray(i // 3 for i in range(768)) + im.palette = ImagePalette.ImagePalette("RGB", palette=source_palette) + assert source_palette is not None + + if palette: + used_palette_colors: list[int | None] = [] + assert im.palette is not None + for i in range(0, len(source_palette), 3): + source_color = tuple(source_palette[i : i + 3]) + index = im.palette.colors.get(source_color) + if index in used_palette_colors: + index = None + used_palette_colors.append(index) + for i, index in enumerate(used_palette_colors): + if index is None: + for j in range(len(used_palette_colors)): + if j not in used_palette_colors: + used_palette_colors[i] = j + break + dest_map: list[int] = [] + for index in used_palette_colors: + assert index is not None + dest_map.append(index) + im = im.remap_palette(dest_map) + else: + optimized_palette_colors = _get_optimize(im, info) + if optimized_palette_colors is not None: + im = im.remap_palette(optimized_palette_colors, source_palette) + if "transparency" in info: + try: + info["transparency"] = optimized_palette_colors.index( + info["transparency"] + ) + except ValueError: + del info["transparency"] + return im + + assert im.palette is not None + im.palette.palette = source_palette + return im + + +def _write_single_frame( + im: Image.Image, + fp: IO[bytes], + palette: _Palette | None, +) -> None: + im_out = _normalize_mode(im) + for k, v in im_out.info.items(): + if isinstance(k, str): + im.encoderinfo.setdefault(k, v) + im_out = _normalize_palette(im_out, palette, im.encoderinfo) + + for s in _get_global_header(im_out, im.encoderinfo): + fp.write(s) + + # local image header + flags = 0 + if get_interlace(im): + flags = flags | 64 + _write_local_header(fp, im, (0, 0), flags) + + im_out.encoderconfig = (8, get_interlace(im)) + ImageFile._save( + im_out, fp, [ImageFile._Tile("gif", (0, 0) + im.size, 0, RAWMODE[im_out.mode])] + ) + + fp.write(b"\0") # end of image data + + +def _getbbox( + base_im: Image.Image, im_frame: Image.Image +) -> tuple[Image.Image, tuple[int, int, int, int] | None]: + palette_bytes = [ + bytes(im.palette.palette) if im.palette else b"" for im in (base_im, im_frame) + ] + if palette_bytes[0] != palette_bytes[1]: + im_frame = im_frame.convert("RGBA") + base_im = base_im.convert("RGBA") + delta = ImageChops.subtract_modulo(im_frame, base_im) + return delta, delta.getbbox(alpha_only=False) + + +class _Frame(NamedTuple): + im: Image.Image + bbox: tuple[int, int, int, int] | None + encoderinfo: dict[str, Any] + + +def _write_multiple_frames( + im: Image.Image, fp: IO[bytes], palette: _Palette | None +) -> bool: + duration = im.encoderinfo.get("duration") + disposal = im.encoderinfo.get("disposal", im.info.get("disposal")) + + im_frames: list[_Frame] = [] + previous_im: Image.Image | None = None + frame_count = 0 + background_im = None + for imSequence in itertools.chain([im], im.encoderinfo.get("append_images", [])): + for im_frame in ImageSequence.Iterator(imSequence): + # a copy is required here since seek can still mutate the image + im_frame = _normalize_mode(im_frame.copy()) + if frame_count == 0: + for k, v in im_frame.info.items(): + if k == "transparency": + continue + if isinstance(k, str): + im.encoderinfo.setdefault(k, v) + + encoderinfo = im.encoderinfo.copy() + if "transparency" in im_frame.info: + encoderinfo.setdefault("transparency", im_frame.info["transparency"]) + im_frame = _normalize_palette(im_frame, palette, encoderinfo) + if isinstance(duration, (list, tuple)): + encoderinfo["duration"] = duration[frame_count] + elif duration is None and "duration" in im_frame.info: + encoderinfo["duration"] = im_frame.info["duration"] + if isinstance(disposal, (list, tuple)): + encoderinfo["disposal"] = disposal[frame_count] + frame_count += 1 + + diff_frame = None + if im_frames and previous_im: + # delta frame + delta, bbox = _getbbox(previous_im, im_frame) + if not bbox: + # This frame is identical to the previous frame + if encoderinfo.get("duration"): + im_frames[-1].encoderinfo["duration"] += encoderinfo["duration"] + continue + if im_frames[-1].encoderinfo.get("disposal") == 2: + # To appear correctly in viewers using a convention, + # only consider transparency, and not background color + color = im.encoderinfo.get( + "transparency", im.info.get("transparency") + ) + if color is not None: + if background_im is None: + background = _get_background(im_frame, color) + background_im = Image.new("P", im_frame.size, background) + first_palette = im_frames[0].im.palette + assert first_palette is not None + background_im.putpalette(first_palette, first_palette.mode) + bbox = _getbbox(background_im, im_frame)[1] + else: + bbox = (0, 0) + im_frame.size + elif encoderinfo.get("optimize") and im_frame.mode != "1": + if "transparency" not in encoderinfo: + assert im_frame.palette is not None + try: + encoderinfo["transparency"] = ( + im_frame.palette._new_color_index(im_frame) + ) + except ValueError: + pass + if "transparency" in encoderinfo: + # When the delta is zero, fill the image with transparency + diff_frame = im_frame.copy() + fill = Image.new("P", delta.size, encoderinfo["transparency"]) + if delta.mode == "RGBA": + r, g, b, a = delta.split() + mask = ImageMath.lambda_eval( + lambda args: args["convert"]( + args["max"]( + args["max"]( + args["max"](args["r"], args["g"]), args["b"] + ), + args["a"], + ) + * 255, + "1", + ), + r=r, + g=g, + b=b, + a=a, + ) + else: + if delta.mode == "P": + # Convert to L without considering palette + delta_l = Image.new("L", delta.size) + delta_l.putdata(delta.get_flattened_data()) + delta = delta_l + mask = ImageMath.lambda_eval( + lambda args: args["convert"](args["im"] * 255, "1"), + im=delta, + ) + diff_frame.paste(fill, mask=ImageOps.invert(mask)) + else: + bbox = None + previous_im = im_frame + im_frames.append(_Frame(diff_frame or im_frame, bbox, encoderinfo)) + + if len(im_frames) == 1: + if "duration" in im.encoderinfo: + # Since multiple frames will not be written, use the combined duration + im.encoderinfo["duration"] = im_frames[0].encoderinfo["duration"] + return False + + for frame_data in im_frames: + im_frame = frame_data.im + if not frame_data.bbox: + # global header + for s in _get_global_header(im_frame, frame_data.encoderinfo): + fp.write(s) + offset = (0, 0) + else: + # compress difference + if not palette: + frame_data.encoderinfo["include_color_table"] = True + + if frame_data.bbox != (0, 0) + im_frame.size: + im_frame = im_frame.crop(frame_data.bbox) + offset = frame_data.bbox[:2] + _write_frame_data(fp, im_frame, offset, frame_data.encoderinfo) + return True + + +def _save_all(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: + _save(im, fp, filename, save_all=True) + + +def _save( + im: Image.Image, fp: IO[bytes], filename: str | bytes, save_all: bool = False +) -> None: + # header + if "palette" in im.encoderinfo or "palette" in im.info: + palette = im.encoderinfo.get("palette", im.info.get("palette")) + else: + palette = None + im.encoderinfo.setdefault("optimize", True) + + if not save_all or not _write_multiple_frames(im, fp, palette): + _write_single_frame(im, fp, palette) + + fp.write(b";") # end of file + + if hasattr(fp, "flush"): + fp.flush() + + +def get_interlace(im: Image.Image) -> int: + interlace = im.encoderinfo.get("interlace", 1) + + # workaround for @PIL153 + if min(im.size) < 16: + interlace = 0 + + return interlace + + +def _write_local_header( + fp: IO[bytes], im: Image.Image, offset: tuple[int, int], flags: int +) -> None: + try: + transparency = im.encoderinfo["transparency"] + except KeyError: + transparency = None + + if "duration" in im.encoderinfo: + duration = int(im.encoderinfo["duration"] / 10) + else: + duration = 0 + + disposal = int(im.encoderinfo.get("disposal", 0)) + + if transparency is not None or duration != 0 or disposal: + packed_flag = 1 if transparency is not None else 0 + packed_flag |= disposal << 2 + + fp.write( + b"!" + + o8(249) # extension intro + + o8(4) # length + + o8(packed_flag) # packed fields + + o16(duration) # duration + + o8(transparency or 0) # transparency index + + o8(0) + ) + + include_color_table = im.encoderinfo.get("include_color_table") + if include_color_table: + palette_bytes = _get_palette_bytes(im) + color_table_size = _get_color_table_size(palette_bytes) + if color_table_size: + flags = flags | 128 # local color table flag + flags = flags | color_table_size + + fp.write( + b"," + + o16(offset[0]) # offset + + o16(offset[1]) + + o16(im.size[0]) # size + + o16(im.size[1]) + + o8(flags) # flags + ) + if include_color_table and color_table_size: + fp.write(_get_header_palette(palette_bytes)) + fp.write(o8(8)) # bits + + +def _save_netpbm(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: + # Unused by default. + # To use, uncomment the register_save call at the end of the file. + # + # If you need real GIF compression and/or RGB quantization, you + # can use the external NETPBM/PBMPLUS utilities. See comments + # below for information on how to enable this. + tempfile = im._dump() + + try: + with open(filename, "wb") as f: + if im.mode != "RGB": + subprocess.check_call( + ["ppmtogif", tempfile], stdout=f, stderr=subprocess.DEVNULL + ) + else: + # Pipe ppmquant output into ppmtogif + # "ppmquant 256 %s | ppmtogif > %s" % (tempfile, filename) + quant_cmd = ["ppmquant", "256", tempfile] + togif_cmd = ["ppmtogif"] + quant_proc = subprocess.Popen( + quant_cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL + ) + togif_proc = subprocess.Popen( + togif_cmd, + stdin=quant_proc.stdout, + stdout=f, + stderr=subprocess.DEVNULL, + ) + + # Allow ppmquant to receive SIGPIPE if ppmtogif exits + assert quant_proc.stdout is not None + quant_proc.stdout.close() + + retcode = quant_proc.wait() + if retcode: + raise subprocess.CalledProcessError(retcode, quant_cmd) + + retcode = togif_proc.wait() + if retcode: + raise subprocess.CalledProcessError(retcode, togif_cmd) + finally: + try: + os.unlink(tempfile) + except OSError: + pass + + +# Force optimization so that we can test performance against +# cases where it took lots of memory and time previously. +_FORCE_OPTIMIZE = False + + +def _get_optimize(im: Image.Image, info: dict[str, Any]) -> list[int] | None: + """ + Palette optimization is a potentially expensive operation. + + This function determines if the palette should be optimized using + some heuristics, then returns the list of palette entries in use. + + :param im: Image object + :param info: encoderinfo + :returns: list of indexes of palette entries in use, or None + """ + if ( + im.mode in ("P", "L") + and info + and info.get("optimize") + and im.width != 0 + and im.height != 0 + ): + # Potentially expensive operation. + + # The palette saves 3 bytes per color not used, but palette + # lengths are restricted to 3*(2**N) bytes. Max saving would + # be 768 -> 6 bytes if we went all the way down to 2 colors. + # * If we're over 128 colors, we can't save any space. + # * If there aren't any holes, it's not worth collapsing. + # * If we have a 'large' image, the palette is in the noise. + + # create the new palette if not every color is used + optimise = _FORCE_OPTIMIZE or im.mode == "L" + if optimise or im.width * im.height < 512 * 512: + # check which colors are used + used_palette_colors = [] + for i, count in enumerate(im.histogram()): + if count: + used_palette_colors.append(i) + + if optimise or max(used_palette_colors) >= len(used_palette_colors): + return used_palette_colors + + assert im.palette is not None + num_palette_colors = len(im.palette.palette) // Image.getmodebands( + im.palette.mode + ) + current_palette_size = 1 << (num_palette_colors - 1).bit_length() + if ( + # check that the palette would become smaller when saved + len(used_palette_colors) <= current_palette_size // 2 + # check that the palette is not already the smallest possible size + and current_palette_size > 2 + ): + return used_palette_colors + return None + + +def _get_color_table_size(palette_bytes: bytes) -> int: + # calculate the palette size for the header + if not palette_bytes: + return 0 + elif len(palette_bytes) < 9: + return 1 + else: + return math.ceil(math.log(len(palette_bytes) // 3, 2)) - 1 + + +def _get_header_palette(palette_bytes: bytes) -> bytes: + """ + Returns the palette, null padded to the next power of 2 (*3) bytes + suitable for direct inclusion in the GIF header + + :param palette_bytes: Unpadded palette bytes, in RGBRGB form + :returns: Null padded palette + """ + color_table_size = _get_color_table_size(palette_bytes) + + # add the missing amount of bytes + # the palette has to be 2<<n in size + actual_target_size_diff = (2 << color_table_size) - len(palette_bytes) // 3 + if actual_target_size_diff > 0: + palette_bytes += o8(0) * 3 * actual_target_size_diff + return palette_bytes + + +def _get_palette_bytes(im: Image.Image) -> bytes: + """ + Gets the palette for inclusion in the gif header + + :param im: Image object + :returns: Bytes, len<=768 suitable for inclusion in gif header + """ + if not im.palette: + return b"" + + palette = bytes(im.palette.palette) + if im.palette.mode == "RGBA": + palette = b"".join(palette[i * 4 : i * 4 + 3] for i in range(len(palette) // 3)) + return palette + + +def _get_background( + im: Image.Image, + info_background: int | tuple[int, int, int] | tuple[int, int, int, int] | None, +) -> int: + background = 0 + if info_background: + if isinstance(info_background, tuple): + # WebPImagePlugin stores an RGBA value in info["background"] + # So it must be converted to the same format as GifImagePlugin's + # info["background"] - a global color table index + assert im.palette is not None + try: + background = im.palette.getcolor(info_background, im) + except ValueError as e: + if str(e) not in ( + # If all 256 colors are in use, + # then there is no need for the background color + "cannot allocate more than 256 colors", + # Ignore non-opaque WebP background + "cannot add non-opaque RGBA color to RGB palette", + ): + raise + else: + background = info_background + return background + + +def _get_global_header(im: Image.Image, info: dict[str, Any]) -> list[bytes]: + """Return a list of strings representing a GIF header""" + + # Header Block + # https://www.matthewflickinger.com/lab/whatsinagif/bits_and_bytes.asp + + version = b"87a" + if im.info.get("version") == b"89a" or ( + info + and ( + "transparency" in info + or info.get("loop") is not None + or info.get("duration") + or info.get("comment") + ) + ): + version = b"89a" + + background = _get_background(im, info.get("background")) + + palette_bytes = _get_palette_bytes(im) + color_table_size = _get_color_table_size(palette_bytes) + + header = [ + b"GIF" # signature + + version # version + + o16(im.size[0]) # canvas width + + o16(im.size[1]), # canvas height + # Logical Screen Descriptor + # size of global color table + global color table flag + o8(color_table_size + 128), # packed fields + # background + reserved/aspect + o8(background) + o8(0), + # Global Color Table + _get_header_palette(palette_bytes), + ] + if info.get("loop") is not None: + header.append( + b"!" + + o8(255) # extension intro + + o8(11) + + b"NETSCAPE2.0" + + o8(3) + + o8(1) + + o16(info["loop"]) # number of loops + + o8(0) + ) + if info.get("comment"): + comment_block = b"!" + o8(254) # extension intro + + comment = info["comment"] + if isinstance(comment, str): + comment = comment.encode() + for i in range(0, len(comment), 255): + subblock = comment[i : i + 255] + comment_block += o8(len(subblock)) + subblock + + comment_block += o8(0) + header.append(comment_block) + return header + + +def _write_frame_data( + fp: IO[bytes], + im_frame: Image.Image, + offset: tuple[int, int], + params: dict[str, Any], +) -> None: + try: + im_frame.encoderinfo = params + + # local image header + _write_local_header(fp, im_frame, offset, 0) + + ImageFile._save( + im_frame, + fp, + [ImageFile._Tile("gif", (0, 0) + im_frame.size, 0, RAWMODE[im_frame.mode])], + ) + + fp.write(b"\0") # end of image data + finally: + del im_frame.encoderinfo + + +# -------------------------------------------------------------------- +# Legacy GIF utilities + + +def getheader( + im: Image.Image, palette: _Palette | None = None, info: dict[str, Any] | None = None +) -> tuple[list[bytes], list[int] | None]: + """ + Legacy Method to get Gif data from image. + + Warning:: May modify image data. + + :param im: Image object + :param palette: bytes object containing the source palette, or .... + :param info: encoderinfo + :returns: tuple of(list of header items, optimized palette) + + """ + if info is None: + info = {} + + used_palette_colors = _get_optimize(im, info) + + if "background" not in info and "background" in im.info: + info["background"] = im.info["background"] + + im_mod = _normalize_palette(im, palette, info) + im.palette = im_mod.palette + im.im = im_mod.im + header = _get_global_header(im, info) + + return header, used_palette_colors + + +def getdata( + im: Image.Image, offset: tuple[int, int] = (0, 0), **params: Any +) -> list[bytes]: + """ + Legacy Method + + Return a list of strings representing this image. + The first string is a local image header, the rest contains + encoded image data. + + To specify duration, add the time in milliseconds, + e.g. ``getdata(im_frame, duration=1000)`` + + :param im: Image object + :param offset: Tuple of (x, y) pixels. Defaults to (0, 0) + :param \\**params: e.g. duration or other encoder info parameters + :returns: List of bytes containing GIF encoded frame data + + """ + from io import BytesIO + + class Collector(BytesIO): + data = [] + + def write(self, data: Buffer) -> int: + self.data.append(data) + return len(data) + + im.load() # make sure raster data is available + + fp = Collector() + + _write_frame_data(fp, im, offset, params) + + return fp.data + + +# -------------------------------------------------------------------- +# Registry + +Image.register_open(GifImageFile.format, GifImageFile, _accept) +Image.register_save(GifImageFile.format, _save) +Image.register_save_all(GifImageFile.format, _save_all) +Image.register_extension(GifImageFile.format, ".gif") +Image.register_mime(GifImageFile.format, "image/gif") + +# +# Uncomment the following line if you wish to use NETPBM/PBMPLUS +# instead of the built-in "uncompressed" GIF encoder + +# Image.register_save(GifImageFile.format, _save_netpbm) diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/GimpGradientFile.py b/presentation/.venv/lib/python3.12/site-packages/PIL/GimpGradientFile.py new file mode 100644 index 0000000..fb95872 --- /dev/null +++ b/presentation/.venv/lib/python3.12/site-packages/PIL/GimpGradientFile.py @@ -0,0 +1,154 @@ +# +# Python Imaging Library +# $Id$ +# +# stuff to read (and render) GIMP gradient files +# +# History: +# 97-08-23 fl Created +# +# Copyright (c) Secret Labs AB 1997. +# Copyright (c) Fredrik Lundh 1997. +# +# See the README file for information on usage and redistribution. +# + +""" +Stuff to translate curve segments to palette values (derived from +the corresponding code in GIMP, written by Federico Mena Quintero. +See the GIMP distribution for more information.) +""" + +from __future__ import annotations + +from math import log, pi, sin, sqrt + +from ._binary import o8 + +TYPE_CHECKING = False +if TYPE_CHECKING: + from collections.abc import Callable + from typing import IO + +EPSILON = 1e-10 +"""""" # Enable auto-doc for data member + + +def linear(middle: float, pos: float) -> float: + if pos <= middle: + if middle < EPSILON: + return 0.0 + else: + return 0.5 * pos / middle + else: + pos = pos - middle + middle = 1.0 - middle + if middle < EPSILON: + return 1.0 + else: + return 0.5 + 0.5 * pos / middle + + +def curved(middle: float, pos: float) -> float: + return pos ** (log(0.5) / log(max(middle, EPSILON))) + + +def sine(middle: float, pos: float) -> float: + return (sin((-pi / 2.0) + pi * linear(middle, pos)) + 1.0) / 2.0 + + +def sphere_increasing(middle: float, pos: float) -> float: + return sqrt(1.0 - (linear(middle, pos) - 1.0) ** 2) + + +def sphere_decreasing(middle: float, pos: float) -> float: + return 1.0 - sqrt(1.0 - linear(middle, pos) ** 2) + + +SEGMENTS = [linear, curved, sine, sphere_increasing, sphere_decreasing] +"""""" # Enable auto-doc for data member + + +class GradientFile: + gradient: ( + list[ + tuple[ + float, + float, + float, + list[float], + list[float], + Callable[[float, float], float], + ] + ] + | None + ) = None + + def getpalette(self, entries: int = 256) -> tuple[bytes, str]: + assert self.gradient is not None + palette = [] + + ix = 0 + x0, x1, xm, rgb0, rgb1, segment = self.gradient[ix] + + for i in range(entries): + x = i / (entries - 1) + + while x1 < x: + ix += 1 + x0, x1, xm, rgb0, rgb1, segment = self.gradient[ix] + + w = x1 - x0 + + if w < EPSILON: + scale = segment(0.5, 0.5) + else: + scale = segment((xm - x0) / w, (x - x0) / w) + + # expand to RGBA + r = o8(int(255 * ((rgb1[0] - rgb0[0]) * scale + rgb0[0]) + 0.5)) + g = o8(int(255 * ((rgb1[1] - rgb0[1]) * scale + rgb0[1]) + 0.5)) + b = o8(int(255 * ((rgb1[2] - rgb0[2]) * scale + rgb0[2]) + 0.5)) + a = o8(int(255 * ((rgb1[3] - rgb0[3]) * scale + rgb0[3]) + 0.5)) + + # add to palette + palette.append(r + g + b + a) + + return b"".join(palette), "RGBA" + + +class GimpGradientFile(GradientFile): + """File handler for GIMP's gradient format.""" + + def __init__(self, fp: IO[bytes]) -> None: + if not fp.readline().startswith(b"GIMP Gradient"): + msg = "not a GIMP gradient file" + raise SyntaxError(msg) + + line = fp.readline() + + # GIMP 1.2 gradient files don't contain a name, but GIMP 1.3 files do + if line.startswith(b"Name: "): + line = fp.readline().strip() + + count = int(line) + + self.gradient = [] + + for i in range(count): + s = fp.readline().split() + w = [float(x) for x in s[:11]] + + x0, x1 = w[0], w[2] + xm = w[1] + rgb0 = w[3:7] + rgb1 = w[7:11] + + segment = SEGMENTS[int(s[11])] + cspace = int(s[12]) + + if cspace != 0: + msg = "cannot handle HSV colour space" + raise OSError(msg) + + self.gradient.append((x0, x1, xm, rgb0, rgb1, segment)) diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/GimpPaletteFile.py b/presentation/.venv/lib/python3.12/site-packages/PIL/GimpPaletteFile.py new file mode 100644 index 0000000..016257d --- /dev/null +++ b/presentation/.venv/lib/python3.12/site-packages/PIL/GimpPaletteFile.py @@ -0,0 +1,75 @@ +# +# Python Imaging Library +# $Id$ +# +# stuff to read GIMP palette files +# +# History: +# 1997-08-23 fl Created +# 2004-09-07 fl Support GIMP 2.0 palette files. +# +# Copyright (c) Secret Labs AB 1997-2004. All rights reserved. +# Copyright (c) Fredrik Lundh 1997-2004. +# +# See the README file for information on usage and redistribution. +# +from __future__ import annotations + +import re +from io import BytesIO + +TYPE_CHECKING = False +if TYPE_CHECKING: + from typing import IO + + +class GimpPaletteFile: + """File handler for GIMP's palette format.""" + + rawmode = "RGB" + + def _read(self, fp: IO[bytes], limit: bool = True) -> None: + if not fp.readline().startswith(b"GIMP Palette"): + msg = "not a GIMP palette file" + raise SyntaxError(msg) + + palette: list[int] = [] + i = 0 + while True: + if limit and i == 256 + 3: + break + + i += 1 + s = fp.readline() + if not s: + break + + # skip fields and comment lines + if re.match(rb"\w+:|#", s): + continue + if limit and len(s) > 100: + msg = "bad palette file" + raise SyntaxError(msg) + + v = s.split(maxsplit=3) + if len(v) < 3: + msg = "bad palette entry" + raise ValueError(msg) + + palette += (int(v[i]) for i in range(3)) + if limit and len(palette) == 768: + break + + self.palette = bytes(palette) + + def __init__(self, fp: IO[bytes]) -> None: + self._read(fp) + + @classmethod + def frombytes(cls, data: bytes) -> GimpPaletteFile: + self = cls.__new__(cls) + self._read(BytesIO(data), False) + return self + + def getpalette(self) -> tuple[bytes, str]: + return self.palette, self.rawmode diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/GribStubImagePlugin.py b/presentation/.venv/lib/python3.12/site-packages/PIL/GribStubImagePlugin.py new file mode 100644 index 0000000..3784ef2 --- /dev/null +++ b/presentation/.venv/lib/python3.12/site-packages/PIL/GribStubImagePlugin.py @@ -0,0 +1,72 @@ +# +# The Python Imaging Library +# $Id$ +# +# GRIB stub adapter +# +# Copyright (c) 1996-2003 by Fredrik Lundh +# +# See the README file for information on usage and redistribution. +# +from __future__ import annotations + +import os +from typing import IO + +from . import Image, ImageFile + +_handler = None + + +def register_handler(handler: ImageFile.StubHandler | None) -> None: + """ + Install application-specific GRIB image handler. + + :param handler: Handler object. + """ + global _handler + _handler = handler + + +# -------------------------------------------------------------------- +# Image adapter + + +def _accept(prefix: bytes) -> bool: + return len(prefix) >= 8 and prefix.startswith(b"GRIB") and prefix[7] == 1 + + +class GribStubImageFile(ImageFile.StubImageFile): + format = "GRIB" + format_description = "GRIB" + + def _open(self) -> None: + assert self.fp is not None + if not _accept(self.fp.read(8)): + msg = "Not a GRIB file" + raise SyntaxError(msg) + + self.fp.seek(-8, os.SEEK_CUR) + + # make something up + self._mode = "F" + self._size = 1, 1 + + def _load(self) -> ImageFile.StubHandler | None: + return _handler + + +def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: + if _handler is None or not hasattr(_handler, "save"): + msg = "GRIB save handler not installed" + raise OSError(msg) + _handler.save(im, fp, filename) + + +# -------------------------------------------------------------------- +# Registry + +Image.register_open(GribStubImageFile.format, GribStubImageFile, _accept) +Image.register_save(GribStubImageFile.format, _save) + +Image.register_extension(GribStubImageFile.format, ".grib") diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/Hdf5StubImagePlugin.py b/presentation/.venv/lib/python3.12/site-packages/PIL/Hdf5StubImagePlugin.py new file mode 100644 index 0000000..1a56660 --- /dev/null +++ b/presentation/.venv/lib/python3.12/site-packages/PIL/Hdf5StubImagePlugin.py @@ -0,0 +1,72 @@ +# +# The Python Imaging Library +# $Id$ +# +# HDF5 stub adapter +# +# Copyright (c) 2000-2003 by Fredrik Lundh +# +# See the README file for information on usage and redistribution. +# +from __future__ import annotations + +import os +from typing import IO + +from . import Image, ImageFile + +_handler = None + + +def register_handler(handler: ImageFile.StubHandler | None) -> None: + """ + Install application-specific HDF5 image handler. + + :param handler: Handler object. + """ + global _handler + _handler = handler + + +# -------------------------------------------------------------------- +# Image adapter + + +def _accept(prefix: bytes) -> bool: + return prefix.startswith(b"\x89HDF\r\n\x1a\n") + + +class HDF5StubImageFile(ImageFile.StubImageFile): + format = "HDF5" + format_description = "HDF5" + + def _open(self) -> None: + assert self.fp is not None + if not _accept(self.fp.read(8)): + msg = "Not an HDF file" + raise SyntaxError(msg) + + self.fp.seek(-8, os.SEEK_CUR) + + # make something up + self._mode = "F" + self._size = 1, 1 + + def _load(self) -> ImageFile.StubHandler | None: + return _handler + + +def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: + if _handler is None or not hasattr(_handler, "save"): + msg = "HDF5 save handler not installed" + raise OSError(msg) + _handler.save(im, fp, filename) + + +# -------------------------------------------------------------------- +# Registry + +Image.register_open(HDF5StubImageFile.format, HDF5StubImageFile, _accept) +Image.register_save(HDF5StubImageFile.format, _save) + +Image.register_extensions(HDF5StubImageFile.format, [".h5", ".hdf"]) diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/IcnsImagePlugin.py b/presentation/.venv/lib/python3.12/site-packages/PIL/IcnsImagePlugin.py new file mode 100644 index 0000000..cb7a74c --- /dev/null +++ b/presentation/.venv/lib/python3.12/site-packages/PIL/IcnsImagePlugin.py @@ -0,0 +1,401 @@ +# +# The Python Imaging Library. +# $Id$ +# +# macOS icns file decoder, based on icns.py by Bob Ippolito. +# +# history: +# 2004-10-09 fl Turned into a PIL plugin; removed 2.3 dependencies. +# 2020-04-04 Allow saving on all operating systems. +# +# Copyright (c) 2004 by Bob Ippolito. +# Copyright (c) 2004 by Secret Labs. +# Copyright (c) 2004 by Fredrik Lundh. +# Copyright (c) 2014 by Alastair Houghton. +# Copyright (c) 2020 by Pan Jing. +# +# See the README file for information on usage and redistribution. +# +from __future__ import annotations + +import io +import os +import struct +import sys +from typing import IO + +from . import Image, ImageFile, PngImagePlugin, features + +enable_jpeg2k = features.check_codec("jpg_2000") +if enable_jpeg2k: + from . import Jpeg2KImagePlugin + +MAGIC = b"icns" +HEADERSIZE = 8 + + +def nextheader(fobj: IO[bytes]) -> tuple[bytes, int]: + return struct.unpack(">4sI", fobj.read(HEADERSIZE)) + + +def read_32t( + fobj: IO[bytes], start_length: tuple[int, int], size: tuple[int, int, int] +) -> dict[str, Image.Image]: + # The 128x128 icon seems to have an extra header for some reason. + start, length = start_length + fobj.seek(start) + sig = fobj.read(4) + if sig != b"\x00\x00\x00\x00": + msg = "Unknown signature, expecting 0x00000000" + raise SyntaxError(msg) + return read_32(fobj, (start + 4, length - 4), size) + + +def read_32( + fobj: IO[bytes], start_length: tuple[int, int], size: tuple[int, int, int] +) -> dict[str, Image.Image]: + """ + Read a 32bit RGB icon resource. Seems to be either uncompressed or + an RLE packbits-like scheme. + """ + start, length = start_length + fobj.seek(start) + pixel_size = (size[0] * size[2], size[1] * size[2]) + sizesq = pixel_size[0] * pixel_size[1] + if length == sizesq * 3: + # uncompressed ("RGBRGBGB") + indata = fobj.read(length) + im = Image.frombuffer("RGB", pixel_size, indata, "raw", "RGB", 0, 1) + else: + # decode image + im = Image.new("RGB", pixel_size, None) + for band_ix in range(3): + data = [] + bytesleft = sizesq + while bytesleft > 0: + byte = fobj.read(1) + if not byte: + break + byte_int = byte[0] + if byte_int & 0x80: + blocksize = byte_int - 125 + byte = fobj.read(1) + data.extend([byte] * blocksize) + else: + blocksize = byte_int + 1 + data.append(fobj.read(blocksize)) + bytesleft -= blocksize + if bytesleft <= 0: + break + if bytesleft != 0: + msg = f"Error reading channel [{repr(bytesleft)} left]" + raise SyntaxError(msg) + band = Image.frombuffer("L", pixel_size, b"".join(data), "raw", "L", 0, 1) + im.im.putband(band.im, band_ix) + return {"RGB": im} + + +def read_mk( + fobj: IO[bytes], start_length: tuple[int, int], size: tuple[int, int, int] +) -> dict[str, Image.Image]: + # Alpha masks seem to be uncompressed + start = start_length[0] + fobj.seek(start) + pixel_size = (size[0] * size[2], size[1] * size[2]) + sizesq = pixel_size[0] * pixel_size[1] + band = Image.frombuffer("L", pixel_size, fobj.read(sizesq), "raw", "L", 0, 1) + return {"A": band} + + +def read_png_or_jpeg2000( + fobj: IO[bytes], start_length: tuple[int, int], size: tuple[int, int, int] +) -> dict[str, Image.Image]: + start, length = start_length + fobj.seek(start) + sig = fobj.read(12) + + im: Image.Image + if sig.startswith(b"\x89PNG\x0d\x0a\x1a\x0a"): + fobj.seek(start) + im = PngImagePlugin.PngImageFile(fobj) + Image._decompression_bomb_check(im.size) + return {"RGBA": im} + elif ( + sig.startswith((b"\xff\x4f\xff\x51", b"\x0d\x0a\x87\x0a")) + or sig == b"\x00\x00\x00\x0cjP \x0d\x0a\x87\x0a" + ): + if not enable_jpeg2k: + msg = ( + "Unsupported icon subimage format (rebuild PIL " + "with JPEG 2000 support to fix this)" + ) + raise ValueError(msg) + # j2k, jpc or j2c + fobj.seek(start) + jp2kstream = fobj.read(length) + f = io.BytesIO(jp2kstream) + im = Jpeg2KImagePlugin.Jpeg2KImageFile(f) + Image._decompression_bomb_check(im.size) + if im.mode != "RGBA": + im = im.convert("RGBA") + return {"RGBA": im} + else: + msg = "Unsupported icon subimage format" + raise ValueError(msg) + + +class IcnsFile: + SIZES = { + (512, 512, 2): [(b"ic10", read_png_or_jpeg2000)], + (512, 512, 1): [(b"ic09", read_png_or_jpeg2000)], + (256, 256, 2): [(b"ic14", read_png_or_jpeg2000)], + (256, 256, 1): [(b"ic08", read_png_or_jpeg2000)], + (128, 128, 2): [(b"ic13", read_png_or_jpeg2000)], + (128, 128, 1): [ + (b"ic07", read_png_or_jpeg2000), + (b"it32", read_32t), + (b"t8mk", read_mk), + ], + (64, 64, 1): [(b"icp6", read_png_or_jpeg2000)], + (32, 32, 2): [(b"ic12", read_png_or_jpeg2000)], + (48, 48, 1): [(b"ih32", read_32), (b"h8mk", read_mk)], + (32, 32, 1): [ + (b"icp5", read_png_or_jpeg2000), + (b"il32", read_32), + (b"l8mk", read_mk), + ], + (16, 16, 2): [(b"ic11", read_png_or_jpeg2000)], + (16, 16, 1): [ + (b"icp4", read_png_or_jpeg2000), + (b"is32", read_32), + (b"s8mk", read_mk), + ], + } + + def __init__(self, fobj: IO[bytes]) -> None: + """ + fobj is a file-like object as an icns resource + """ + # signature : (start, length) + self.dct = {} + self.fobj = fobj + sig, filesize = nextheader(fobj) + if not _accept(sig): + msg = "not an icns file" + raise SyntaxError(msg) + i = HEADERSIZE + while i < filesize: + sig, blocksize = nextheader(fobj) + if blocksize <= 0: + msg = "invalid block header" + raise SyntaxError(msg) + i += HEADERSIZE + blocksize -= HEADERSIZE + self.dct[sig] = (i, blocksize) + fobj.seek(blocksize, io.SEEK_CUR) + i += blocksize + + def itersizes(self) -> list[tuple[int, int, int]]: + sizes = [] + for size, fmts in self.SIZES.items(): + for fmt, reader in fmts: + if fmt in self.dct: + sizes.append(size) + break + return sizes + + def bestsize(self) -> tuple[int, int, int]: + sizes = self.itersizes() + if not sizes: + msg = "No 32bit icon resources found" + raise SyntaxError(msg) + return max(sizes) + + def dataforsize(self, size: tuple[int, int, int]) -> dict[str, Image.Image]: + """ + Get an icon resource as {channel: array}. Note that + the arrays are bottom-up like windows bitmaps and will likely + need to be flipped or transposed in some way. + """ + dct = {} + for code, reader in self.SIZES[size]: + desc = self.dct.get(code) + if desc is not None: + dct.update(reader(self.fobj, desc, size)) + return dct + + def getimage( + self, size: tuple[int, int] | tuple[int, int, int] | None = None + ) -> Image.Image: + if size is None: + size = self.bestsize() + elif len(size) == 2: + size = (size[0], size[1], 1) + channels = self.dataforsize(size) + + im = channels.get("RGBA") + if im: + return im + + im = channels["RGB"].copy() + try: + im.putalpha(channels["A"]) + except KeyError: + pass + return im + + +## +# Image plugin for Mac OS icons. + + +class IcnsImageFile(ImageFile.ImageFile): + """ + PIL image support for Mac OS .icns files. + Chooses the best resolution, but will possibly load + a different size image if you mutate the size attribute + before calling 'load'. + + The info dictionary has a key 'sizes' that is a list + of sizes that the icns file has. + """ + + format = "ICNS" + format_description = "Mac OS icns resource" + + def _open(self) -> None: + assert self.fp is not None + self.icns = IcnsFile(self.fp) + self._mode = "RGBA" + self.info["sizes"] = self.icns.itersizes() + self.best_size = self.icns.bestsize() + self.size = ( + self.best_size[0] * self.best_size[2], + self.best_size[1] * self.best_size[2], + ) + + @property + def size(self) -> tuple[int, int]: + return self._size + + @size.setter + def size(self, value: tuple[int, int]) -> None: + # Check that a matching size exists, + # or that there is a scale that would create a size that matches + for size in self.info["sizes"]: + simple_size = size[0] * size[2], size[1] * size[2] + scale = simple_size[0] // value[0] + if simple_size[1] / value[1] == scale: + self._size = value + return + msg = "This is not one of the allowed sizes of this image" + raise ValueError(msg) + + def load(self, scale: int | None = None) -> Image.core.PixelAccess | None: + if scale is not None: + width, height = self.size[:2] + self.size = width * scale, height * scale + self.best_size = width, height, scale + + px = Image.Image.load(self) + if self._im is not None and self.im.size == self.size: + # Already loaded + return px + self.load_prepare() + # This is likely NOT the best way to do it, but whatever. + im = self.icns.getimage(self.best_size) + + # If this is a PNG or JPEG 2000, it won't be loaded yet + px = im.load() + + self.im = im.im + self._mode = im.mode + self.size = im.size + + return px + + +def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: + """ + Saves the image as a series of PNG files, + that are then combined into a .icns file. + """ + if hasattr(fp, "flush"): + fp.flush() + + sizes = { + b"ic07": 128, + b"ic08": 256, + b"ic09": 512, + b"ic10": 1024, + b"ic11": 32, + b"ic12": 64, + b"ic13": 256, + b"ic14": 512, + } + provided_images = {im.width: im for im in im.encoderinfo.get("append_images", [])} + size_streams = {} + for size in set(sizes.values()): + image = ( + provided_images[size] + if size in provided_images + else im.resize((size, size)) + ) + + temp = io.BytesIO() + image.save(temp, "png") + size_streams[size] = temp.getvalue() + + entries = [] + for type, size in sizes.items(): + stream = size_streams[size] + entries.append((type, HEADERSIZE + len(stream), stream)) + + # Header + fp.write(MAGIC) + file_length = HEADERSIZE # Header + file_length += HEADERSIZE + 8 * len(entries) # TOC + file_length += sum(entry[1] for entry in entries) + fp.write(struct.pack(">i", file_length)) + + # TOC + fp.write(b"TOC ") + fp.write(struct.pack(">i", HEADERSIZE + len(entries) * HEADERSIZE)) + for entry in entries: + fp.write(entry[0]) + fp.write(struct.pack(">i", entry[1])) + + # Data + for entry in entries: + fp.write(entry[0]) + fp.write(struct.pack(">i", entry[1])) + fp.write(entry[2]) + + if hasattr(fp, "flush"): + fp.flush() + + +def _accept(prefix: bytes) -> bool: + return prefix.startswith(MAGIC) + + +Image.register_open(IcnsImageFile.format, IcnsImageFile, _accept) +Image.register_extension(IcnsImageFile.format, ".icns") + +Image.register_save(IcnsImageFile.format, _save) +Image.register_mime(IcnsImageFile.format, "image/icns") + +if __name__ == "__main__": + if len(sys.argv) < 2: + print("Syntax: python3 IcnsImagePlugin.py [file]") + sys.exit() + + with open(sys.argv[1], "rb") as fp: + imf = IcnsImageFile(fp) + for size in imf.info["sizes"]: + width, height, scale = imf.size = size + imf.save(f"out-{width}-{height}-{scale}.png") + with Image.open(sys.argv[1]) as im: + im.save("out.png") + if sys.platform == "windows": + os.startfile("out.png") diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/IcoImagePlugin.py b/presentation/.venv/lib/python3.12/site-packages/PIL/IcoImagePlugin.py new file mode 100644 index 0000000..8dd57ff --- /dev/null +++ b/presentation/.venv/lib/python3.12/site-packages/PIL/IcoImagePlugin.py @@ -0,0 +1,396 @@ +# +# The Python Imaging Library. +# $Id$ +# +# Windows Icon support for PIL +# +# History: +# 96-05-27 fl Created +# +# Copyright (c) Secret Labs AB 1997. +# Copyright (c) Fredrik Lundh 1996. +# +# See the README file for information on usage and redistribution. +# + +# This plugin is a refactored version of Win32IconImagePlugin by Bryan Davis +# <casadebender@gmail.com>. +# https://code.google.com/archive/p/casadebender/wikis/Win32IconImagePlugin.wiki +# +# Copyright 2008 Bryan Davis +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Icon format references: +# * https://en.wikipedia.org/wiki/ICO_(file_format) +# * https://msdn.microsoft.com/en-us/library/ms997538.aspx +from __future__ import annotations + +import warnings +from io import BytesIO +from math import ceil, log +from typing import IO, NamedTuple + +from . import BmpImagePlugin, Image, ImageFile, PngImagePlugin +from ._binary import i16le as i16 +from ._binary import i32le as i32 +from ._binary import o8 +from ._binary import o16le as o16 +from ._binary import o32le as o32 + +# +# -------------------------------------------------------------------- + +_MAGIC = b"\0\0\1\0" + + +def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: + fp.write(_MAGIC) # (2+2) + bmp = im.encoderinfo.get("bitmap_format") == "bmp" + sizes = im.encoderinfo.get( + "sizes", + [(16, 16), (24, 24), (32, 32), (48, 48), (64, 64), (128, 128), (256, 256)], + ) + frames = [] + provided_ims = [im] + im.encoderinfo.get("append_images", []) + width, height = im.size + for size in sorted(set(sizes)): + if size[0] > width or size[1] > height or size[0] > 256 or size[1] > 256: + continue + + for provided_im in provided_ims: + if provided_im.size != size: + continue + frames.append(provided_im) + if bmp: + bits = BmpImagePlugin.SAVE[provided_im.mode][1] + bits_used = [bits] + for other_im in provided_ims: + if other_im.size != size: + continue + bits = BmpImagePlugin.SAVE[other_im.mode][1] + if bits not in bits_used: + # Another image has been supplied for this size + # with a different bit depth + frames.append(other_im) + bits_used.append(bits) + break + else: + # TODO: invent a more convenient method for proportional scalings + frame = provided_im.copy() + frame.thumbnail(size, Image.Resampling.LANCZOS, reducing_gap=None) + frames.append(frame) + fp.write(o16(len(frames))) # idCount(2) + offset = fp.tell() + len(frames) * 16 + for frame in frames: + width, height = frame.size + # 0 means 256 + fp.write(o8(width if width < 256 else 0)) # bWidth(1) + fp.write(o8(height if height < 256 else 0)) # bHeight(1) + + bits, colors = BmpImagePlugin.SAVE[frame.mode][1:] if bmp else (32, 0) + fp.write(o8(colors)) # bColorCount(1) + fp.write(b"\0") # bReserved(1) + fp.write(b"\0\0") # wPlanes(2) + fp.write(o16(bits)) # wBitCount(2) + + image_io = BytesIO() + if bmp: + frame.save(image_io, "dib") + + if bits != 32: + and_mask = Image.new("1", size) + ImageFile._save( + and_mask, + image_io, + [ImageFile._Tile("raw", (0, 0) + size, 0, ("1", 0, -1))], + ) + else: + frame.save(image_io, "png") + image_io.seek(0) + image_bytes = image_io.read() + if bmp: + image_bytes = image_bytes[:8] + o32(height * 2) + image_bytes[12:] + bytes_len = len(image_bytes) + fp.write(o32(bytes_len)) # dwBytesInRes(4) + fp.write(o32(offset)) # dwImageOffset(4) + current = fp.tell() + fp.seek(offset) + fp.write(image_bytes) + offset = offset + bytes_len + fp.seek(current) + + +def _accept(prefix: bytes) -> bool: + return prefix.startswith(_MAGIC) + + +class IconHeader(NamedTuple): + width: int + height: int + nb_color: int + reserved: int + planes: int + bpp: int + size: int + offset: int + dim: tuple[int, int] + square: int + color_depth: int + + +class IcoFile: + def __init__(self, buf: IO[bytes]) -> None: + """ + Parse image from file-like object containing ico file data + """ + + # check magic + s = buf.read(6) + if not _accept(s): + msg = "not an ICO file" + raise SyntaxError(msg) + + self.buf = buf + self.entry = [] + + # Number of items in file + self.nb_items = i16(s, 4) + + # Get headers for each item + for i in range(self.nb_items): + s = buf.read(16) + + # See Wikipedia + width = s[0] or 256 + height = s[1] or 256 + + # No. of colors in image (0 if >=8bpp) + nb_color = s[2] + bpp = i16(s, 6) + icon_header = IconHeader( + width=width, + height=height, + nb_color=nb_color, + reserved=s[3], + planes=i16(s, 4), + bpp=i16(s, 6), + size=i32(s, 8), + offset=i32(s, 12), + dim=(width, height), + square=width * height, + # See Wikipedia notes about color depth. + # We need this just to differ images with equal sizes + color_depth=bpp or (nb_color != 0 and ceil(log(nb_color, 2))) or 256, + ) + + self.entry.append(icon_header) + + self.entry = sorted(self.entry, key=lambda x: x.color_depth) + # ICO images are usually squares + self.entry = sorted(self.entry, key=lambda x: x.square, reverse=True) + + def sizes(self) -> set[tuple[int, int]]: + """ + Get a set of all available icon sizes and color depths. + """ + return {(h.width, h.height) for h in self.entry} + + def getentryindex(self, size: tuple[int, int], bpp: int | bool = False) -> int: + for i, h in enumerate(self.entry): + if size == h.dim and (bpp is False or bpp == h.color_depth): + return i + return 0 + + def getimage(self, size: tuple[int, int], bpp: int | bool = False) -> Image.Image: + """ + Get an image from the icon + """ + return self.frame(self.getentryindex(size, bpp)) + + def frame(self, idx: int) -> Image.Image: + """ + Get an image from frame idx + """ + + header = self.entry[idx] + + self.buf.seek(header.offset) + data = self.buf.read(8) + self.buf.seek(header.offset) + + im: Image.Image + if data[:8] == PngImagePlugin._MAGIC: + # png frame + im = PngImagePlugin.PngImageFile(self.buf) + Image._decompression_bomb_check(im.size) + else: + # XOR + AND mask bmp frame + im = BmpImagePlugin.DibImageFile(self.buf) + Image._decompression_bomb_check(im.size) + + # change tile dimension to only encompass XOR image + im._size = (im.size[0], int(im.size[1] / 2)) + d, e, o, a = im.tile[0] + im.tile[0] = ImageFile._Tile(d, (0, 0) + im.size, o, a) + + # figure out where AND mask image starts + if header.bpp == 32: + # 32-bit color depth icon image allows semitransparent areas + # PIL's DIB format ignores transparency bits, recover them. + # The DIB is packed in BGRX byte order where X is the alpha + # channel. + + # Back up to start of bmp data + self.buf.seek(o) + # extract every 4th byte (eg. 3,7,11,15,...) + alpha_bytes = self.buf.read(im.size[0] * im.size[1] * 4)[3::4] + + # convert to an 8bpp grayscale image + try: + mask = Image.frombuffer( + "L", # 8bpp + im.size, # (w, h) + alpha_bytes, # source chars + "raw", # raw decoder + ("L", 0, -1), # 8bpp inverted, unpadded, reversed + ) + except ValueError: + if ImageFile.LOAD_TRUNCATED_IMAGES: + mask = None + else: + raise + else: + # get AND image from end of bitmap + w = im.size[0] + if (w % 32) > 0: + # bitmap row data is aligned to word boundaries + w += 32 - (im.size[0] % 32) + + # the total mask data is + # padded row size * height / bits per char + + total_bytes = int((w * im.size[1]) / 8) + and_mask_offset = header.offset + header.size - total_bytes + + self.buf.seek(and_mask_offset) + mask_data = self.buf.read(total_bytes) + + # convert raw data to image + try: + mask = Image.frombuffer( + "1", # 1 bpp + im.size, # (w, h) + mask_data, # source chars + "raw", # raw decoder + ("1;I", int(w / 8), -1), # 1bpp inverted, padded, reversed + ) + except ValueError: + if ImageFile.LOAD_TRUNCATED_IMAGES: + mask = None + else: + raise + + # now we have two images, im is XOR image and mask is AND image + + # apply mask image as alpha channel + if mask: + im = im.convert("RGBA") + im.putalpha(mask) + + return im + + +## +# Image plugin for Windows Icon files. + + +class IcoImageFile(ImageFile.ImageFile): + """ + PIL read-only image support for Microsoft Windows .ico files. + + By default the largest resolution image in the file will be loaded. This + can be changed by altering the 'size' attribute before calling 'load'. + + The info dictionary has a key 'sizes' that is a list of the sizes available + in the icon file. + + Handles classic, XP and Vista icon formats. + + When saving, PNG compression is used. Support for this was only added in + Windows Vista. If you are unable to view the icon in Windows, convert the + image to "RGBA" mode before saving. + + This plugin is a refactored version of Win32IconImagePlugin by Bryan Davis + <casadebender@gmail.com>. + https://code.google.com/archive/p/casadebender/wikis/Win32IconImagePlugin.wiki + """ + + format = "ICO" + format_description = "Windows Icon" + + def _open(self) -> None: + assert self.fp is not None + self.ico = IcoFile(self.fp) + self.info["sizes"] = self.ico.sizes() + self.size = self.ico.entry[0].dim + self.load() + + @property + def size(self) -> tuple[int, int]: + return self._size + + @size.setter + def size(self, value: tuple[int, int]) -> None: + if value not in self.info["sizes"]: + msg = "This is not one of the allowed sizes of this image" + raise ValueError(msg) + self._size = value + + def load(self) -> Image.core.PixelAccess | None: + if self._im is not None and self.im.size == self.size: + # Already loaded + return Image.Image.load(self) + im = self.ico.getimage(self.size) + # if tile is PNG, it won't really be loaded yet + im.load() + self.im = im.im + self._mode = im.mode + if im.palette: + self.palette = im.palette + if im.size != self.size: + warnings.warn("Image was not the expected size") + + index = self.ico.getentryindex(self.size) + sizes = list(self.info["sizes"]) + sizes[index] = im.size + self.info["sizes"] = set(sizes) + + self.size = im.size + return Image.Image.load(self) + + def load_seek(self, pos: int) -> None: + # Flag the ImageFile.Parser so that it + # just does all the decode at the end. + pass + + +# +# -------------------------------------------------------------------- + + +Image.register_open(IcoImageFile.format, IcoImageFile, _accept) +Image.register_save(IcoImageFile.format, _save) +Image.register_extension(IcoImageFile.format, ".ico") + +Image.register_mime(IcoImageFile.format, "image/x-icon") diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/ImImagePlugin.py b/presentation/.venv/lib/python3.12/site-packages/PIL/ImImagePlugin.py new file mode 100644 index 0000000..ef54f16 --- /dev/null +++ b/presentation/.venv/lib/python3.12/site-packages/PIL/ImImagePlugin.py @@ -0,0 +1,390 @@ +# +# The Python Imaging Library. +# $Id$ +# +# IFUNC IM file handling for PIL +# +# history: +# 1995-09-01 fl Created. +# 1997-01-03 fl Save palette images +# 1997-01-08 fl Added sequence support +# 1997-01-23 fl Added P and RGB save support +# 1997-05-31 fl Read floating point images +# 1997-06-22 fl Save floating point images +# 1997-08-27 fl Read and save 1-bit images +# 1998-06-25 fl Added support for RGB+LUT images +# 1998-07-02 fl Added support for YCC images +# 1998-07-15 fl Renamed offset attribute to avoid name clash +# 1998-12-29 fl Added I;16 support +# 2001-02-17 fl Use 're' instead of 'regex' (Python 2.1) (0.7) +# 2003-09-26 fl Added LA/PA support +# +# Copyright (c) 1997-2003 by Secret Labs AB. +# Copyright (c) 1995-2001 by Fredrik Lundh. +# +# See the README file for information on usage and redistribution. +# +from __future__ import annotations + +import os +import re +from typing import IO, Any + +from . import Image, ImageFile, ImagePalette +from ._util import DeferredError + +# -------------------------------------------------------------------- +# Standard tags + +COMMENT = "Comment" +DATE = "Date" +EQUIPMENT = "Digitalization equipment" +FRAMES = "File size (no of images)" +LUT = "Lut" +NAME = "Name" +SCALE = "Scale (x,y)" +SIZE = "Image size (x*y)" +MODE = "Image type" + +TAGS = { + COMMENT: 0, + DATE: 0, + EQUIPMENT: 0, + FRAMES: 0, + LUT: 0, + NAME: 0, + SCALE: 0, + SIZE: 0, + MODE: 0, +} + +OPEN = { + # ifunc93/p3cfunc formats + "0 1 image": ("1", "1"), + "L 1 image": ("1", "1"), + "Greyscale image": ("L", "L"), + "Grayscale image": ("L", "L"), + "RGB image": ("RGB", "RGB;L"), + "RLB image": ("RGB", "RLB"), + "RYB image": ("RGB", "RLB"), + "B1 image": ("1", "1"), + "B2 image": ("P", "P;2"), + "B4 image": ("P", "P;4"), + "X 24 image": ("RGB", "RGB"), + "L 32 S image": ("I", "I;32"), + "L 32 F image": ("F", "F;32"), + # old p3cfunc formats + "RGB3 image": ("RGB", "RGB;T"), + "RYB3 image": ("RGB", "RYB;T"), + # extensions + "LA image": ("LA", "LA;L"), + "PA image": ("LA", "PA;L"), + "RGBA image": ("RGBA", "RGBA;L"), + "RGBX image": ("RGB", "RGBX;L"), + "CMYK image": ("CMYK", "CMYK;L"), + "YCC image": ("YCbCr", "YCbCr;L"), +} + +# ifunc95 extensions +for i in ["8", "8S", "16", "16S", "32", "32F"]: + OPEN[f"L {i} image"] = ("F", f"F;{i}") + OPEN[f"L*{i} image"] = ("F", f"F;{i}") +for i in ["16", "16L", "16B"]: + OPEN[f"L {i} image"] = (f"I;{i}", f"I;{i}") + OPEN[f"L*{i} image"] = (f"I;{i}", f"I;{i}") +for i in ["32S"]: + OPEN[f"L {i} image"] = ("I", f"I;{i}") + OPEN[f"L*{i} image"] = ("I", f"I;{i}") +for j in range(2, 33): + OPEN[f"L*{j} image"] = ("F", f"F;{j}") + + +# -------------------------------------------------------------------- +# Read IM directory + +split = re.compile(rb"^([A-Za-z][^:]*):[ \t]*(.*)[ \t]*$") + + +def number(s: Any) -> float: + try: + return int(s) + except ValueError: + return float(s) + + +## +# Image plugin for the IFUNC IM file format. + + +class ImImageFile(ImageFile.ImageFile): + format = "IM" + format_description = "IFUNC Image Memory" + _close_exclusive_fp_after_loading = False + + def _open(self) -> None: + # Quick rejection: if there's not an LF among the first + # 100 bytes, this is (probably) not a text header. + + assert self.fp is not None + if b"\n" not in self.fp.read(100): + msg = "not an IM file" + raise SyntaxError(msg) + self.fp.seek(0) + + n = 0 + + # Default values + self.info[MODE] = "L" + self.info[SIZE] = (512, 512) + self.info[FRAMES] = 1 + + self.rawmode = "L" + + while True: + s = self.fp.read(1) + + # Some versions of IFUNC uses \n\r instead of \r\n... + if s == b"\r": + continue + + if not s or s == b"\0" or s == b"\x1a": + break + + # FIXME: this may read whole file if not a text file + s = s + self.fp.readline() + + if len(s) > 100: + msg = "not an IM file" + raise SyntaxError(msg) + + if s.endswith(b"\r\n"): + s = s[:-2] + elif s.endswith(b"\n"): + s = s[:-1] + + try: + m = split.match(s) + except re.error as e: + msg = "not an IM file" + raise SyntaxError(msg) from e + + if m: + k, v = m.group(1, 2) + + # Don't know if this is the correct encoding, + # but a decent guess (I guess) + k = k.decode("latin-1", "replace") + v = v.decode("latin-1", "replace") + + # Convert value as appropriate + if k in [FRAMES, SCALE, SIZE]: + v = v.replace("*", ",") + v = tuple(map(number, v.split(","))) + if len(v) == 1: + v = v[0] + elif k == MODE and v in OPEN: + v, self.rawmode = OPEN[v] + + # Add to dictionary. Note that COMMENT tags are + # combined into a list of strings. + if k == COMMENT: + if k in self.info: + self.info[k].append(v) + else: + self.info[k] = [v] + else: + self.info[k] = v + + if k in TAGS: + n += 1 + + else: + msg = f"Syntax error in IM header: {s.decode('ascii', 'replace')}" + raise SyntaxError(msg) + + if not n: + msg = "Not an IM file" + raise SyntaxError(msg) + + # Basic attributes + self._size = self.info[SIZE] + self._mode = self.info[MODE] + + # Skip forward to start of image data + while s and not s.startswith(b"\x1a"): + s = self.fp.read(1) + if not s: + msg = "File truncated" + raise SyntaxError(msg) + + if LUT in self.info: + # convert lookup table to palette or lut attribute + palette = self.fp.read(768) + greyscale = 1 # greyscale palette + linear = 1 # linear greyscale palette + for i in range(256): + if palette[i] == palette[i + 256] == palette[i + 512]: + if palette[i] != i: + linear = 0 + else: + greyscale = 0 + if self.mode in ["L", "LA", "P", "PA"]: + if greyscale: + if not linear: + self.lut = list(palette[:256]) + else: + if self.mode in ["L", "P"]: + self._mode = self.rawmode = "P" + elif self.mode in ["LA", "PA"]: + self._mode = "PA" + self.rawmode = "PA;L" + self.palette = ImagePalette.raw("RGB;L", palette) + elif self.mode == "RGB": + if not greyscale or not linear: + self.lut = list(palette) + + self.frame = 0 + + self.__offset = offs = self.fp.tell() + + self._fp = self.fp # FIXME: hack + + if self.rawmode.startswith("F;"): + # ifunc95 formats + try: + # use bit decoder (if necessary) + bits = int(self.rawmode[2:]) + if bits not in [8, 16, 32]: + self.tile = [ + ImageFile._Tile( + "bit", (0, 0) + self.size, offs, (bits, 8, 3, 0, -1) + ) + ] + return + except ValueError: + pass + + if self.rawmode in ["RGB;T", "RYB;T"]: + # Old LabEye/3PC files. Would be very surprised if anyone + # ever stumbled upon such a file ;-) + size = self.size[0] * self.size[1] + self.tile = [ + ImageFile._Tile("raw", (0, 0) + self.size, offs, ("G", 0, -1)), + ImageFile._Tile("raw", (0, 0) + self.size, offs + size, ("R", 0, -1)), + ImageFile._Tile( + "raw", (0, 0) + self.size, offs + 2 * size, ("B", 0, -1) + ), + ] + else: + # LabEye/IFUNC files + self.tile = [ + ImageFile._Tile("raw", (0, 0) + self.size, offs, (self.rawmode, 0, -1)) + ] + + @property + def n_frames(self) -> int: + return self.info[FRAMES] + + @property + def is_animated(self) -> bool: + return self.info[FRAMES] > 1 + + def seek(self, frame: int) -> None: + if not self._seek_check(frame): + return + if isinstance(self._fp, DeferredError): + raise self._fp.ex + + self.frame = frame + + if self.mode == "1": + bits = 1 + else: + bits = 8 * len(self.mode) + + size = ((self.size[0] * bits + 7) // 8) * self.size[1] + offs = self.__offset + frame * size + + self.fp = self._fp + + self.tile = [ + ImageFile._Tile("raw", (0, 0) + self.size, offs, (self.rawmode, 0, -1)) + ] + + def tell(self) -> int: + return self.frame + + +# +# -------------------------------------------------------------------- +# Save IM files + + +SAVE = { + # mode: (im type, raw mode) + "1": ("0 1", "1"), + "L": ("Greyscale", "L"), + "LA": ("LA", "LA;L"), + "P": ("Greyscale", "P"), + "PA": ("LA", "PA;L"), + "I": ("L 32S", "I;32S"), + "I;16": ("L 16", "I;16"), + "I;16L": ("L 16L", "I;16L"), + "I;16B": ("L 16B", "I;16B"), + "F": ("L 32F", "F;32F"), + "RGB": ("RGB", "RGB;L"), + "RGBA": ("RGBA", "RGBA;L"), + "RGBX": ("RGBX", "RGBX;L"), + "CMYK": ("CMYK", "CMYK;L"), + "YCbCr": ("YCC", "YCbCr;L"), +} + + +def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: + try: + image_type, rawmode = SAVE[im.mode] + except KeyError as e: + msg = f"Cannot save {im.mode} images as IM" + raise ValueError(msg) from e + + frames = im.encoderinfo.get("frames", 1) + + fp.write(f"Image type: {image_type} image\r\n".encode("ascii")) + if filename: + # Each line must be 100 characters or less, + # or: SyntaxError("not an IM file") + # 8 characters are used for "Name: " and "\r\n" + # Keep just the filename, ditch the potentially overlong path + if isinstance(filename, bytes): + filename = filename.decode("ascii") + name, ext = os.path.splitext(os.path.basename(filename)) + name = "".join([name[: 92 - len(ext)], ext]) + + fp.write(f"Name: {name}\r\n".encode("ascii")) + fp.write(f"Image size (x*y): {im.size[0]}*{im.size[1]}\r\n".encode("ascii")) + fp.write(f"File size (no of images): {frames}\r\n".encode("ascii")) + if im.mode in ["P", "PA"]: + fp.write(b"Lut: 1\r\n") + fp.write(b"\000" * (511 - fp.tell()) + b"\032") + if im.mode in ["P", "PA"]: + im_palette = im.im.getpalette("RGB", "RGB;L") + colors = len(im_palette) // 3 + palette = b"" + for i in range(3): + palette += im_palette[colors * i : colors * (i + 1)] + palette += b"\x00" * (256 - colors) + fp.write(palette) # 768 bytes + ImageFile._save( + im, fp, [ImageFile._Tile("raw", (0, 0) + im.size, 0, (rawmode, 0, -1))] + ) + + +# +# -------------------------------------------------------------------- +# Registry + + +Image.register_open(ImImageFile.format, ImImageFile) +Image.register_save(ImImageFile.format, _save) + +Image.register_extension(ImImageFile.format, ".im") diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/Image.py b/presentation/.venv/lib/python3.12/site-packages/PIL/Image.py new file mode 100644 index 0000000..5749807 --- /dev/null +++ b/presentation/.venv/lib/python3.12/site-packages/PIL/Image.py @@ -0,0 +1,4381 @@ +# +# The Python Imaging Library. +# $Id$ +# +# the Image class wrapper +# +# partial release history: +# 1995-09-09 fl Created +# 1996-03-11 fl PIL release 0.0 (proof of concept) +# 1996-04-30 fl PIL release 0.1b1 +# 1999-07-28 fl PIL release 1.0 final +# 2000-06-07 fl PIL release 1.1 +# 2000-10-20 fl PIL release 1.1.1 +# 2001-05-07 fl PIL release 1.1.2 +# 2002-03-15 fl PIL release 1.1.3 +# 2003-05-10 fl PIL release 1.1.4 +# 2005-03-28 fl PIL release 1.1.5 +# 2006-12-02 fl PIL release 1.1.6 +# 2009-11-15 fl PIL release 1.1.7 +# +# Copyright (c) 1997-2009 by Secret Labs AB. All rights reserved. +# Copyright (c) 1995-2009 by Fredrik Lundh. +# +# See the README file for information on usage and redistribution. +# + +from __future__ import annotations + +import abc +import atexit +import builtins +import io +import logging +import math +import os +import re +import struct +import sys +import tempfile +import warnings +from collections.abc import MutableMapping +from enum import IntEnum +from typing import IO, Protocol, cast + +# VERSION was removed in Pillow 6.0.0. +# PILLOW_VERSION was removed in Pillow 9.0.0. +# Use __version__ instead. +from . import ( + ExifTags, + ImageMode, + TiffTags, + UnidentifiedImageError, + __version__, + _plugins, +) +from ._binary import i32le, o32be, o32le +from ._deprecate import deprecate +from ._util import DeferredError, is_path + +ElementTree: ModuleType | None +try: + from defusedxml import ElementTree +except ImportError: + ElementTree = None + +TYPE_CHECKING = False +if TYPE_CHECKING: + from collections.abc import Callable, Iterator, Sequence + from types import ModuleType + from typing import Any, Literal + +logger = logging.getLogger(__name__) + + +class DecompressionBombWarning(RuntimeWarning): + pass + + +class DecompressionBombError(Exception): + pass + + +WARN_POSSIBLE_FORMATS: bool = False + +# Limit to around a quarter gigabyte for a 24-bit (3 bpp) image +MAX_IMAGE_PIXELS: int | None = int(1024 * 1024 * 1024 // 4 // 3) + + +try: + # If the _imaging C module is not present, Pillow will not load. + # Note that other modules should not refer to _imaging directly; + # import Image and use the Image.core variable instead. + # Also note that Image.core is not a publicly documented interface, + # and should be considered private and subject to change. + from . import _imaging as core + + if __version__ != getattr(core, "PILLOW_VERSION", None): + msg = ( + "The _imaging extension was built for another version of Pillow or PIL:\n" + f"Core version: {getattr(core, 'PILLOW_VERSION', None)}\n" + f"Pillow version: {__version__}" + ) + raise ImportError(msg) + +except ImportError as v: + # Explanations for ways that we know we might have an import error + if str(v).startswith("Module use of python"): + # The _imaging C module is present, but not compiled for + # the right version (windows only). Print a warning, if + # possible. + warnings.warn( + "The _imaging extension was built for another version of Python.", + RuntimeWarning, + ) + elif str(v).startswith("The _imaging extension"): + warnings.warn(str(v), RuntimeWarning) + # Fail here anyway. Don't let people run with a mostly broken Pillow. + # see docs/porting.rst + raise + + +# +# Constants + + +# transpose +class Transpose(IntEnum): + FLIP_LEFT_RIGHT = 0 + FLIP_TOP_BOTTOM = 1 + ROTATE_90 = 2 + ROTATE_180 = 3 + ROTATE_270 = 4 + TRANSPOSE = 5 + TRANSVERSE = 6 + + +# transforms (also defined in Imaging.h) +class Transform(IntEnum): + AFFINE = 0 + EXTENT = 1 + PERSPECTIVE = 2 + QUAD = 3 + MESH = 4 + + +# resampling filters (also defined in Imaging.h) +class Resampling(IntEnum): + NEAREST = 0 + BOX = 4 + BILINEAR = 2 + HAMMING = 5 + BICUBIC = 3 + LANCZOS = 1 + + +_filters_support = { + Resampling.BOX: 0.5, + Resampling.BILINEAR: 1.0, + Resampling.HAMMING: 1.0, + Resampling.BICUBIC: 2.0, + Resampling.LANCZOS: 3.0, +} + + +# dithers +class Dither(IntEnum): + NONE = 0 + ORDERED = 1 # Not yet implemented + RASTERIZE = 2 # Not yet implemented + FLOYDSTEINBERG = 3 # default + + +# palettes/quantizers +class Palette(IntEnum): + WEB = 0 + ADAPTIVE = 1 + + +class Quantize(IntEnum): + MEDIANCUT = 0 + MAXCOVERAGE = 1 + FASTOCTREE = 2 + LIBIMAGEQUANT = 3 + + +module = sys.modules[__name__] +for enum in (Transpose, Transform, Resampling, Dither, Palette, Quantize): + for item in enum: + setattr(module, item.name, item.value) + + +if hasattr(core, "DEFAULT_STRATEGY"): + DEFAULT_STRATEGY = core.DEFAULT_STRATEGY + FILTERED = core.FILTERED + HUFFMAN_ONLY = core.HUFFMAN_ONLY + RLE = core.RLE + FIXED = core.FIXED + + +# -------------------------------------------------------------------- +# Registries + +TYPE_CHECKING = False +if TYPE_CHECKING: + import mmap + from xml.etree.ElementTree import Element + + from IPython.lib.pretty import PrettyPrinter + + from . import ImageFile, ImageFilter, ImagePalette, ImageQt, TiffImagePlugin + from ._typing import CapsuleType, NumpyArray, StrOrBytesPath +ID: list[str] = [] +OPEN: dict[ + str, + tuple[ + Callable[[IO[bytes], str | bytes], ImageFile.ImageFile], + Callable[[bytes], bool | str] | None, + ], +] = {} +MIME: dict[str, str] = {} +SAVE: dict[str, Callable[[Image, IO[bytes], str | bytes], None]] = {} +SAVE_ALL: dict[str, Callable[[Image, IO[bytes], str | bytes], None]] = {} +EXTENSION: dict[str, str] = {} +DECODERS: dict[str, type[ImageFile.PyDecoder]] = {} +ENCODERS: dict[str, type[ImageFile.PyEncoder]] = {} + +# -------------------------------------------------------------------- +# Modes + +_ENDIAN = "<" if sys.byteorder == "little" else ">" + + +def _conv_type_shape(im: Image) -> tuple[tuple[int, ...], str]: + m = ImageMode.getmode(im.mode) + shape: tuple[int, ...] = (im.height, im.width) + extra = len(m.bands) + if extra != 1: + shape += (extra,) + return shape, m.typestr + + +MODES = [ + "1", + "CMYK", + "F", + "HSV", + "I", + "I;16", + "I;16B", + "I;16L", + "I;16N", + "L", + "LA", + "La", + "LAB", + "P", + "PA", + "RGB", + "RGBA", + "RGBa", + "RGBX", + "YCbCr", +] + +# raw modes that may be memory mapped. NOTE: if you change this, you +# may have to modify the stride calculation in map.c too! +_MAPMODES = ("L", "P", "RGBX", "RGBA", "CMYK", "I;16", "I;16L", "I;16B") + + +def getmodebase(mode: str) -> str: + """ + Gets the "base" mode for given mode. This function returns "L" for + images that contain grayscale data, and "RGB" for images that + contain color data. + + :param mode: Input mode. + :returns: "L" or "RGB". + :exception KeyError: If the input mode was not a standard mode. + """ + return ImageMode.getmode(mode).basemode + + +def getmodetype(mode: str) -> str: + """ + Gets the storage type mode. Given a mode, this function returns a + single-layer mode suitable for storing individual bands. + + :param mode: Input mode. + :returns: "L", "I", or "F". + :exception KeyError: If the input mode was not a standard mode. + """ + return ImageMode.getmode(mode).basetype + + +def getmodebandnames(mode: str) -> tuple[str, ...]: + """ + Gets a list of individual band names. Given a mode, this function returns + a tuple containing the names of individual bands (use + :py:method:`~PIL.Image.getmodetype` to get the mode used to store each + individual band. + + :param mode: Input mode. + :returns: A tuple containing band names. The length of the tuple + gives the number of bands in an image of the given mode. + :exception KeyError: If the input mode was not a standard mode. + """ + return ImageMode.getmode(mode).bands + + +def getmodebands(mode: str) -> int: + """ + Gets the number of individual bands for this mode. + + :param mode: Input mode. + :returns: The number of bands in this mode. + :exception KeyError: If the input mode was not a standard mode. + """ + return len(ImageMode.getmode(mode).bands) + + +# -------------------------------------------------------------------- +# Helpers + +_initialized = 0 + +# Mapping from file extension to plugin module name for lazy importing +_EXTENSION_PLUGIN: dict[str, str] = { + # Common formats (preinit) + ".bmp": "BmpImagePlugin", + ".dib": "BmpImagePlugin", + ".gif": "GifImagePlugin", + ".jfif": "JpegImagePlugin", + ".jpe": "JpegImagePlugin", + ".jpg": "JpegImagePlugin", + ".jpeg": "JpegImagePlugin", + ".pbm": "PpmImagePlugin", + ".pgm": "PpmImagePlugin", + ".pnm": "PpmImagePlugin", + ".ppm": "PpmImagePlugin", + ".pfm": "PpmImagePlugin", + ".png": "PngImagePlugin", + ".apng": "PngImagePlugin", + # Less common formats (init) + ".avif": "AvifImagePlugin", + ".avifs": "AvifImagePlugin", + ".blp": "BlpImagePlugin", + ".bufr": "BufrStubImagePlugin", + ".cur": "CurImagePlugin", + ".dcx": "DcxImagePlugin", + ".dds": "DdsImagePlugin", + ".ps": "EpsImagePlugin", + ".eps": "EpsImagePlugin", + ".fit": "FitsImagePlugin", + ".fits": "FitsImagePlugin", + ".fli": "FliImagePlugin", + ".flc": "FliImagePlugin", + ".fpx": "FpxImagePlugin", + ".ftc": "FtexImagePlugin", + ".ftu": "FtexImagePlugin", + ".gbr": "GbrImagePlugin", + ".grib": "GribStubImagePlugin", + ".h5": "Hdf5StubImagePlugin", + ".hdf": "Hdf5StubImagePlugin", + ".icns": "IcnsImagePlugin", + ".ico": "IcoImagePlugin", + ".im": "ImImagePlugin", + ".iim": "IptcImagePlugin", + ".jp2": "Jpeg2KImagePlugin", + ".j2k": "Jpeg2KImagePlugin", + ".jpc": "Jpeg2KImagePlugin", + ".jpf": "Jpeg2KImagePlugin", + ".jpx": "Jpeg2KImagePlugin", + ".j2c": "Jpeg2KImagePlugin", + ".mic": "MicImagePlugin", + ".mpg": "MpegImagePlugin", + ".mpeg": "MpegImagePlugin", + ".mpo": "MpoImagePlugin", + ".msp": "MspImagePlugin", + ".palm": "PalmImagePlugin", + ".pcd": "PcdImagePlugin", + ".pcx": "PcxImagePlugin", + ".pdf": "PdfImagePlugin", + ".pxr": "PixarImagePlugin", + ".psd": "PsdImagePlugin", + ".qoi": "QoiImagePlugin", + ".bw": "SgiImagePlugin", + ".rgb": "SgiImagePlugin", + ".rgba": "SgiImagePlugin", + ".sgi": "SgiImagePlugin", + ".ras": "SunImagePlugin", + ".tga": "TgaImagePlugin", + ".icb": "TgaImagePlugin", + ".vda": "TgaImagePlugin", + ".vst": "TgaImagePlugin", + ".tif": "TiffImagePlugin", + ".tiff": "TiffImagePlugin", + ".webp": "WebPImagePlugin", + ".wmf": "WmfImagePlugin", + ".emf": "WmfImagePlugin", + ".xbm": "XbmImagePlugin", + ".xpm": "XpmImagePlugin", +} + + +def _import_plugin_for_extension(ext: str | bytes) -> bool: + """Import only the plugin needed for a specific file extension.""" + if not ext: + return False + + if isinstance(ext, bytes): + ext = ext.decode() + ext = ext.lower() + if ext in EXTENSION: + return True + + plugin = _EXTENSION_PLUGIN.get(ext) + if plugin is None: + return False + + try: + logger.debug("Importing %s", plugin) + __import__(f"{__spec__.parent}.{plugin}", globals(), locals(), []) + return True + except ImportError as e: + logger.debug("Image: failed to import %s: %s", plugin, e) + return False + + +def preinit() -> None: + """ + Explicitly loads BMP, GIF, JPEG, PPM and PNG file format drivers. + + It is called when opening or saving images. + """ + + global _initialized + if _initialized >= 1: + return + + try: + from . import BmpImagePlugin + + assert BmpImagePlugin + except ImportError: + pass + try: + from . import GifImagePlugin + + assert GifImagePlugin + except ImportError: + pass + try: + from . import JpegImagePlugin + + assert JpegImagePlugin + except ImportError: + pass + try: + from . import PpmImagePlugin + + assert PpmImagePlugin + except ImportError: + pass + try: + from . import PngImagePlugin + + assert PngImagePlugin + except ImportError: + pass + + _initialized = 1 + + +def init() -> bool: + """ + Explicitly initializes the Python Imaging Library. This function + loads all available file format drivers. + + It is called when opening or saving images if :py:meth:`~preinit()` is + insufficient, and by :py:meth:`~PIL.features.pilinfo`. + """ + + global _initialized + if _initialized >= 2: + return False + + for plugin in _plugins: + try: + logger.debug("Importing %s", plugin) + __import__(f"{__spec__.parent}.{plugin}", globals(), locals(), []) + except ImportError as e: # noqa: PERF203 + logger.debug("Image: failed to import %s: %s", plugin, e) + + if OPEN or SAVE: + _initialized = 2 + return True + return False + + +# -------------------------------------------------------------------- +# Codec factories (used by tobytes/frombytes and ImageFile.load) + + +def _getdecoder( + mode: str, decoder_name: str, args: Any, extra: tuple[Any, ...] = () +) -> core.ImagingDecoder | ImageFile.PyDecoder: + # tweak arguments + if args is None: + args = () + elif not isinstance(args, tuple): + args = (args,) + + try: + decoder = DECODERS[decoder_name] + except KeyError: + pass + else: + return decoder(mode, *args + extra) + + try: + # get decoder + decoder = getattr(core, f"{decoder_name}_decoder") + except AttributeError as e: + msg = f"decoder {decoder_name} not available" + raise OSError(msg) from e + return decoder(mode, *args + extra) + + +def _getencoder( + mode: str, encoder_name: str, args: Any, extra: tuple[Any, ...] = () +) -> core.ImagingEncoder | ImageFile.PyEncoder: + # tweak arguments + if args is None: + args = () + elif not isinstance(args, tuple): + args = (args,) + + try: + encoder = ENCODERS[encoder_name] + except KeyError: + pass + else: + return encoder(mode, *args + extra) + + try: + # get encoder + encoder = getattr(core, f"{encoder_name}_encoder") + except AttributeError as e: + msg = f"encoder {encoder_name} not available" + raise OSError(msg) from e + return encoder(mode, *args + extra) + + +# -------------------------------------------------------------------- +# Simple expression analyzer + + +class ImagePointTransform: + """ + Used with :py:meth:`~PIL.Image.Image.point` for single band images with more than + 8 bits, this represents an affine transformation, where the value is multiplied by + ``scale`` and ``offset`` is added. + """ + + def __init__(self, scale: float, offset: float) -> None: + self.scale = scale + self.offset = offset + + def __neg__(self) -> ImagePointTransform: + return ImagePointTransform(-self.scale, -self.offset) + + def __add__(self, other: ImagePointTransform | float) -> ImagePointTransform: + if isinstance(other, ImagePointTransform): + return ImagePointTransform( + self.scale + other.scale, self.offset + other.offset + ) + return ImagePointTransform(self.scale, self.offset + other) + + __radd__ = __add__ + + def __sub__(self, other: ImagePointTransform | float) -> ImagePointTransform: + return self + -other + + def __rsub__(self, other: ImagePointTransform | float) -> ImagePointTransform: + return other + -self + + def __mul__(self, other: ImagePointTransform | float) -> ImagePointTransform: + if isinstance(other, ImagePointTransform): + return NotImplemented + return ImagePointTransform(self.scale * other, self.offset * other) + + __rmul__ = __mul__ + + def __truediv__(self, other: ImagePointTransform | float) -> ImagePointTransform: + if isinstance(other, ImagePointTransform): + return NotImplemented + return ImagePointTransform(self.scale / other, self.offset / other) + + +def _getscaleoffset( + expr: Callable[[ImagePointTransform], ImagePointTransform | float], +) -> tuple[float, float]: + a = expr(ImagePointTransform(1, 0)) + return (a.scale, a.offset) if isinstance(a, ImagePointTransform) else (0, a) + + +# -------------------------------------------------------------------- +# Implementation wrapper + + +class SupportsGetData(Protocol): + def getdata( + self, + ) -> tuple[Transform, Sequence[int]]: ... + + +class Image: + """ + This class represents an image object. To create + :py:class:`~PIL.Image.Image` objects, use the appropriate factory + functions. There's hardly ever any reason to call the Image constructor + directly. + + * :py:func:`~PIL.Image.open` + * :py:func:`~PIL.Image.new` + * :py:func:`~PIL.Image.frombytes` + """ + + format: str | None = None + format_description: str | None = None + _close_exclusive_fp_after_loading = True + + def __init__(self) -> None: + # FIXME: take "new" parameters / other image? + self._im: core.ImagingCore | DeferredError | None = None + self._mode = "" + self._size = (0, 0) + self.palette: ImagePalette.ImagePalette | None = None + self.info: dict[str | tuple[int, int], Any] = {} + self.readonly = 0 + self._exif: Exif | None = None + + @property + def im(self) -> core.ImagingCore: + if isinstance(self._im, DeferredError): + raise self._im.ex + assert self._im is not None + return self._im + + @im.setter + def im(self, im: core.ImagingCore) -> None: + self._im = im + + @property + def width(self) -> int: + return self.size[0] + + @property + def height(self) -> int: + return self.size[1] + + @property + def size(self) -> tuple[int, int]: + return self._size + + @property + def mode(self) -> str: + return self._mode + + @property + def readonly(self) -> int: + return (self._im and self._im.readonly) or self._readonly + + @readonly.setter + def readonly(self, readonly: int) -> None: + self._readonly = readonly + + def _new(self, im: core.ImagingCore) -> Image: + new = Image() + new.im = im + new._mode = im.mode + new._size = im.size + if im.mode in ("P", "PA"): + if self.palette: + new.palette = self.palette.copy() + else: + from . import ImagePalette + + new.palette = ImagePalette.ImagePalette() + new.info = self.info.copy() + return new + + # Context manager support + def __enter__(self) -> Image: + return self + + def __exit__(self, *args: object) -> None: + pass + + def close(self) -> None: + """ + This operation will destroy the image core and release its memory. + The image data will be unusable afterward. + + This function is required to close images that have multiple frames or + have not had their file read and closed by the + :py:meth:`~PIL.Image.Image.load` method. See :ref:`file-handling` for + more information. + """ + if getattr(self, "map", None): + if sys.platform == "win32" and hasattr(sys, "pypy_version_info"): + self.map.close() + self.map: mmap.mmap | None = None + + # Instead of simply setting to None, we're setting up a + # deferred error that will better explain that the core image + # object is gone. + self._im = DeferredError(ValueError("Operation on closed image")) + + def _copy(self) -> None: + self.load() + self.im = self.im.copy() + self.readonly = 0 + + def _ensure_mutable(self) -> None: + if self.readonly: + self._copy() + else: + self.load() + + def _dump( + self, file: str | None = None, format: str | None = None, **options: Any + ) -> str: + suffix = "" + if format: + suffix = f".{format}" + + if not file: + f, filename = tempfile.mkstemp(suffix) + os.close(f) + else: + filename = file + if not filename.endswith(suffix): + filename = filename + suffix + + self.load() + + if not format or format == "PPM": + self.im.save_ppm(filename) + else: + self.save(filename, format, **options) + + return filename + + def __eq__(self, other: object) -> bool: + if self.__class__ is not other.__class__: + return False + assert isinstance(other, Image) + return ( + self.mode == other.mode + and self.size == other.size + and self.info == other.info + and self.getpalette() == other.getpalette() + and self.tobytes() == other.tobytes() + ) + + def __repr__(self) -> str: + return ( + f"<{self.__class__.__module__}.{self.__class__.__name__} " + f"image mode={self.mode} size={self.size[0]}x{self.size[1]} " + f"at 0x{id(self):X}>" + ) + + def _repr_pretty_(self, p: PrettyPrinter, cycle: bool) -> None: + """IPython plain text display support""" + + # Same as __repr__ but without unpredictable id(self), + # to keep Jupyter notebook `text/plain` output stable. + p.text( + f"<{self.__class__.__module__}.{self.__class__.__name__} " + f"image mode={self.mode} size={self.size[0]}x{self.size[1]}>" + ) + + def _repr_image(self, image_format: str, **kwargs: Any) -> bytes | None: + """Helper function for iPython display hook. + + :param image_format: Image format. + :returns: image as bytes, saved into the given format. + """ + b = io.BytesIO() + try: + self.save(b, image_format, **kwargs) + except Exception: + return None + return b.getvalue() + + def _repr_png_(self) -> bytes | None: + """iPython display hook support for PNG format. + + :returns: PNG version of the image as bytes + """ + return self._repr_image("PNG", compress_level=1) + + def _repr_jpeg_(self) -> bytes | None: + """iPython display hook support for JPEG format. + + :returns: JPEG version of the image as bytes + """ + return self._repr_image("JPEG") + + @property + def __array_interface__(self) -> dict[str, str | bytes | int | tuple[int, ...]]: + # numpy array interface support + new: dict[str, str | bytes | int | tuple[int, ...]] = {"version": 3} + if self.mode == "1": + # Binary images need to be extended from bits to bytes + # See: https://github.com/python-pillow/Pillow/issues/350 + new["data"] = self.tobytes("raw", "L") + else: + new["data"] = self.tobytes() + new["shape"], new["typestr"] = _conv_type_shape(self) + return new + + def __arrow_c_schema__(self) -> object: + self.load() + return self.im.__arrow_c_schema__() + + def __arrow_c_array__( + self, requested_schema: object | None = None + ) -> tuple[object, object]: + self.load() + return (self.im.__arrow_c_schema__(), self.im.__arrow_c_array__()) + + def __getstate__(self) -> list[Any]: + im_data = self.tobytes() # load image first + return [self.info, self.mode, self.size, self.getpalette(), im_data] + + def __setstate__(self, state: list[Any]) -> None: + Image.__init__(self) + info, mode, size, palette, data = state[:5] + self.info = info + self._mode = mode + self._size = size + self.im = core.new(mode, size) + if mode in ("L", "LA", "P", "PA") and palette: + self.putpalette(palette) + self.frombytes(data) + + def tobytes(self, encoder_name: str = "raw", *args: Any) -> bytes: + """ + Return image as a bytes object. + + .. warning:: + + This method returns raw image data derived from Pillow's internal + storage. For compressed image data (e.g. PNG, JPEG) use + :meth:`~.save`, with a BytesIO parameter for in-memory data. + + :param encoder_name: What encoder to use. + + The default is to use the standard "raw" encoder. + To see how this packs pixel data into the returned + bytes, see :file:`libImaging/Pack.c`. + + A list of C encoders can be seen under codecs + section of the function array in + :file:`_imaging.c`. Python encoders are registered + within the relevant plugins. + :param args: Extra arguments to the encoder. + :returns: A :py:class:`bytes` object. + """ + + encoder_args: Any = args + if len(encoder_args) == 1 and isinstance(encoder_args[0], tuple): + # may pass tuple instead of argument list + encoder_args = encoder_args[0] + + if encoder_name == "raw" and encoder_args == (): + encoder_args = self.mode + + self.load() + + if self.width == 0 or self.height == 0: + return b"" + + # unpack data + e = _getencoder(self.mode, encoder_name, encoder_args) + e.setimage(self.im, (0, 0) + self.size) + + from . import ImageFile + + bufsize = max(ImageFile.MAXBLOCK, self.size[0] * 4) # see RawEncode.c + + output = [] + while True: + bytes_consumed, errcode, data = e.encode(bufsize) + output.append(data) + if errcode: + break + if errcode < 0: + msg = f"encoder error {errcode} in tobytes" + raise RuntimeError(msg) + + return b"".join(output) + + def tobitmap(self, name: str = "image") -> bytes: + """ + Returns the image converted to an X11 bitmap. + + .. note:: This method only works for mode "1" images. + + :param name: The name prefix to use for the bitmap variables. + :returns: A string containing an X11 bitmap. + :raises ValueError: If the mode is not "1" + """ + + self.load() + if self.mode != "1": + msg = "not a bitmap" + raise ValueError(msg) + data = self.tobytes("xbm") + return b"".join( + [ + f"#define {name}_width {self.size[0]}\n".encode("ascii"), + f"#define {name}_height {self.size[1]}\n".encode("ascii"), + f"static char {name}_bits[] = {{\n".encode("ascii"), + data, + b"};", + ] + ) + + def frombytes( + self, + data: bytes | bytearray | SupportsArrayInterface, + decoder_name: str = "raw", + *args: Any, + ) -> None: + """ + Loads this image with pixel data from a bytes object. + + This method is similar to the :py:func:`~PIL.Image.frombytes` function, + but loads data into this image instead of creating a new image object. + """ + + if self.width == 0 or self.height == 0: + return + + decoder_args: Any = args + if len(decoder_args) == 1 and isinstance(decoder_args[0], tuple): + # may pass tuple instead of argument list + decoder_args = decoder_args[0] + + # default format + if decoder_name == "raw" and decoder_args == (): + decoder_args = self.mode + + # unpack data + d = _getdecoder(self.mode, decoder_name, decoder_args) + d.setimage(self.im, (0, 0) + self.size) + s = d.decode(data) + + if s[0] >= 0: + msg = "not enough image data" + raise ValueError(msg) + if s[1] != 0: + msg = "cannot decode image data" + raise ValueError(msg) + + def load(self) -> core.PixelAccess | None: + """ + Allocates storage for the image and loads the pixel data. In + normal cases, you don't need to call this method, since the + Image class automatically loads an opened image when it is + accessed for the first time. + + If the file associated with the image was opened by Pillow, then this + method will close it. The exception to this is if the image has + multiple frames, in which case the file will be left open for seek + operations. See :ref:`file-handling` for more information. + + :returns: An image access object. + :rtype: :py:class:`.PixelAccess` + """ + if self._im is not None and self.palette and self.palette.dirty: + # realize palette + mode, arr = self.palette.getdata() + self.im.putpalette(self.palette.mode, mode, arr) + self.palette.dirty = 0 + self.palette.rawmode = None + if "transparency" in self.info and mode in ("LA", "PA"): + if isinstance(self.info["transparency"], int): + self.im.putpalettealpha(self.info["transparency"], 0) + else: + self.im.putpalettealphas(self.info["transparency"]) + self.palette.mode = "RGBA" + elif self.palette.mode != mode: + # If the palette rawmode is different to the mode, + # then update the Python palette data + self.palette.palette = self.im.getpalette( + self.palette.mode, self.palette.mode + ) + + if self._im is not None: + return self.im.pixel_access(self.readonly) + return None + + def verify(self) -> None: + """ + Verifies the contents of a file. For data read from a file, this + method attempts to determine if the file is broken, without + actually decoding the image data. If this method finds any + problems, it raises suitable exceptions. If you need to load + the image after using this method, you must reopen the image + file. + """ + pass + + def convert( + self, + mode: str | None = None, + matrix: tuple[float, ...] | None = None, + dither: Dither | None = None, + palette: Palette = Palette.WEB, + colors: int = 256, + ) -> Image: + """ + Returns a converted copy of this image. For the "P" mode, this + method translates pixels through the palette. If mode is + omitted, a mode is chosen so that all information in the image + and the palette can be represented without a palette. + + This supports all possible conversions between "L", "RGB" and "CMYK". The + ``matrix`` argument only supports "L" and "RGB". + + When translating a color image to grayscale (mode "L"), + the library uses the ITU-R 601-2 luma transform:: + + L = R * 299/1000 + G * 587/1000 + B * 114/1000 + + The default method of converting a grayscale ("L") or "RGB" + image into a bilevel (mode "1") image uses Floyd-Steinberg + dither to approximate the original image luminosity levels. If + dither is ``None``, all values larger than 127 are set to 255 (white), + all other values to 0 (black). To use other thresholds, use the + :py:meth:`~PIL.Image.Image.point` method. + + When converting from "RGBA" to "P" without a ``matrix`` argument, + this passes the operation to :py:meth:`~PIL.Image.Image.quantize`, + and ``dither`` and ``palette`` are ignored. + + When converting from "PA", if an "RGBA" palette is present, the alpha + channel from the image will be used instead of the values from the palette. + + :param mode: The requested mode. See: :ref:`concept-modes`. + :param matrix: An optional conversion matrix. If given, this + should be 4- or 12-tuple containing floating point values. + :param dither: Dithering method, used when converting from + mode "RGB" to "P" or from "RGB" or "L" to "1". + Available methods are :data:`Dither.NONE` or :data:`Dither.FLOYDSTEINBERG` + (default). Note that this is not used when ``matrix`` is supplied. + :param palette: Palette to use when converting from mode "RGB" + to "P". Available palettes are :data:`Palette.WEB` or + :data:`Palette.ADAPTIVE`. + :param colors: Number of colors to use for the :data:`Palette.ADAPTIVE` + palette. Defaults to 256. + :rtype: :py:class:`~PIL.Image.Image` + :returns: An :py:class:`~PIL.Image.Image` object. + """ + + self.load() + + has_transparency = "transparency" in self.info + if not mode and self.mode == "P": + # determine default mode + if self.palette: + mode = self.palette.mode + else: + mode = "RGB" + if mode == "RGB" and has_transparency: + mode = "RGBA" + if not mode or (mode == self.mode and not matrix): + return self.copy() + + if matrix: + # matrix conversion + if mode not in ("L", "RGB"): + msg = "illegal conversion" + raise ValueError(msg) + im = self.im.convert_matrix(mode, matrix) + new_im = self._new(im) + if has_transparency and self.im.bands == 3: + transparency = new_im.info["transparency"] + + def convert_transparency( + m: tuple[float, ...], v: tuple[int, int, int] + ) -> int: + value = m[0] * v[0] + m[1] * v[1] + m[2] * v[2] + m[3] * 0.5 + return max(0, min(255, int(value))) + + if mode == "L": + transparency = convert_transparency(matrix, transparency) + elif len(mode) == 3: + transparency = tuple( + convert_transparency(matrix[i * 4 : i * 4 + 4], transparency) + for i in range(len(transparency)) + ) + new_im.info["transparency"] = transparency + return new_im + + if self.mode == "RGBA": + if mode == "P": + return self.quantize(colors) + elif mode == "PA": + r, g, b, a = self.split() + rgb = merge("RGB", (r, g, b)) + p = rgb.quantize(colors) + return merge("PA", (p, a)) + + trns = None + delete_trns = False + # transparency handling + if has_transparency: + if (self.mode in ("1", "L", "I", "I;16") and mode in ("LA", "RGBA")) or ( + self.mode == "RGB" and mode in ("La", "LA", "RGBa", "RGBA") + ): + # Use transparent conversion to promote from transparent + # color to an alpha channel. + new_im = self._new( + self.im.convert_transparent(mode, self.info["transparency"]) + ) + del new_im.info["transparency"] + return new_im + elif self.mode in ("L", "RGB", "P") and mode in ("L", "RGB", "P"): + t = self.info["transparency"] + if isinstance(t, bytes): + # Dragons. This can't be represented by a single color + warnings.warn( + "Palette images with Transparency expressed in bytes should be " + "converted to RGBA images" + ) + delete_trns = True + else: + # get the new transparency color. + # use existing conversions + trns_im = new(self.mode, (1, 1)) + if self.mode == "P": + assert self.palette is not None + trns_im.putpalette(self.palette, self.palette.mode) + if isinstance(t, tuple): + err = "Couldn't allocate a palette color for transparency" + assert trns_im.palette is not None + try: + t = trns_im.palette.getcolor(t, self) + except ValueError as e: + if str(e) == "cannot allocate more than 256 colors": + # If all 256 colors are in use, + # then there is no need for transparency + t = None + else: + raise ValueError(err) from e + if t is None: + trns = None + else: + trns_im.putpixel((0, 0), t) + + if mode in ("L", "RGB"): + trns_im = trns_im.convert(mode) + else: + # can't just retrieve the palette number, got to do it + # after quantization. + trns_im = trns_im.convert("RGB") + trns = trns_im.getpixel((0, 0)) + + elif self.mode == "P" and mode in ("LA", "PA", "RGBA"): + t = self.info["transparency"] + delete_trns = True + + if isinstance(t, bytes): + self.im.putpalettealphas(t) + elif isinstance(t, int): + self.im.putpalettealpha(t, 0) + else: + msg = "Transparency for P mode should be bytes or int" + raise ValueError(msg) + + if mode == "P" and palette == Palette.ADAPTIVE: + im = self.im.quantize(colors) + new_im = self._new(im) + from . import ImagePalette + + new_im.palette = ImagePalette.ImagePalette( + "RGB", new_im.im.getpalette("RGB") + ) + if delete_trns: + # This could possibly happen if we requantize to fewer colors. + # The transparency would be totally off in that case. + del new_im.info["transparency"] + if trns is not None: + try: + new_im.info["transparency"] = new_im.palette.getcolor( + cast(tuple[int, ...], trns), # trns was converted to RGB + new_im, + ) + except Exception: + # if we can't make a transparent color, don't leave the old + # transparency hanging around to mess us up. + del new_im.info["transparency"] + warnings.warn("Couldn't allocate palette entry for transparency") + return new_im + + if "LAB" in (self.mode, mode): + im = self + if mode == "LAB": + if im.mode not in ("RGB", "RGBA", "RGBX"): + im = im.convert("RGBA") + other_mode = im.mode + else: + other_mode = mode + if other_mode in ("RGB", "RGBA", "RGBX"): + from . import ImageCms + + srgb = ImageCms.createProfile("sRGB") + lab = ImageCms.createProfile("LAB") + profiles = [lab, srgb] if im.mode == "LAB" else [srgb, lab] + transform = ImageCms.buildTransform( + profiles[0], profiles[1], im.mode, mode + ) + return transform.apply(im) + + # colorspace conversion + if dither is None: + dither = Dither.FLOYDSTEINBERG + + try: + im = self.im.convert(mode, dither) + except ValueError: + try: + # normalize source image and try again + modebase = getmodebase(self.mode) + if modebase == self.mode: + raise + im = self.im.convert(modebase) + im = im.convert(mode, dither) + except KeyError as e: + msg = "illegal conversion" + raise ValueError(msg) from e + + new_im = self._new(im) + if mode in ("P", "PA") and palette != Palette.ADAPTIVE: + from . import ImagePalette + + new_im.palette = ImagePalette.ImagePalette("RGB", im.getpalette("RGB")) + if delete_trns: + # crash fail if we leave a bytes transparency in an rgb/l mode. + del new_im.info["transparency"] + if trns is not None: + if new_im.mode == "P" and new_im.palette: + try: + new_im.info["transparency"] = new_im.palette.getcolor( + cast(tuple[int, ...], trns), new_im # trns was converted to RGB + ) + except ValueError as e: + del new_im.info["transparency"] + if str(e) != "cannot allocate more than 256 colors": + # If all 256 colors are in use, + # then there is no need for transparency + warnings.warn( + "Couldn't allocate palette entry for transparency" + ) + else: + new_im.info["transparency"] = trns + return new_im + + def quantize( + self, + colors: int = 256, + method: int | None = None, + kmeans: int = 0, + palette: Image | None = None, + dither: Dither = Dither.FLOYDSTEINBERG, + ) -> Image: + """ + Convert the image to 'P' mode with the specified number + of colors. + + :param colors: The desired number of colors, <= 256 + :param method: :data:`Quantize.MEDIANCUT` (median cut), + :data:`Quantize.MAXCOVERAGE` (maximum coverage), + :data:`Quantize.FASTOCTREE` (fast octree), + :data:`Quantize.LIBIMAGEQUANT` (libimagequant; check support + using :py:func:`PIL.features.check_feature` with + ``feature="libimagequant"``). + + By default, :data:`Quantize.MEDIANCUT` will be used. + + The exception to this is RGBA images. :data:`Quantize.MEDIANCUT` + and :data:`Quantize.MAXCOVERAGE` do not support RGBA images, so + :data:`Quantize.FASTOCTREE` is used by default instead. + :param kmeans: Integer greater than or equal to zero. + :param palette: Quantize to the palette of given + :py:class:`PIL.Image.Image`. + :param dither: Dithering method, used when converting from + mode "RGB" to "P" or from "RGB" or "L" to "1". + Available methods are :data:`Dither.NONE` or :data:`Dither.FLOYDSTEINBERG` + (default). + :returns: A new image + """ + + self.load() + + if method is None: + # defaults: + method = Quantize.MEDIANCUT + if self.mode == "RGBA": + method = Quantize.FASTOCTREE + + if self.mode == "RGBA" and method not in ( + Quantize.FASTOCTREE, + Quantize.LIBIMAGEQUANT, + ): + # Caller specified an invalid mode. + msg = ( + "Fast Octree (method == 2) and libimagequant (method == 3) " + "are the only valid methods for quantizing RGBA images" + ) + raise ValueError(msg) + + if palette: + # use palette from reference image + palette.load() + if palette.mode != "P": + msg = "bad mode for palette image" + raise ValueError(msg) + if self.mode not in {"RGB", "L"}: + msg = "only RGB or L mode images can be quantized to a palette" + raise ValueError(msg) + im = self.im.convert("P", dither, palette.im) + new_im = self._new(im) + assert palette.palette is not None + new_im.palette = palette.palette.copy() + return new_im + + if kmeans < 0: + msg = "kmeans must not be negative" + raise ValueError(msg) + + im = self._new(self.im.quantize(colors, method, kmeans)) + + from . import ImagePalette + + mode = im.im.getpalettemode() + palette_data = im.im.getpalette(mode, mode)[: colors * len(mode)] + im.palette = ImagePalette.ImagePalette(mode, palette_data) + + return im + + def copy(self) -> Image: + """ + Copies this image. Use this method if you wish to paste things + into an image, but still retain the original. + + :rtype: :py:class:`~PIL.Image.Image` + :returns: An :py:class:`~PIL.Image.Image` object. + """ + self.load() + return self._new(self.im.copy()) + + __copy__ = copy + + def crop(self, box: tuple[float, float, float, float] | None = None) -> Image: + """ + Returns a rectangular region from this image. The box is a + 4-tuple defining the left, upper, right, and lower pixel + coordinate. See :ref:`coordinate-system`. + + Note: Prior to Pillow 3.4.0, this was a lazy operation. + + :param box: The crop rectangle, as a (left, upper, right, lower)-tuple. + :rtype: :py:class:`~PIL.Image.Image` + :returns: An :py:class:`~PIL.Image.Image` object. + """ + + if box is None: + return self.copy() + + if box[2] < box[0]: + msg = "Coordinate 'right' is less than 'left'" + raise ValueError(msg) + elif box[3] < box[1]: + msg = "Coordinate 'lower' is less than 'upper'" + raise ValueError(msg) + + self.load() + return self._new(self._crop(self.im, box)) + + def _crop( + self, im: core.ImagingCore, box: tuple[float, float, float, float] + ) -> core.ImagingCore: + """ + Returns a rectangular region from the core image object im. + + This is equivalent to calling im.crop((x0, y0, x1, y1)), but + includes additional sanity checks. + + :param im: a core image object + :param box: The crop rectangle, as a (left, upper, right, lower)-tuple. + :returns: A core image object. + """ + + x0, y0, x1, y1 = map(int, map(round, box)) + + absolute_values = (abs(x1 - x0), abs(y1 - y0)) + + _decompression_bomb_check(absolute_values) + + return im.crop((x0, y0, x1, y1)) + + def draft( + self, mode: str | None, size: tuple[int, int] | None + ) -> tuple[str, tuple[int, int, float, float]] | None: + """ + Configures the image file loader so it returns a version of the + image that as closely as possible matches the given mode and + size. For example, you can use this method to convert a color + JPEG to grayscale while loading it. + + If any changes are made, returns a tuple with the chosen ``mode`` and + ``box`` with coordinates of the original image within the altered one. + + Note that this method modifies the :py:class:`~PIL.Image.Image` object + in place. If the image has already been loaded, this method has no + effect. + + Note: This method is not implemented for most images. It is + currently implemented only for JPEG and MPO images. + + :param mode: The requested mode. + :param size: The requested size in pixels, as a 2-tuple: + (width, height). + """ + pass + + def filter(self, filter: ImageFilter.Filter | type[ImageFilter.Filter]) -> Image: + """ + Filters this image using the given filter. For a list of + available filters, see the :py:mod:`~PIL.ImageFilter` module. + + :param filter: Filter kernel. + :returns: An :py:class:`~PIL.Image.Image` object.""" + + from . import ImageFilter + + self.load() + + if callable(filter): + filter = filter() + if not hasattr(filter, "filter"): + msg = "filter argument should be ImageFilter.Filter instance or class" + raise TypeError(msg) + + multiband = isinstance(filter, ImageFilter.MultibandFilter) + if self.im.bands == 1 or multiband: + return self._new(filter.filter(self.im)) + + ims = [ + self._new(filter.filter(self.im.getband(c))) for c in range(self.im.bands) + ] + return merge(self.mode, ims) + + def getbands(self) -> tuple[str, ...]: + """ + Returns a tuple containing the name of each band in this image. + For example, ``getbands`` on an RGB image returns ("R", "G", "B"). + + :returns: A tuple containing band names. + :rtype: tuple + """ + return ImageMode.getmode(self.mode).bands + + def getbbox(self, *, alpha_only: bool = True) -> tuple[int, int, int, int] | None: + """ + Calculates the bounding box of the non-zero regions in the + image. + + :param alpha_only: Optional flag, defaulting to ``True``. + If ``True`` and the image has an alpha channel, trim transparent pixels. + Otherwise, trim pixels when all channels are zero. + Keyword-only argument. + :returns: The bounding box is returned as a 4-tuple defining the + left, upper, right, and lower pixel coordinate. See + :ref:`coordinate-system`. If the image is completely empty, this + method returns None. + + """ + + self.load() + return self.im.getbbox(alpha_only) + + def getcolors( + self, maxcolors: int = 256 + ) -> list[tuple[int, tuple[int, ...]]] | list[tuple[int, float]] | None: + """ + Returns a list of colors used in this image. + + The colors will be in the image's mode. For example, an RGB image will + return a tuple of (red, green, blue) color values, and a P image will + return the index of the color in the palette. + + :param maxcolors: Maximum number of colors. If this number is + exceeded, this method returns None. The default limit is + 256 colors. + :returns: An unsorted list of (count, pixel) values. + """ + + self.load() + if self.mode in ("1", "L", "P"): + h = self.im.histogram() + out: list[tuple[int, float]] = [(h[i], i) for i in range(256) if h[i]] + if len(out) > maxcolors: + return None + return out + return self.im.getcolors(maxcolors) + + def getdata(self, band: int | None = None) -> core.ImagingCore: + """ + Returns the contents of this image as a sequence object + containing pixel values. The sequence object is flattened, so + that values for line one follow directly after the values of + line zero, and so on. + + Note that the sequence object returned by this method is an + internal PIL data type, which only supports certain sequence + operations. To convert it to an ordinary sequence (e.g. for + printing), use ``list(im.getdata())``. + + :param band: What band to return. The default is to return + all bands. To return a single band, pass in the index + value (e.g. 0 to get the "R" band from an "RGB" image). + :returns: A sequence-like object. + """ + deprecate("Image.Image.getdata", 14, "get_flattened_data") + + self.load() + if band is not None: + return self.im.getband(band) + return self.im # could be abused + + def get_flattened_data( + self, band: int | None = None + ) -> tuple[tuple[int, ...], ...] | tuple[float, ...]: + """ + Returns the contents of this image as a tuple containing pixel values. + The sequence object is flattened, so that values for line one follow + directly after the values of line zero, and so on. + + :param band: What band to return. The default is to return + all bands. To return a single band, pass in the index + value (e.g. 0 to get the "R" band from an "RGB" image). + :returns: A tuple containing pixel values. + """ + self.load() + if band is not None: + return tuple(self.im.getband(band)) + return tuple(self.im) + + def getextrema(self) -> tuple[float, float] | tuple[tuple[int, int], ...]: + """ + Gets the minimum and maximum pixel values for each band in + the image. + + :returns: For a single-band image, a 2-tuple containing the + minimum and maximum pixel value. For a multi-band image, + a tuple containing one 2-tuple for each band. + """ + + self.load() + if self.im.bands > 1: + return tuple(self.im.getband(i).getextrema() for i in range(self.im.bands)) + return self.im.getextrema() + + def getxmp(self) -> dict[str, Any]: + """ + Returns a dictionary containing the XMP tags. + Requires defusedxml to be installed. + + :returns: XMP tags in a dictionary. + """ + + def get_name(tag: str) -> str: + return re.sub("^{[^}]+}", "", tag) + + def get_value(element: Element) -> str | dict[str, Any] | None: + value: dict[str, Any] = {get_name(k): v for k, v in element.attrib.items()} + children = list(element) + if children: + for child in children: + name = get_name(child.tag) + child_value = get_value(child) + if name in value: + if not isinstance(value[name], list): + value[name] = [value[name]] + value[name].append(child_value) + else: + value[name] = child_value + elif value: + if element.text: + value["text"] = element.text + else: + return element.text + return value + + if ElementTree is None: + warnings.warn("XMP data cannot be read without defusedxml dependency") + return {} + if "xmp" not in self.info: + return {} + root = ElementTree.fromstring(self.info["xmp"].rstrip(b"\x00 ")) + return {get_name(root.tag): get_value(root)} + + def getexif(self) -> Exif: + """ + Gets EXIF data from the image. + + :returns: an :py:class:`~PIL.Image.Exif` object. + """ + if self._exif is None: + self._exif = Exif() + elif self._exif._loaded: + return self._exif + self._exif._loaded = True + + exif_info = self.info.get("exif") + if exif_info is None: + if "Raw profile type exif" in self.info: + exif_info = bytes.fromhex( + "".join(self.info["Raw profile type exif"].split("\n")[3:]) + ) + elif hasattr(self, "tag_v2"): + from . import TiffImagePlugin + + assert isinstance(self, TiffImagePlugin.TiffImageFile) + self._exif.bigtiff = self.tag_v2._bigtiff + self._exif.endian = self.tag_v2._endian + + assert self.fp is not None + self._exif.load_from_fp(self.fp, self.tag_v2._offset) + if exif_info is not None: + self._exif.load(exif_info) + + # XMP tags + if ExifTags.Base.Orientation not in self._exif: + xmp_tags = self.info.get("XML:com.adobe.xmp") + pattern: str | bytes = r'tiff:Orientation(="|>)([0-9])' + if not xmp_tags and (xmp_tags := self.info.get("xmp")): + pattern = rb'tiff:Orientation(="|>)([0-9])' + if xmp_tags: + match = re.search(pattern, xmp_tags) + if match: + self._exif[ExifTags.Base.Orientation] = int(match[2]) + + return self._exif + + def _reload_exif(self) -> None: + if self._exif is None or not self._exif._loaded: + return + self._exif._loaded = False + self.getexif() + + def get_child_images(self) -> list[ImageFile.ImageFile]: + from . import ImageFile + + deprecate("Image.Image.get_child_images", 13) + return ImageFile.ImageFile.get_child_images(self) # type: ignore[arg-type] + + def getim(self) -> CapsuleType: + """ + Returns a capsule that points to the internal image memory. + + :returns: A capsule object. + """ + + self.load() + return self.im.ptr + + def getpalette(self, rawmode: str | None = "RGB") -> list[int] | None: + """ + Returns the image palette as a list. + + :param rawmode: The mode in which to return the palette. ``None`` will + return the palette in its current mode. + + .. versionadded:: 9.1.0 + + :returns: A list of color values [r, g, b, ...], or None if the + image has no palette. + """ + + self.load() + try: + mode = self.im.getpalettemode() + except ValueError: + return None # no palette + if rawmode is None: + rawmode = mode + return list(self.im.getpalette(mode, rawmode)) + + @property + def has_transparency_data(self) -> bool: + """ + Determine if an image has transparency data, whether in the form of an + alpha channel, a palette with an alpha channel, or a "transparency" key + in the info dictionary. + + Note the image might still appear solid, if all of the values shown + within are opaque. + + :returns: A boolean. + """ + if ( + self.mode in ("LA", "La", "PA", "RGBA", "RGBa") + or "transparency" in self.info + ): + return True + if self.mode == "P": + assert self.palette is not None + return self.palette.mode.endswith("A") + return False + + def apply_transparency(self) -> None: + """ + If a P mode image has a "transparency" key in the info dictionary, + remove the key and instead apply the transparency to the palette. + Otherwise, the image is unchanged. + """ + if self.mode != "P" or "transparency" not in self.info: + return + + from . import ImagePalette + + palette = self.getpalette("RGBA") + assert palette is not None + transparency = self.info["transparency"] + if isinstance(transparency, bytes): + for i, alpha in enumerate(transparency): + palette[i * 4 + 3] = alpha + else: + palette[transparency * 4 + 3] = 0 + self.palette = ImagePalette.ImagePalette("RGBA", bytes(palette)) + self.palette.dirty = 1 + + del self.info["transparency"] + + def getpixel( + self, xy: tuple[int, int] | list[int] + ) -> float | tuple[int, ...] | None: + """ + Returns the pixel value at a given position. + + :param xy: The coordinate, given as (x, y). See + :ref:`coordinate-system`. + :returns: The pixel value. If the image is a multi-layer image, + this method returns a tuple. + """ + + self.load() + return self.im.getpixel(tuple(xy)) + + def getprojection(self) -> tuple[list[int], list[int]]: + """ + Get projection to x and y axes + + :returns: Two sequences, indicating where there are non-zero + pixels along the X-axis and the Y-axis, respectively. + """ + + self.load() + x, y = self.im.getprojection() + return list(x), list(y) + + def histogram( + self, mask: Image | None = None, extrema: tuple[float, float] | None = None + ) -> list[int]: + """ + Returns a histogram for the image. The histogram is returned as a + list of pixel counts, one for each pixel value in the source + image. Counts are grouped into 256 bins for each band, even if + the image has more than 8 bits per band. If the image has more + than one band, the histograms for all bands are concatenated (for + example, the histogram for an "RGB" image contains 768 values). + + A bilevel image (mode "1") is treated as a grayscale ("L") image + by this method. + + If a mask is provided, the method returns a histogram for those + parts of the image where the mask image is non-zero. The mask + image must have the same size as the image, and be either a + bi-level image (mode "1") or a grayscale image ("L"). + + :param mask: An optional mask. + :param extrema: An optional tuple of manually-specified extrema. + :returns: A list containing pixel counts. + """ + self.load() + if mask: + mask.load() + return self.im.histogram((0, 0), mask.im) + if self.mode in ("I", "F"): + return self.im.histogram( + extrema if extrema is not None else self.getextrema() + ) + return self.im.histogram() + + def entropy( + self, mask: Image | None = None, extrema: tuple[float, float] | None = None + ) -> float: + """ + Calculates and returns the entropy for the image. + + A bilevel image (mode "1") is treated as a grayscale ("L") + image by this method. + + If a mask is provided, the method employs the histogram for + those parts of the image where the mask image is non-zero. + The mask image must have the same size as the image, and be + either a bi-level image (mode "1") or a grayscale image ("L"). + + :param mask: An optional mask. + :param extrema: An optional tuple of manually-specified extrema. + :returns: A float value representing the image entropy + """ + self.load() + if mask: + mask.load() + return self.im.entropy((0, 0), mask.im) + if self.mode in ("I", "F"): + return self.im.entropy( + extrema if extrema is not None else self.getextrema() + ) + return self.im.entropy() + + def paste( + self, + im: Image | str | float | tuple[float, ...], + box: Image | tuple[int, int, int, int] | tuple[int, int] | None = None, + mask: Image | None = None, + ) -> None: + """ + Pastes another image into this image. The box argument is either + a 2-tuple giving the upper left corner, a 4-tuple defining the + left, upper, right, and lower pixel coordinate, or None (same as + (0, 0)). See :ref:`coordinate-system`. If a 4-tuple is given, the size + of the pasted image must match the size of the region. + + If the modes don't match, the pasted image is converted to the mode of + this image (see the :py:meth:`~PIL.Image.Image.convert` method for + details). + + Instead of an image, the source can be a integer or tuple + containing pixel values. The method then fills the region + with the given color. When creating RGB images, you can + also use color strings as supported by the ImageColor module. See + :ref:`colors` for more information. + + If a mask is given, this method updates only the regions + indicated by the mask. You can use either "1", "L", "LA", "RGBA" + or "RGBa" images (if present, the alpha band is used as mask). + Where the mask is 255, the given image is copied as is. Where + the mask is 0, the current value is preserved. Intermediate + values will mix the two images together, including their alpha + channels if they have them. + + See :py:meth:`~PIL.Image.Image.alpha_composite` if you want to + combine images with respect to their alpha channels. + + :param im: Source image or pixel value (integer, float or tuple). + :param box: An optional 4-tuple giving the region to paste into. + If a 2-tuple is used instead, it's treated as the upper left + corner. If omitted or None, the source is pasted into the + upper left corner. + + If an image is given as the second argument and there is no + third, the box defaults to (0, 0), and the second argument + is interpreted as a mask image. + :param mask: An optional mask image. + """ + + if isinstance(box, Image): + if mask is not None: + msg = "If using second argument as mask, third argument must be None" + raise ValueError(msg) + # abbreviated paste(im, mask) syntax + mask = box + box = None + + if box is None: + box = (0, 0) + + if len(box) == 2: + # upper left corner given; get size from image or mask + if isinstance(im, Image): + size = im.size + elif isinstance(mask, Image): + size = mask.size + else: + # FIXME: use self.size here? + msg = "cannot determine region size; use 4-item box" + raise ValueError(msg) + box += (box[0] + size[0], box[1] + size[1]) + + source: core.ImagingCore | str | float | tuple[float, ...] + if isinstance(im, str): + from . import ImageColor + + source = ImageColor.getcolor(im, self.mode) + elif isinstance(im, Image): + im.load() + if self.mode != im.mode: + if self.mode != "RGB" or im.mode not in ("LA", "RGBA", "RGBa"): + # should use an adapter for this! + im = im.convert(self.mode) + source = im.im + else: + source = im + + self._ensure_mutable() + + if mask: + mask.load() + self.im.paste(source, box, mask.im) + else: + self.im.paste(source, box) + + def alpha_composite( + self, im: Image, dest: Sequence[int] = (0, 0), source: Sequence[int] = (0, 0) + ) -> None: + """'In-place' analog of Image.alpha_composite. Composites an image + onto this image. + + :param im: image to composite over this one + :param dest: Optional 2 tuple (left, top) specifying the upper + left corner in this (destination) image. + :param source: Optional 2 (left, top) tuple for the upper left + corner in the overlay source image, or 4 tuple (left, top, right, + bottom) for the bounds of the source rectangle + + Performance Note: Not currently implemented in-place in the core layer. + """ + + if not isinstance(source, (list, tuple)): + msg = "Source must be a list or tuple" + raise ValueError(msg) + if not isinstance(dest, (list, tuple)): + msg = "Destination must be a list or tuple" + raise ValueError(msg) + + if len(source) == 4: + overlay_crop_box = tuple(source) + elif len(source) == 2: + overlay_crop_box = tuple(source) + im.size + else: + msg = "Source must be a sequence of length 2 or 4" + raise ValueError(msg) + + if not len(dest) == 2: + msg = "Destination must be a sequence of length 2" + raise ValueError(msg) + if min(source) < 0: + msg = "Source must be non-negative" + raise ValueError(msg) + + # over image, crop if it's not the whole image. + if overlay_crop_box == (0, 0) + im.size: + overlay = im + else: + overlay = im.crop(overlay_crop_box) + + # target for the paste + box = tuple(dest) + (dest[0] + overlay.width, dest[1] + overlay.height) + + # destination image. don't copy if we're using the whole image. + if box == (0, 0) + self.size: + background = self + else: + background = self.crop(box) + + result = alpha_composite(background, overlay) + self.paste(result, box) + + def point( + self, + lut: ( + Sequence[float] + | NumpyArray + | Callable[[int], float] + | Callable[[ImagePointTransform], ImagePointTransform | float] + | ImagePointHandler + ), + mode: str | None = None, + ) -> Image: + """ + Maps this image through a lookup table or function. + + :param lut: A lookup table, containing 256 (or 65536 if + self.mode=="I" and mode == "L") values per band in the + image. A function can be used instead, it should take a + single argument. The function is called once for each + possible pixel value, and the resulting table is applied to + all bands of the image. + + It may also be an :py:class:`~PIL.Image.ImagePointHandler` + object:: + + class Example(Image.ImagePointHandler): + def point(self, im: Image) -> Image: + # Return result + :param mode: Output mode (default is same as input). This can only be used if + the source image has mode "L" or "P", and the output has mode "1" or the + source image mode is "I" and the output mode is "L". + :returns: An :py:class:`~PIL.Image.Image` object. + """ + + self.load() + + if isinstance(lut, ImagePointHandler): + return lut.point(self) + + if callable(lut): + # if it isn't a list, it should be a function + if self.mode in ("I", "I;16", "F"): + # check if the function can be used with point_transform + # UNDONE wiredfool -- I think this prevents us from ever doing + # a gamma function point transform on > 8bit images. + scale, offset = _getscaleoffset(lut) # type: ignore[arg-type] + return self._new(self.im.point_transform(scale, offset)) + # for other modes, convert the function to a table + flatLut = [lut(i) for i in range(256)] * self.im.bands # type: ignore[arg-type] + else: + flatLut = lut + + if self.mode == "F": + # FIXME: _imaging returns a confusing error message for this case + msg = "point operation not supported for this mode" + raise ValueError(msg) + + if mode != "F": + flatLut = [round(i) for i in flatLut] + return self._new(self.im.point(flatLut, mode)) + + def putalpha(self, alpha: Image | int) -> None: + """ + Adds or replaces the alpha layer in this image. If the image + does not have an alpha layer, it's converted to "LA" or "RGBA". + The new layer must be either "L" or "1". + + :param alpha: The new alpha layer. This can either be an "L" or "1" + image having the same size as this image, or an integer. + """ + + self._ensure_mutable() + + if self.mode not in ("LA", "PA", "RGBA"): + # attempt to promote self to a matching alpha mode + try: + mode = getmodebase(self.mode) + "A" + try: + self.im.setmode(mode) + except (AttributeError, ValueError) as e: + # do things the hard way + im = self.im.convert(mode) + if im.mode not in ("LA", "PA", "RGBA"): + msg = "alpha channel could not be added" + raise ValueError(msg) from e # sanity check + self.im = im + self._mode = self.im.mode + except KeyError as e: + msg = "illegal image mode" + raise ValueError(msg) from e + + if self.mode in ("LA", "PA"): + band = 1 + else: + band = 3 + + if isinstance(alpha, Image): + # alpha layer + if alpha.mode not in ("1", "L"): + msg = "illegal image mode" + raise ValueError(msg) + alpha.load() + if alpha.mode == "1": + alpha = alpha.convert("L") + else: + # constant alpha + try: + self.im.fillband(band, alpha) + except (AttributeError, ValueError): + # do things the hard way + alpha = new("L", self.size, alpha) + else: + return + + self.im.putband(alpha.im, band) + + def putdata( + self, + data: Sequence[float] | Sequence[Sequence[int]] | core.ImagingCore | NumpyArray, + scale: float = 1.0, + offset: float = 0.0, + ) -> None: + """ + Copies pixel data from a flattened sequence object into the image. The + values should start at the upper left corner (0, 0), continue to the + end of the line, followed directly by the first value of the second + line, and so on. Data will be read until either the image or the + sequence ends. The scale and offset values are used to adjust the + sequence values: **pixel = value*scale + offset**. + + :param data: A flattened sequence object. See :ref:`colors` for more + information about values. + :param scale: An optional scale value. The default is 1.0. + :param offset: An optional offset value. The default is 0.0. + """ + + self._ensure_mutable() + + self.im.putdata(data, scale, offset) + + def putpalette( + self, + data: ImagePalette.ImagePalette | bytes | Sequence[int], + rawmode: str = "RGB", + ) -> None: + """ + Attaches a palette to this image. The image must be a "P", "PA", "L" + or "LA" image. + + The palette sequence must contain at most 256 colors, made up of one + integer value for each channel in the raw mode. + For example, if the raw mode is "RGB", then it can contain at most 768 + values, made up of red, green and blue values for the corresponding pixel + index in the 256 colors. + If the raw mode is "RGBA", then it can contain at most 1024 values, + containing red, green, blue and alpha values. + + Alternatively, an 8-bit string may be used instead of an integer sequence. + + :param data: A palette sequence (either a list or a string). + :param rawmode: The raw mode of the palette. Either "RGB", "RGBA", "CMYK", or a + mode that can be transformed to one of those modes (e.g. "R", "RGBA;L"). + """ + from . import ImagePalette + + if self.mode not in ("L", "LA", "P", "PA"): + msg = "illegal image mode" + raise ValueError(msg) + if isinstance(data, ImagePalette.ImagePalette): + if data.rawmode is not None: + palette = ImagePalette.raw(data.rawmode, data.palette) + else: + palette = ImagePalette.ImagePalette(palette=data.palette) + palette.dirty = 1 + else: + if not isinstance(data, bytes): + data = bytes(data) + palette = ImagePalette.raw(rawmode, data) + self._mode = "PA" if "A" in self.mode else "P" + self.palette = palette + if rawmode.startswith("CMYK"): + self.palette.mode = "CMYK" + elif "A" in rawmode: + self.palette.mode = "RGBA" + else: + self.palette.mode = "RGB" + self.load() # install new palette + + def putpixel( + self, xy: tuple[int, int], value: float | tuple[int, ...] | list[int] + ) -> None: + """ + Modifies the pixel at the given position. The color is given as + a single numerical value for single-band images, and a tuple for + multi-band images. In addition to this, RGB and RGBA tuples are + accepted for P and PA images. See :ref:`colors` for more information. + + Note that this method is relatively slow. For more extensive changes, + use :py:meth:`~PIL.Image.Image.paste` or the :py:mod:`~PIL.ImageDraw` + module instead. + + See: + + * :py:meth:`~PIL.Image.Image.paste` + * :py:meth:`~PIL.Image.Image.putdata` + * :py:mod:`~PIL.ImageDraw` + + :param xy: The pixel coordinate, given as (x, y). See + :ref:`coordinate-system`. + :param value: The pixel value. + """ + + self._ensure_mutable() + + if ( + self.mode in ("P", "PA") + and isinstance(value, (list, tuple)) + and len(value) in [3, 4] + ): + # RGB or RGBA value for a P or PA image + if self.mode == "PA": + alpha = value[3] if len(value) == 4 else 255 + value = value[:3] + assert self.palette is not None + palette_index = self.palette.getcolor(tuple(value), self) + value = (palette_index, alpha) if self.mode == "PA" else palette_index + return self.im.putpixel(xy, value) + + def remap_palette( + self, dest_map: list[int], source_palette: bytes | bytearray | None = None + ) -> Image: + """ + Rewrites the image to reorder the palette. + + :param dest_map: A list of indexes into the original palette. + e.g. ``[1,0]`` would swap a two item palette, and ``list(range(256))`` + is the identity transform. + :param source_palette: Bytes or None. + :returns: An :py:class:`~PIL.Image.Image` object. + + """ + from . import ImagePalette + + if self.mode not in ("L", "P"): + msg = "illegal image mode" + raise ValueError(msg) + + bands = 3 + palette_mode = "RGB" + if source_palette is None: + if self.mode == "P": + self.load() + palette_mode = self.im.getpalettemode() + if palette_mode == "RGBA": + bands = 4 + source_palette = self.im.getpalette(palette_mode, palette_mode) + else: # L-mode + source_palette = bytearray(i // 3 for i in range(768)) + elif len(source_palette) > 768: + bands = 4 + palette_mode = "RGBA" + + palette_bytes = b"" + new_positions = [0] * 256 + + # pick only the used colors from the palette + for i, oldPosition in enumerate(dest_map): + palette_bytes += source_palette[ + oldPosition * bands : oldPosition * bands + bands + ] + new_positions[oldPosition] = i + + # replace the palette color id of all pixel with the new id + + # Palette images are [0..255], mapped through a 1 or 3 + # byte/color map. We need to remap the whole image + # from palette 1 to palette 2. New_positions is + # an array of indexes into palette 1. Palette 2 is + # palette 1 with any holes removed. + + # We're going to leverage the convert mechanism to use the + # C code to remap the image from palette 1 to palette 2, + # by forcing the source image into 'L' mode and adding a + # mapping 'L' mode palette, then converting back to 'L' + # sans palette thus converting the image bytes, then + # assigning the optimized RGB palette. + + # perf reference, 9500x4000 gif, w/~135 colors + # 14 sec prepatch, 1 sec postpatch with optimization forced. + + mapping_palette = bytearray(new_positions) + + m_im = self.copy() + m_im._mode = "P" + + m_im.palette = ImagePalette.ImagePalette( + palette_mode, palette=mapping_palette * bands + ) + # possibly set palette dirty, then + # m_im.putpalette(mapping_palette, 'L') # converts to 'P' + # or just force it. + # UNDONE -- this is part of the general issue with palettes + m_im.im.putpalette(palette_mode, palette_mode + ";L", m_im.palette.tobytes()) + + m_im = m_im.convert("L") + + m_im.putpalette(palette_bytes, palette_mode) + m_im.palette = ImagePalette.ImagePalette(palette_mode, palette=palette_bytes) + + if "transparency" in self.info: + try: + m_im.info["transparency"] = dest_map.index(self.info["transparency"]) + except ValueError: + if "transparency" in m_im.info: + del m_im.info["transparency"] + + return m_im + + def _get_safe_box( + self, + size: tuple[int, int], + resample: Resampling, + box: tuple[float, float, float, float], + ) -> tuple[int, int, int, int]: + """Expands the box so it includes adjacent pixels + that may be used by resampling with the given resampling filter. + """ + filter_support = _filters_support[resample] - 0.5 + scale_x = (box[2] - box[0]) / size[0] + scale_y = (box[3] - box[1]) / size[1] + support_x = filter_support * scale_x + support_y = filter_support * scale_y + + return ( + max(0, int(box[0] - support_x)), + max(0, int(box[1] - support_y)), + min(self.size[0], math.ceil(box[2] + support_x)), + min(self.size[1], math.ceil(box[3] + support_y)), + ) + + def resize( + self, + size: tuple[int, int] | list[int] | NumpyArray, + resample: int | None = None, + box: tuple[float, float, float, float] | None = None, + reducing_gap: float | None = None, + ) -> Image: + """ + Returns a resized copy of this image. + + :param size: The requested size in pixels, as a tuple or array: + (width, height). + :param resample: An optional resampling filter. This can be + one of :py:data:`Resampling.NEAREST`, :py:data:`Resampling.BOX`, + :py:data:`Resampling.BILINEAR`, :py:data:`Resampling.HAMMING`, + :py:data:`Resampling.BICUBIC` or :py:data:`Resampling.LANCZOS`. + If the image has mode "1" or "P", it is always set to + :py:data:`Resampling.NEAREST`. Otherwise, the default filter is + :py:data:`Resampling.BICUBIC`. See: :ref:`concept-filters`. + :param box: An optional 4-tuple of floats providing + the source image region to be scaled. + The values must be within (0, 0, width, height) rectangle. + If omitted or None, the entire source is used. + :param reducing_gap: Apply optimization by resizing the image + in two steps. First, reducing the image by integer times + using :py:meth:`~PIL.Image.Image.reduce`. + Second, resizing using regular resampling. The last step + changes size no less than by ``reducing_gap`` times. + ``reducing_gap`` may be None (no first step is performed) + or should be greater than 1.0. The bigger ``reducing_gap``, + the closer the result to the fair resampling. + The smaller ``reducing_gap``, the faster resizing. + With ``reducing_gap`` greater or equal to 3.0, the result is + indistinguishable from fair resampling in most cases. + The default value is None (no optimization). + :returns: An :py:class:`~PIL.Image.Image` object. + """ + + if resample is None: + resample = Resampling.BICUBIC + elif resample not in ( + Resampling.NEAREST, + Resampling.BILINEAR, + Resampling.BICUBIC, + Resampling.LANCZOS, + Resampling.BOX, + Resampling.HAMMING, + ): + msg = f"Unknown resampling filter ({resample})." + + filters = [ + f"{filter[1]} ({filter[0]})" + for filter in ( + (Resampling.NEAREST, "Image.Resampling.NEAREST"), + (Resampling.LANCZOS, "Image.Resampling.LANCZOS"), + (Resampling.BILINEAR, "Image.Resampling.BILINEAR"), + (Resampling.BICUBIC, "Image.Resampling.BICUBIC"), + (Resampling.BOX, "Image.Resampling.BOX"), + (Resampling.HAMMING, "Image.Resampling.HAMMING"), + ) + ] + msg += f" Use {', '.join(filters[:-1])} or {filters[-1]}" + raise ValueError(msg) + + if reducing_gap is not None and reducing_gap < 1.0: + msg = "reducing_gap must be 1.0 or greater" + raise ValueError(msg) + + if box is None: + box = (0, 0) + self.size + + size = tuple(size) + if self.size == size and box == (0, 0) + self.size: + return self.copy() + + if self.mode in ("1", "P"): + resample = Resampling.NEAREST + + if self.mode in ["LA", "RGBA"] and resample != Resampling.NEAREST: + im = self.convert({"LA": "La", "RGBA": "RGBa"}[self.mode]) + im = im.resize(size, resample, box) + return im.convert(self.mode) + + self.load() + + if reducing_gap is not None and resample != Resampling.NEAREST: + factor_x = int((box[2] - box[0]) / size[0] / reducing_gap) or 1 + factor_y = int((box[3] - box[1]) / size[1] / reducing_gap) or 1 + if factor_x > 1 or factor_y > 1: + reduce_box = self._get_safe_box(size, cast(Resampling, resample), box) + factor = (factor_x, factor_y) + self = ( + self.reduce(factor, box=reduce_box) + if callable(self.reduce) + else Image.reduce(self, factor, box=reduce_box) + ) + box = ( + (box[0] - reduce_box[0]) / factor_x, + (box[1] - reduce_box[1]) / factor_y, + (box[2] - reduce_box[0]) / factor_x, + (box[3] - reduce_box[1]) / factor_y, + ) + + if self.size[1] > self.size[0] * 100 and size[1] < self.size[1]: + im = self.im.resize( + (self.size[0], size[1]), resample, (0, box[1], self.size[0], box[3]) + ) + im = im.resize(size, resample, (box[0], 0, box[2], size[1])) + else: + im = self.im.resize(size, resample, box) + return self._new(im) + + def reduce( + self, + factor: int | tuple[int, int], + box: tuple[int, int, int, int] | None = None, + ) -> Image: + """ + Returns a copy of the image reduced ``factor`` times. + If the size of the image is not dividable by ``factor``, + the resulting size will be rounded up. + + :param factor: A greater than 0 integer or tuple of two integers + for width and height separately. + :param box: An optional 4-tuple of ints providing + the source image region to be reduced. + The values must be within ``(0, 0, width, height)`` rectangle. + If omitted or ``None``, the entire source is used. + """ + if not isinstance(factor, (list, tuple)): + factor = (factor, factor) + + if box is None: + box = (0, 0) + self.size + + if factor == (1, 1) and box == (0, 0) + self.size: + return self.copy() + + if self.mode in ["LA", "RGBA"]: + im = self.convert({"LA": "La", "RGBA": "RGBa"}[self.mode]) + im = im.reduce(factor, box) + return im.convert(self.mode) + + self.load() + + return self._new(self.im.reduce(factor, box)) + + def rotate( + self, + angle: float, + resample: Resampling = Resampling.NEAREST, + expand: int | bool = False, + center: tuple[float, float] | None = None, + translate: tuple[int, int] | None = None, + fillcolor: float | tuple[float, ...] | str | None = None, + ) -> Image: + """ + Returns a rotated copy of this image. This method returns a + copy of this image, rotated the given number of degrees counter + clockwise around its centre. + + :param angle: In degrees counter clockwise. + :param resample: An optional resampling filter. This can be + one of :py:data:`Resampling.NEAREST` (use nearest neighbour), + :py:data:`Resampling.BILINEAR` (linear interpolation in a 2x2 + environment), or :py:data:`Resampling.BICUBIC` (cubic spline + interpolation in a 4x4 environment). If omitted, or if the image has + mode "1" or "P", it is set to :py:data:`Resampling.NEAREST`. + See :ref:`concept-filters`. + :param expand: Optional expansion flag. If true, expands the output + image to make it large enough to hold the entire rotated image. + If false or omitted, make the output image the same size as the + input image. Note that the expand flag assumes rotation around + the center and no translation. + :param center: Optional center of rotation (a 2-tuple). Origin is + the upper left corner. Default is the center of the image. + :param translate: An optional post-rotate translation (a 2-tuple). + :param fillcolor: An optional color for area outside the rotated image. + :returns: An :py:class:`~PIL.Image.Image` object. + """ + + angle = angle % 360.0 + + # Fast paths regardless of filter, as long as we're not + # translating or changing the center. + if not (center or translate): + if angle == 0: + return self.copy() + if angle == 180: + return self.transpose(Transpose.ROTATE_180) + if angle in (90, 270) and (expand or self.width == self.height): + return self.transpose( + Transpose.ROTATE_90 if angle == 90 else Transpose.ROTATE_270 + ) + + # Calculate the affine matrix. Note that this is the reverse + # transformation (from destination image to source) because we + # want to interpolate the (discrete) destination pixel from + # the local area around the (floating) source pixel. + + # The matrix we actually want (note that it operates from the right): + # (1, 0, tx) (1, 0, cx) ( cos a, sin a, 0) (1, 0, -cx) + # (0, 1, ty) * (0, 1, cy) * (-sin a, cos a, 0) * (0, 1, -cy) + # (0, 0, 1) (0, 0, 1) ( 0, 0, 1) (0, 0, 1) + + # The reverse matrix is thus: + # (1, 0, cx) ( cos -a, sin -a, 0) (1, 0, -cx) (1, 0, -tx) + # (0, 1, cy) * (-sin -a, cos -a, 0) * (0, 1, -cy) * (0, 1, -ty) + # (0, 0, 1) ( 0, 0, 1) (0, 0, 1) (0, 0, 1) + + # In any case, the final translation may be updated at the end to + # compensate for the expand flag. + + w, h = self.size + + if translate is None: + post_trans = (0, 0) + else: + post_trans = translate + if center is None: + center = (w / 2, h / 2) + + angle = -math.radians(angle) + matrix = [ + round(math.cos(angle), 15), + round(math.sin(angle), 15), + 0.0, + round(-math.sin(angle), 15), + round(math.cos(angle), 15), + 0.0, + ] + + def transform(x: float, y: float, matrix: list[float]) -> tuple[float, float]: + a, b, c, d, e, f = matrix + return a * x + b * y + c, d * x + e * y + f + + matrix[2], matrix[5] = transform( + -center[0] - post_trans[0], -center[1] - post_trans[1], matrix + ) + matrix[2] += center[0] + matrix[5] += center[1] + + if expand: + # calculate output size + xx = [] + yy = [] + for x, y in ((0, 0), (w, 0), (w, h), (0, h)): + transformed_x, transformed_y = transform(x, y, matrix) + xx.append(transformed_x) + yy.append(transformed_y) + nw = math.ceil(max(xx)) - math.floor(min(xx)) + nh = math.ceil(max(yy)) - math.floor(min(yy)) + + # We multiply a translation matrix from the right. Because of its + # special form, this is the same as taking the image of the + # translation vector as new translation vector. + matrix[2], matrix[5] = transform(-(nw - w) / 2.0, -(nh - h) / 2.0, matrix) + w, h = nw, nh + + return self.transform( + (w, h), Transform.AFFINE, matrix, resample, fillcolor=fillcolor + ) + + def save( + self, fp: StrOrBytesPath | IO[bytes], format: str | None = None, **params: Any + ) -> None: + """ + Saves this image under the given filename. If no format is + specified, the format to use is determined from the filename + extension, if possible. + + Keyword options can be used to provide additional instructions + to the writer. If a writer doesn't recognise an option, it is + silently ignored. The available options are described in the + :doc:`image format documentation + <../handbook/image-file-formats>` for each writer. + + You can use a file object instead of a filename. In this case, + you must always specify the format. The file object must + implement the ``seek``, ``tell``, and ``write`` + methods, and be opened in binary mode. + + :param fp: A filename (string), os.PathLike object or file object. + :param format: Optional format override. If omitted, the + format to use is determined from the filename extension. + If a file object was used instead of a filename, this + parameter should always be used. + :param params: Extra parameters to the image writer. These can also be + set on the image itself through ``encoderinfo``. This is useful when + saving multiple images:: + + # Saving XMP data to a single image + from PIL import Image + red = Image.new("RGB", (1, 1), "#f00") + red.save("out.mpo", xmp=b"test") + + # Saving XMP data to the second frame of an image + from PIL import Image + black = Image.new("RGB", (1, 1)) + red = Image.new("RGB", (1, 1), "#f00") + red.encoderinfo = {"xmp": b"test"} + black.save("out.mpo", save_all=True, append_images=[red]) + :returns: None + :exception ValueError: If the output format could not be determined + from the file name. Use the format option to solve this. + :exception OSError: If the file could not be written. The file + may have been created, and may contain partial data. + """ + + filename: str | bytes = "" + open_fp = False + if is_path(fp): + filename = os.fspath(fp) + open_fp = True + elif fp == sys.stdout: + try: + fp = sys.stdout.buffer + except AttributeError: + pass + if not filename and hasattr(fp, "name") and is_path(fp.name): + # only set the name for metadata purposes + filename = os.fspath(fp.name) + + if format: + preinit() + else: + filename_ext = os.path.splitext(filename)[1].lower() + ext = ( + filename_ext.decode() + if isinstance(filename_ext, bytes) + else filename_ext + ) + + # Try importing only the plugin for this extension first + if not _import_plugin_for_extension(ext): + preinit() + + if ext not in EXTENSION: + init() + try: + format = EXTENSION[ext] + except KeyError as e: + msg = f"unknown file extension: {ext}" + raise ValueError(msg) from e + + from . import ImageFile + + # may mutate self! + if isinstance(self, ImageFile.ImageFile) and os.path.abspath( + filename + ) == os.path.abspath(self.filename): + self._ensure_mutable() + else: + self.load() + + save_all = params.pop("save_all", None) + self._default_encoderinfo = params + encoderinfo = getattr(self, "encoderinfo", {}) + self._attach_default_encoderinfo(self) + self.encoderconfig: tuple[Any, ...] = () + + if format.upper() not in SAVE: + init() + if save_all or ( + save_all is None + and params.get("append_images") + and format.upper() in SAVE_ALL + ): + save_handler = SAVE_ALL[format.upper()] + else: + save_handler = SAVE[format.upper()] + + created = False + if open_fp: + created = not os.path.exists(filename) + if params.get("append", False): + # Open also for reading ("+"), because TIFF save_all + # writer needs to go back and edit the written data. + fp = builtins.open(filename, "r+b") + else: + fp = builtins.open(filename, "w+b") + else: + fp = cast(IO[bytes], fp) + + try: + save_handler(self, fp, filename) + except Exception: + if open_fp: + fp.close() + if created: + try: + os.remove(filename) + except PermissionError: + pass + raise + finally: + self.encoderinfo = encoderinfo + if open_fp: + fp.close() + + def _attach_default_encoderinfo(self, im: Image) -> dict[str, Any]: + encoderinfo = getattr(self, "encoderinfo", {}) + self.encoderinfo = {**im._default_encoderinfo, **encoderinfo} + return encoderinfo + + def seek(self, frame: int) -> None: + """ + Seeks to the given frame in this sequence file. If you seek + beyond the end of the sequence, the method raises an + ``EOFError`` exception. When a sequence file is opened, the + library automatically seeks to frame 0. + + See :py:meth:`~PIL.Image.Image.tell`. + + If defined, :attr:`~PIL.Image.Image.n_frames` refers to the + number of available frames. + + :param frame: Frame number, starting at 0. + :exception EOFError: If the call attempts to seek beyond the end + of the sequence. + """ + + # overridden by file handlers + if frame != 0: + msg = "no more images in file" + raise EOFError(msg) + + def show(self, title: str | None = None) -> None: + """ + Displays this image. This method is mainly intended for debugging purposes. + + This method calls :py:func:`PIL.ImageShow.show` internally. You can use + :py:func:`PIL.ImageShow.register` to override its default behaviour. + + The image is first saved to a temporary file. By default, it will be in + PNG format. + + On Unix, the image is then opened using the **xdg-open**, **display**, + **gm**, **eog** or **xv** utility, depending on which one can be found. + + On macOS, the image is opened with the native Preview application. + + On Windows, the image is opened with the standard PNG display utility. + + :param title: Optional title to use for the image window, where possible. + """ + + from . import ImageShow + + ImageShow.show(self, title) + + def split(self) -> tuple[Image, ...]: + """ + Split this image into individual bands. This method returns a + tuple of individual image bands from an image. For example, + splitting an "RGB" image creates three new images each + containing a copy of one of the original bands (red, green, + blue). + + If you need only one band, :py:meth:`~PIL.Image.Image.getchannel` + method can be more convenient and faster. + + :returns: A tuple containing bands. + """ + + self.load() + if self.im.bands == 1: + return (self.copy(),) + return tuple(map(self._new, self.im.split())) + + def getchannel(self, channel: int | str) -> Image: + """ + Returns an image containing a single channel of the source image. + + :param channel: What channel to return. Could be index + (0 for "R" channel of "RGB") or channel name + ("A" for alpha channel of "RGBA"). + :returns: An image in "L" mode. + + .. versionadded:: 4.3.0 + """ + self.load() + + if isinstance(channel, str): + try: + channel = self.getbands().index(channel) + except ValueError as e: + msg = f'The image has no channel "{channel}"' + raise ValueError(msg) from e + + return self._new(self.im.getband(channel)) + + def tell(self) -> int: + """ + Returns the current frame number. See :py:meth:`~PIL.Image.Image.seek`. + + If defined, :attr:`~PIL.Image.Image.n_frames` refers to the + number of available frames. + + :returns: Frame number, starting with 0. + """ + return 0 + + def thumbnail( + self, + size: tuple[float, float], + resample: Resampling = Resampling.BICUBIC, + reducing_gap: float | None = 2.0, + ) -> None: + """ + Make this image into a thumbnail. This method modifies the + image to contain a thumbnail version of itself, no larger than + the given size. This method calculates an appropriate thumbnail + size to preserve the aspect of the image, calls the + :py:meth:`~PIL.Image.Image.draft` method to configure the file reader + (where applicable), and finally resizes the image. + + Note that this function modifies the :py:class:`~PIL.Image.Image` + object in place. If you need to use the full resolution image as well, + apply this method to a :py:meth:`~PIL.Image.Image.copy` of the original + image. + + :param size: The requested size in pixels, as a 2-tuple: + (width, height). + :param resample: Optional resampling filter. This can be one + of :py:data:`Resampling.NEAREST`, :py:data:`Resampling.BOX`, + :py:data:`Resampling.BILINEAR`, :py:data:`Resampling.HAMMING`, + :py:data:`Resampling.BICUBIC` or :py:data:`Resampling.LANCZOS`. + If omitted, it defaults to :py:data:`Resampling.BICUBIC`. + (was :py:data:`Resampling.NEAREST` prior to version 2.5.0). + See: :ref:`concept-filters`. + :param reducing_gap: Apply optimization by resizing the image + in two steps. First, reducing the image by integer times + using :py:meth:`~PIL.Image.Image.reduce` or + :py:meth:`~PIL.Image.Image.draft` for JPEG images. + Second, resizing using regular resampling. The last step + changes size no less than by ``reducing_gap`` times. + ``reducing_gap`` may be None (no first step is performed) + or should be greater than 1.0. The bigger ``reducing_gap``, + the closer the result to the fair resampling. + The smaller ``reducing_gap``, the faster resizing. + With ``reducing_gap`` greater or equal to 3.0, the result is + indistinguishable from fair resampling in most cases. + The default value is 2.0 (very close to fair resampling + while still being faster in many cases). + :returns: None + """ + + provided_size = tuple(map(math.floor, size)) + + def preserve_aspect_ratio() -> tuple[int, int] | None: + def round_aspect(number: float, key: Callable[[int], float]) -> int: + return max(min(math.floor(number), math.ceil(number), key=key), 1) + + x, y = provided_size + if x >= self.width and y >= self.height: + return None + + aspect = self.width / self.height + if x / y >= aspect: + x = round_aspect(y * aspect, key=lambda n: abs(aspect - n / y)) + else: + y = round_aspect( + x / aspect, key=lambda n: 0 if n == 0 else abs(aspect - x / n) + ) + return x, y + + preserved_size = preserve_aspect_ratio() + if preserved_size is None: + return + final_size = preserved_size + + box = None + if reducing_gap is not None: + res = self.draft( + None, (int(size[0] * reducing_gap), int(size[1] * reducing_gap)) + ) + if res is not None: + box = res[1] + + if self.size != final_size: + im = self.resize(final_size, resample, box=box, reducing_gap=reducing_gap) + + self.im = im.im + self._size = final_size + self._mode = self.im.mode + + self.readonly = 0 + + # FIXME: the different transform methods need further explanation + # instead of bloating the method docs, add a separate chapter. + def transform( + self, + size: tuple[int, int], + method: Transform | ImageTransformHandler | SupportsGetData, + data: Sequence[Any] | None = None, + resample: int = Resampling.NEAREST, + fill: int = 1, + fillcolor: float | tuple[float, ...] | str | None = None, + ) -> Image: + """ + Transforms this image. This method creates a new image with the + given size, and the same mode as the original, and copies data + to the new image using the given transform. + + :param size: The output size in pixels, as a 2-tuple: + (width, height). + :param method: The transformation method. This is one of + :py:data:`Transform.EXTENT` (cut out a rectangular subregion), + :py:data:`Transform.AFFINE` (affine transform), + :py:data:`Transform.PERSPECTIVE` (perspective transform), + :py:data:`Transform.QUAD` (map a quadrilateral to a rectangle), or + :py:data:`Transform.MESH` (map a number of source quadrilaterals + in one operation). + + It may also be an :py:class:`~PIL.Image.ImageTransformHandler` + object:: + + class Example(Image.ImageTransformHandler): + def transform(self, size, data, resample, fill=1): + # Return result + + Implementations of :py:class:`~PIL.Image.ImageTransformHandler` + for some of the :py:class:`Transform` methods are provided + in :py:mod:`~PIL.ImageTransform`. + + It may also be an object with a ``method.getdata`` method + that returns a tuple supplying new ``method`` and ``data`` values:: + + class Example: + def getdata(self): + method = Image.Transform.EXTENT + data = (0, 0, 100, 100) + return method, data + :param data: Extra data to the transformation method. + :param resample: Optional resampling filter. It can be one of + :py:data:`Resampling.NEAREST` (use nearest neighbour), + :py:data:`Resampling.BILINEAR` (linear interpolation in a 2x2 + environment), or :py:data:`Resampling.BICUBIC` (cubic spline + interpolation in a 4x4 environment). If omitted, or if the image + has mode "1" or "P", it is set to :py:data:`Resampling.NEAREST`. + See: :ref:`concept-filters`. + :param fill: If ``method`` is an + :py:class:`~PIL.Image.ImageTransformHandler` object, this is one of + the arguments passed to it. Otherwise, it is unused. + :param fillcolor: Optional fill color for the area outside the + transform in the output image. + :returns: An :py:class:`~PIL.Image.Image` object. + """ + + if self.mode in ("LA", "RGBA") and resample != Resampling.NEAREST: + return ( + self.convert({"LA": "La", "RGBA": "RGBa"}[self.mode]) + .transform(size, method, data, resample, fill, fillcolor) + .convert(self.mode) + ) + + if isinstance(method, ImageTransformHandler): + return method.transform(size, self, resample=resample, fill=fill) + + if hasattr(method, "getdata"): + # compatibility w. old-style transform objects + method, data = method.getdata() + + if data is None: + msg = "missing method data" + raise ValueError(msg) + + im = new(self.mode, size, fillcolor) + if self.mode == "P" and self.palette: + im.palette = self.palette.copy() + im.info = self.info.copy() + if method == Transform.MESH: + # list of quads + for box, quad in data: + im.__transformer( + box, self, Transform.QUAD, quad, resample, fillcolor is None + ) + else: + im.__transformer( + (0, 0) + size, self, method, data, resample, fillcolor is None + ) + + return im + + def __transformer( + self, + box: tuple[int, int, int, int], + image: Image, + method: Transform, + data: Sequence[float], + resample: int = Resampling.NEAREST, + fill: bool = True, + ) -> None: + w = box[2] - box[0] + h = box[3] - box[1] + + if method == Transform.AFFINE: + data = data[:6] + + elif method == Transform.EXTENT: + # convert extent to an affine transform + x0, y0, x1, y1 = data + xs = (x1 - x0) / w + ys = (y1 - y0) / h + method = Transform.AFFINE + data = (xs, 0, x0, 0, ys, y0) + + elif method == Transform.PERSPECTIVE: + data = data[:8] + + elif method == Transform.QUAD: + # quadrilateral warp. data specifies the four corners + # given as NW, SW, SE, and NE. + nw = data[:2] + sw = data[2:4] + se = data[4:6] + ne = data[6:8] + x0, y0 = nw + As = 1.0 / w + At = 1.0 / h + data = ( + x0, + (ne[0] - x0) * As, + (sw[0] - x0) * At, + (se[0] - sw[0] - ne[0] + x0) * As * At, + y0, + (ne[1] - y0) * As, + (sw[1] - y0) * At, + (se[1] - sw[1] - ne[1] + y0) * As * At, + ) + + else: + msg = "unknown transformation method" + raise ValueError(msg) + + if resample not in ( + Resampling.NEAREST, + Resampling.BILINEAR, + Resampling.BICUBIC, + ): + if resample in (Resampling.BOX, Resampling.HAMMING, Resampling.LANCZOS): + unusable: dict[int, str] = { + Resampling.BOX: "Image.Resampling.BOX", + Resampling.HAMMING: "Image.Resampling.HAMMING", + Resampling.LANCZOS: "Image.Resampling.LANCZOS", + } + msg = unusable[resample] + f" ({resample}) cannot be used." + else: + msg = f"Unknown resampling filter ({resample})." + + filters = [ + f"{filter[1]} ({filter[0]})" + for filter in ( + (Resampling.NEAREST, "Image.Resampling.NEAREST"), + (Resampling.BILINEAR, "Image.Resampling.BILINEAR"), + (Resampling.BICUBIC, "Image.Resampling.BICUBIC"), + ) + ] + msg += f" Use {', '.join(filters[:-1])} or {filters[-1]}" + raise ValueError(msg) + + image.load() + + self.load() + + if image.mode in ("1", "P"): + resample = Resampling.NEAREST + + self.im.transform(box, image.im, method, data, resample, fill) + + def transpose(self, method: Transpose) -> Image: + """ + Transpose image (flip or rotate in 90 degree steps) + + :param method: One of :py:data:`Transpose.FLIP_LEFT_RIGHT`, + :py:data:`Transpose.FLIP_TOP_BOTTOM`, :py:data:`Transpose.ROTATE_90`, + :py:data:`Transpose.ROTATE_180`, :py:data:`Transpose.ROTATE_270`, + :py:data:`Transpose.TRANSPOSE` or :py:data:`Transpose.TRANSVERSE`. + :returns: Returns a flipped or rotated copy of this image. + """ + + self.load() + return self._new(self.im.transpose(method)) + + def effect_spread(self, distance: int) -> Image: + """ + Randomly spread pixels in an image. + + :param distance: Distance to spread pixels. + """ + self.load() + return self._new(self.im.effect_spread(distance)) + + def toqimage(self) -> ImageQt.ImageQt: + """Returns a QImage copy of this image""" + from . import ImageQt + + if not ImageQt.qt_is_installed: + msg = "Qt bindings are not installed" + raise ImportError(msg) + return ImageQt.toqimage(self) + + def toqpixmap(self) -> ImageQt.QPixmap: + """Returns a QPixmap copy of this image""" + from . import ImageQt + + if not ImageQt.qt_is_installed: + msg = "Qt bindings are not installed" + raise ImportError(msg) + return ImageQt.toqpixmap(self) + + +# -------------------------------------------------------------------- +# Abstract handlers. + + +class ImagePointHandler(abc.ABC): + """ + Used as a mixin by point transforms + (for use with :py:meth:`~PIL.Image.Image.point`) + """ + + @abc.abstractmethod + def point(self, im: Image) -> Image: + pass + + +class ImageTransformHandler(abc.ABC): + """ + Used as a mixin by geometry transforms + (for use with :py:meth:`~PIL.Image.Image.transform`) + """ + + @abc.abstractmethod + def transform( + self, + size: tuple[int, int], + image: Image, + **options: Any, + ) -> Image: + pass + + +# -------------------------------------------------------------------- +# Factories + + +def _check_size(size: Any) -> None: + """ + Common check to enforce type and sanity check on size tuples + + :param size: Should be a 2 tuple of (width, height) + :returns: None, or raises a ValueError + """ + + if not isinstance(size, (list, tuple)): + msg = "Size must be a list or tuple" + raise ValueError(msg) + if len(size) != 2: + msg = "Size must be a sequence of length 2" + raise ValueError(msg) + if size[0] < 0 or size[1] < 0: + msg = "Width and height must be >= 0" + raise ValueError(msg) + + +def new( + mode: str, + size: tuple[int, int] | list[int], + color: float | tuple[float, ...] | str | None = 0, +) -> Image: + """ + Creates a new image with the given mode and size. + + :param mode: The mode to use for the new image. See: + :ref:`concept-modes`. + :param size: A 2-tuple, containing (width, height) in pixels. + :param color: What color to use for the image. Default is black. If given, + this should be a single integer or floating point value for single-band + modes, and a tuple for multi-band modes (one value per band). When + creating RGB or HSV images, you can also use color strings as supported + by the ImageColor module. See :ref:`colors` for more information. If the + color is None, the image is not initialised. + :returns: An :py:class:`~PIL.Image.Image` object. + """ + + _check_size(size) + + if color is None: + # don't initialize + return Image()._new(core.new(mode, size)) + + if isinstance(color, str): + # css3-style specifier + + from . import ImageColor + + color = ImageColor.getcolor(color, mode) + + im = Image() + if ( + mode == "P" + and isinstance(color, (list, tuple)) + and all(isinstance(i, int) for i in color) + ): + color_ints: tuple[int, ...] = cast(tuple[int, ...], tuple(color)) + if len(color_ints) == 3 or len(color_ints) == 4: + # RGB or RGBA value for a P image + from . import ImagePalette + + im.palette = ImagePalette.ImagePalette() + color = im.palette.getcolor(color_ints) + return im._new(core.fill(mode, size, color)) + + +def frombytes( + mode: str, + size: tuple[int, int], + data: bytes | bytearray | SupportsArrayInterface, + decoder_name: str = "raw", + *args: Any, +) -> Image: + """ + Creates a copy of an image memory from pixel data in a buffer. + + In its simplest form, this function takes three arguments + (mode, size, and unpacked pixel data). + + You can also use any pixel decoder supported by PIL. For more + information on available decoders, see the section + :ref:`Writing Your Own File Codec <file-codecs>`. + + Note that this function decodes pixel data only, not entire images. + If you have an entire image in a string, wrap it in a + :py:class:`~io.BytesIO` object, and use :py:func:`~PIL.Image.open` to load + it. + + :param mode: The image mode. See: :ref:`concept-modes`. + :param size: The image size. + :param data: A byte buffer containing raw data for the given mode. + :param decoder_name: What decoder to use. + :param args: Additional parameters for the given decoder. + :returns: An :py:class:`~PIL.Image.Image` object. + """ + + _check_size(size) + + im = new(mode, size) + if im.width != 0 and im.height != 0: + decoder_args: Any = args + if len(decoder_args) == 1 and isinstance(decoder_args[0], tuple): + # may pass tuple instead of argument list + decoder_args = decoder_args[0] + + if decoder_name == "raw" and decoder_args == (): + decoder_args = mode + + im.frombytes(data, decoder_name, decoder_args) + return im + + +def frombuffer( + mode: str, + size: tuple[int, int], + data: bytes | SupportsArrayInterface, + decoder_name: str = "raw", + *args: Any, +) -> Image: + """ + Creates an image memory referencing pixel data in a byte buffer. + + This function is similar to :py:func:`~PIL.Image.frombytes`, but uses data + in the byte buffer, where possible. This means that changes to the + original buffer object are reflected in this image). Not all modes can + share memory; supported modes include "L", "RGBX", "RGBA", and "CMYK". + + Note that this function decodes pixel data only, not entire images. + If you have an entire image file in a string, wrap it in a + :py:class:`~io.BytesIO` object, and use :py:func:`~PIL.Image.open` to load it. + + The default parameters used for the "raw" decoder differs from that used for + :py:func:`~PIL.Image.frombytes`. This is a bug, and will probably be fixed in a + future release. The current release issues a warning if you do this; to disable + the warning, you should provide the full set of parameters. See below for details. + + :param mode: The image mode. See: :ref:`concept-modes`. + :param size: The image size. + :param data: A bytes or other buffer object containing raw + data for the given mode. + :param decoder_name: What decoder to use. + :param args: Additional parameters for the given decoder. For the + default encoder ("raw"), it's recommended that you provide the + full set of parameters:: + + frombuffer(mode, size, data, "raw", mode, 0, 1) + + :returns: An :py:class:`~PIL.Image.Image` object. + + .. versionadded:: 1.1.4 + """ + + _check_size(size) + + # may pass tuple instead of argument list + if len(args) == 1 and isinstance(args[0], tuple): + args = args[0] + + if decoder_name == "raw": + if args == (): + args = mode, 0, 1 + if args[0] in _MAPMODES: + im = new(mode, (0, 0)) + im = im._new(core.map_buffer(data, size, decoder_name, 0, args)) + if mode == "P": + from . import ImagePalette + + im.palette = ImagePalette.ImagePalette("RGB", im.im.getpalette("RGB")) + im.readonly = 1 + return im + + return frombytes(mode, size, data, decoder_name, args) + + +class SupportsArrayInterface(Protocol): + """ + An object that has an ``__array_interface__`` dictionary. + """ + + @property + def __array_interface__(self) -> dict[str, Any]: + raise NotImplementedError() + + +class SupportsArrowArrayInterface(Protocol): + """ + An object that has an ``__arrow_c_array__`` method corresponding to the arrow c + data interface. + """ + + def __arrow_c_array__( + self, requested_schema: "PyCapsule" = None # type: ignore[name-defined] # noqa: F821, UP037 + ) -> tuple["PyCapsule", "PyCapsule"]: # type: ignore[name-defined] # noqa: F821, UP037 + raise NotImplementedError() + + +def fromarray(obj: SupportsArrayInterface, mode: str | None = None) -> Image: + """ + Creates an image memory from an object exporting the array interface + (using the buffer protocol):: + + from PIL import Image + import numpy as np + a = np.zeros((5, 5)) + im = Image.fromarray(a) + + If ``obj`` is not contiguous, then the ``tobytes`` method is called + and :py:func:`~PIL.Image.frombuffer` is used. + + In the case of NumPy, be aware that Pillow modes do not always correspond + to NumPy dtypes. Pillow modes only offer 1-bit pixels, 8-bit pixels, + 32-bit signed integer pixels, and 32-bit floating point pixels. + + Pillow images can also be converted to arrays:: + + from PIL import Image + import numpy as np + im = Image.open("hopper.jpg") + a = np.asarray(im) + + When converting Pillow images to arrays however, only pixel values are + transferred. This means that P and PA mode images will lose their palette. + + :param obj: Object with array interface + :param mode: Optional mode to use when reading ``obj``. Since pixel values do not + contain information about palettes or color spaces, this can be used to place + grayscale L mode data within a P mode image, or read RGB data as YCbCr for + example. + + See: :ref:`concept-modes` for general information about modes. + :returns: An image object. + + .. versionadded:: 1.1.6 + """ + arr = obj.__array_interface__ + shape = arr["shape"] + ndim = len(shape) + strides = arr.get("strides", None) + try: + typekey = (1, 1) + shape[2:], arr["typestr"] + except KeyError as e: + if mode is not None: + typekey = None + color_modes: list[str] = [] + else: + msg = "Cannot handle this data type" + raise TypeError(msg) from e + if typekey is not None: + try: + typemode, rawmode, color_modes = _fromarray_typemap[typekey] + except KeyError as e: + typekey_shape, typestr = typekey + msg = f"Cannot handle this data type: {typekey_shape}, {typestr}" + raise TypeError(msg) from e + if mode is not None: + if mode != typemode and mode not in color_modes: + deprecate("'mode' parameter for changing data types", 13) + rawmode = mode + else: + mode = typemode + if mode in ["1", "L", "I", "P", "F"]: + ndmax = 2 + elif mode == "RGB": + ndmax = 3 + else: + ndmax = 4 + if ndim > ndmax: + msg = f"Too many dimensions: {ndim} > {ndmax}." + raise ValueError(msg) + + size = 1 if ndim == 1 else shape[1], shape[0] + if strides is not None: + if hasattr(obj, "tobytes"): + obj = obj.tobytes() + elif hasattr(obj, "tostring"): + obj = obj.tostring() + else: + msg = "'strides' requires either tobytes() or tostring()" + raise ValueError(msg) + + return frombuffer(mode, size, obj, "raw", rawmode, 0, 1) + + +def fromarrow( + obj: SupportsArrowArrayInterface, mode: str, size: tuple[int, int] +) -> Image: + """Creates an image with zero-copy shared memory from an object exporting + the arrow_c_array interface protocol:: + + from PIL import Image + import pyarrow as pa + arr = pa.array([0]*(5*5*4), type=pa.uint8()) + im = Image.fromarrow(arr, 'RGBA', (5, 5)) + + If the data representation of the ``obj`` is not compatible with + Pillow internal storage, a ValueError is raised. + + Pillow images can also be converted to Arrow objects:: + + from PIL import Image + import pyarrow as pa + im = Image.open('hopper.jpg') + arr = pa.array(im) + + As with array support, when converting Pillow images to arrays, + only pixel values are transferred. This means that P and PA mode + images will lose their palette. + + :param obj: Object with an arrow_c_array interface + :param mode: Image mode. + :param size: Image size. This must match the storage of the arrow object. + :returns: An Image object + + Note that according to the Arrow spec, both the producer and the + consumer should consider the exported array to be immutable, as + unsynchronized updates will potentially cause inconsistent data. + + See: :ref:`arrow-support` for more detailed information + + .. versionadded:: 11.2.1 + + """ + if not hasattr(obj, "__arrow_c_array__"): + msg = "arrow_c_array interface not found" + raise ValueError(msg) + + schema_capsule, array_capsule = obj.__arrow_c_array__() + _im = core.new_arrow(mode, size, schema_capsule, array_capsule) + if _im: + return Image()._new(_im) + + msg = "new_arrow returned None without an exception" + raise ValueError(msg) + + +def fromqimage(im: ImageQt.QImage) -> ImageFile.ImageFile: + """Creates an image instance from a QImage image""" + from . import ImageQt + + if not ImageQt.qt_is_installed: + msg = "Qt bindings are not installed" + raise ImportError(msg) + return ImageQt.fromqimage(im) + + +def fromqpixmap(im: ImageQt.QPixmap) -> ImageFile.ImageFile: + """Creates an image instance from a QPixmap image""" + from . import ImageQt + + if not ImageQt.qt_is_installed: + msg = "Qt bindings are not installed" + raise ImportError(msg) + return ImageQt.fromqpixmap(im) + + +_fromarray_typemap = { + # (shape, typestr) => mode, rawmode, color modes + # first two members of shape are set to one + ((1, 1), "|b1"): ("1", "1;8", []), + ((1, 1), "|u1"): ("L", "L", ["P"]), + ((1, 1), "|i1"): ("I", "I;8", []), + ((1, 1), "<u2"): ("I", "I;16", []), + ((1, 1), ">u2"): ("I", "I;16B", []), + ((1, 1), "<i2"): ("I", "I;16S", []), + ((1, 1), ">i2"): ("I", "I;16BS", []), + ((1, 1), "<u4"): ("I", "I;32", []), + ((1, 1), ">u4"): ("I", "I;32B", []), + ((1, 1), "<i4"): ("I", "I;32S", []), + ((1, 1), ">i4"): ("I", "I;32BS", []), + ((1, 1), "<f4"): ("F", "F;32F", []), + ((1, 1), ">f4"): ("F", "F;32BF", []), + ((1, 1), "<f8"): ("F", "F;64F", []), + ((1, 1), ">f8"): ("F", "F;64BF", []), + ((1, 1, 2), "|u1"): ("LA", "LA", ["La", "PA"]), + ((1, 1, 3), "|u1"): ("RGB", "RGB", ["YCbCr", "LAB", "HSV"]), + ((1, 1, 4), "|u1"): ("RGBA", "RGBA", ["RGBa", "RGBX", "CMYK"]), + # shortcuts: + ((1, 1), f"{_ENDIAN}i4"): ("I", "I", []), + ((1, 1), f"{_ENDIAN}f4"): ("F", "F", []), +} + + +def _decompression_bomb_check(size: tuple[int, int]) -> None: + if MAX_IMAGE_PIXELS is None: + return + + pixels = max(1, size[0]) * max(1, size[1]) + + if pixels > 2 * MAX_IMAGE_PIXELS: + msg = ( + f"Image size ({pixels} pixels) exceeds limit of {2 * MAX_IMAGE_PIXELS} " + "pixels, could be decompression bomb DOS attack." + ) + raise DecompressionBombError(msg) + + if pixels > MAX_IMAGE_PIXELS: + warnings.warn( + f"Image size ({pixels} pixels) exceeds limit of {MAX_IMAGE_PIXELS} pixels, " + "could be decompression bomb DOS attack.", + DecompressionBombWarning, + ) + + +def open( + fp: StrOrBytesPath | IO[bytes], + mode: Literal["r"] = "r", + formats: list[str] | tuple[str, ...] | None = None, +) -> ImageFile.ImageFile: + """ + Opens and identifies the given image file. + + This is a lazy operation; this function identifies the file, but + the file remains open and the actual image data is not read from + the file until you try to process the data (or call the + :py:meth:`~PIL.Image.Image.load` method). See + :py:func:`~PIL.Image.new`. See :ref:`file-handling`. + + :param fp: A filename (string), os.PathLike object or a file object. + The file object must implement ``file.read``, + ``file.seek``, and ``file.tell`` methods, + and be opened in binary mode. The file object will also seek to zero + before reading. + :param mode: The mode. If given, this argument must be "r". + :param formats: A list or tuple of formats to attempt to load the file in. + This can be used to restrict the set of formats checked. + Pass ``None`` to try all supported formats. You can print the set of + available formats by running ``python3 -m PIL`` or using + the :py:func:`PIL.features.pilinfo` function. + :returns: An :py:class:`~PIL.Image.Image` object. + :exception FileNotFoundError: If the file cannot be found. + :exception PIL.UnidentifiedImageError: If the image cannot be opened and + identified. + :exception ValueError: If the ``mode`` is not "r", or if a ``StringIO`` + instance is used for ``fp``. + :exception TypeError: If ``formats`` is not ``None``, a list or a tuple. + """ + + if mode != "r": + msg = f"bad mode {repr(mode)}" # type: ignore[unreachable] + raise ValueError(msg) + elif isinstance(fp, io.StringIO): + msg = ( # type: ignore[unreachable] + "StringIO cannot be used to open an image. " + "Binary data must be used instead." + ) + raise ValueError(msg) + + if formats is None: + formats = ID + elif not isinstance(formats, (list, tuple)): + msg = "formats must be a list or tuple" # type: ignore[unreachable] + raise TypeError(msg) + + exclusive_fp = False + filename: str | bytes = "" + if is_path(fp): + filename = os.fspath(fp) + fp = builtins.open(filename, "rb") + exclusive_fp = True + else: + fp = cast(IO[bytes], fp) + + try: + fp.seek(0) + except (AttributeError, io.UnsupportedOperation): + fp = io.BytesIO(fp.read()) + exclusive_fp = True + + prefix = fp.read(16) + + # Try to import just the plugin needed for this file extension + # before falling back to preinit() which imports common plugins + ext = os.path.splitext(filename)[1] if filename else "" + if not _import_plugin_for_extension(ext): + preinit() + + warning_messages: list[str] = [] + + def _open_core( + fp: IO[bytes], + filename: str | bytes, + prefix: bytes, + formats: list[str] | tuple[str, ...], + ) -> ImageFile.ImageFile | None: + for i in formats: + i = i.upper() + if i not in OPEN: + init() + try: + factory, accept = OPEN[i] + result = not accept or accept(prefix) + if isinstance(result, str): + warning_messages.append(result) + elif result: + fp.seek(0) + im = factory(fp, filename) + _decompression_bomb_check(im.size) + return im + except (SyntaxError, IndexError, TypeError, struct.error) as e: + if WARN_POSSIBLE_FORMATS: + warning_messages.append(i + " opening failed. " + str(e)) + except BaseException: + if exclusive_fp: + fp.close() + raise + return None + + im = _open_core(fp, filename, prefix, formats) + + if im is None and formats is ID: + # Try preinit (few common plugins) then init (all plugins) + for loader in (preinit, init): + checked_formats = ID.copy() + loader() + if formats != checked_formats: + im = _open_core( + fp, + filename, + prefix, + tuple(f for f in formats if f not in checked_formats), + ) + if im is not None: + break + + if im: + im._exclusive_fp = exclusive_fp + return im + + if exclusive_fp: + fp.close() + for message in warning_messages: + warnings.warn(message) + msg = "cannot identify image file %r" % (filename if filename else fp) + raise UnidentifiedImageError(msg) + + +# +# Image processing. + + +def alpha_composite(im1: Image, im2: Image) -> Image: + """ + Alpha composite im2 over im1. + + :param im1: The first image. Must have mode RGBA or LA. + :param im2: The second image. Must have the same mode and size as the first image. + :returns: An :py:class:`~PIL.Image.Image` object. + """ + + im1.load() + im2.load() + return im1._new(core.alpha_composite(im1.im, im2.im)) + + +def blend(im1: Image, im2: Image, alpha: float) -> Image: + """ + Creates a new image by interpolating between two input images, using + a constant alpha:: + + out = image1 * (1.0 - alpha) + image2 * alpha + + :param im1: The first image. + :param im2: The second image. Must have the same mode and size as + the first image. + :param alpha: The interpolation alpha factor. If alpha is 0.0, a + copy of the first image is returned. If alpha is 1.0, a copy of + the second image is returned. There are no restrictions on the + alpha value. If necessary, the result is clipped to fit into + the allowed output range. + :returns: An :py:class:`~PIL.Image.Image` object. + """ + + im1.load() + im2.load() + return im1._new(core.blend(im1.im, im2.im, alpha)) + + +def composite(image1: Image, image2: Image, mask: Image) -> Image: + """ + Create composite image by blending images using a transparency mask. + + :param image1: The first image. + :param image2: The second image. Must have the same mode and + size as the first image. + :param mask: A mask image. This image can have mode + "1", "L", or "RGBA", and must have the same size as the + other two images. + """ + + image = image2.copy() + image.paste(image1, None, mask) + return image + + +def eval(image: Image, *args: Callable[[int], float]) -> Image: + """ + Applies the function (which should take one argument) to each pixel + in the given image. If the image has more than one band, the same + function is applied to each band. Note that the function is + evaluated once for each possible pixel value, so you cannot use + random components or other generators. + + :param image: The input image. + :param function: A function object, taking one integer argument. + :returns: An :py:class:`~PIL.Image.Image` object. + """ + + return image.point(args[0]) + + +def merge(mode: str, bands: Sequence[Image]) -> Image: + """ + Merge a set of single band images into a new multiband image. + + :param mode: The mode to use for the output image. See: + :ref:`concept-modes`. + :param bands: A sequence containing one single-band image for + each band in the output image. All bands must have the + same size. + :returns: An :py:class:`~PIL.Image.Image` object. + """ + + if getmodebands(mode) != len(bands) or "*" in mode: + msg = "wrong number of bands" + raise ValueError(msg) + for band in bands[1:]: + if band.mode != getmodetype(mode): + msg = "mode mismatch" + raise ValueError(msg) + if band.size != bands[0].size: + msg = "size mismatch" + raise ValueError(msg) + for band in bands: + band.load() + return bands[0]._new(core.merge(mode, *[b.im for b in bands])) + + +# -------------------------------------------------------------------- +# Plugin registry + + +def register_open( + id: str, + factory: ( + Callable[[IO[bytes], str | bytes], ImageFile.ImageFile] + | type[ImageFile.ImageFile] + ), + accept: Callable[[bytes], bool | str] | None = None, +) -> None: + """ + Register an image file plugin. This function should not be used + in application code. + + :param id: An image format identifier. + :param factory: An image file factory method. + :param accept: An optional function that can be used to quickly + reject images having another format. + """ + id = id.upper() + if id not in ID: + ID.append(id) + OPEN[id] = factory, accept + + +def register_mime(id: str, mimetype: str) -> None: + """ + Registers an image MIME type by populating ``Image.MIME``. This function + should not be used in application code. + + ``Image.MIME`` provides a mapping from image format identifiers to mime + formats, but :py:meth:`~PIL.ImageFile.ImageFile.get_format_mimetype` can + provide a different result for specific images. + + :param id: An image format identifier. + :param mimetype: The image MIME type for this format. + """ + MIME[id.upper()] = mimetype + + +def register_save( + id: str, driver: Callable[[Image, IO[bytes], str | bytes], None] +) -> None: + """ + Registers an image save function. This function should not be + used in application code. + + :param id: An image format identifier. + :param driver: A function to save images in this format. + """ + SAVE[id.upper()] = driver + + +def register_save_all( + id: str, driver: Callable[[Image, IO[bytes], str | bytes], None] +) -> None: + """ + Registers an image function to save all the frames + of a multiframe format. This function should not be + used in application code. + + :param id: An image format identifier. + :param driver: A function to save images in this format. + """ + SAVE_ALL[id.upper()] = driver + + +def register_extension(id: str, extension: str) -> None: + """ + Registers an image extension. This function should not be + used in application code. + + :param id: An image format identifier. + :param extension: An extension used for this format. + """ + EXTENSION[extension.lower()] = id.upper() + + +def register_extensions(id: str, extensions: list[str]) -> None: + """ + Registers image extensions. This function should not be + used in application code. + + :param id: An image format identifier. + :param extensions: A list of extensions used for this format. + """ + for extension in extensions: + register_extension(id, extension) + + +def registered_extensions() -> dict[str, str]: + """ + Returns a dictionary containing all file extensions belonging + to registered plugins + """ + init() + return EXTENSION + + +def register_decoder(name: str, decoder: type[ImageFile.PyDecoder]) -> None: + """ + Registers an image decoder. This function should not be + used in application code. + + :param name: The name of the decoder + :param decoder: An ImageFile.PyDecoder object + + .. versionadded:: 4.1.0 + """ + DECODERS[name] = decoder + + +def register_encoder(name: str, encoder: type[ImageFile.PyEncoder]) -> None: + """ + Registers an image encoder. This function should not be + used in application code. + + :param name: The name of the encoder + :param encoder: An ImageFile.PyEncoder object + + .. versionadded:: 4.1.0 + """ + ENCODERS[name] = encoder + + +# -------------------------------------------------------------------- +# Simple display support. + + +def _show(image: Image, **options: Any) -> None: + from . import ImageShow + + deprecate("Image._show", 13, "ImageShow.show") + ImageShow.show(image, **options) + + +# -------------------------------------------------------------------- +# Effects + + +def effect_mandelbrot( + size: tuple[int, int], extent: tuple[float, float, float, float], quality: int +) -> Image: + """ + Generate a Mandelbrot set covering the given extent. + + :param size: The requested size in pixels, as a 2-tuple: + (width, height). + :param extent: The extent to cover, as a 4-tuple: + (x0, y0, x1, y1). + :param quality: Quality. + """ + return Image()._new(core.effect_mandelbrot(size, extent, quality)) + + +def effect_noise(size: tuple[int, int], sigma: float) -> Image: + """ + Generate Gaussian noise centered around 128. + + :param size: The requested size in pixels, as a 2-tuple: + (width, height). + :param sigma: Standard deviation of noise. + """ + return Image()._new(core.effect_noise(size, sigma)) + + +def linear_gradient(mode: str) -> Image: + """ + Generate 256x256 linear gradient from black to white, top to bottom. + + :param mode: Input mode. + """ + return Image()._new(core.linear_gradient(mode)) + + +def radial_gradient(mode: str) -> Image: + """ + Generate 256x256 radial gradient from black to white, centre to edge. + + :param mode: Input mode. + """ + return Image()._new(core.radial_gradient(mode)) + + +# -------------------------------------------------------------------- +# Resources + + +def _apply_env_variables(env: dict[str, str] | None = None) -> None: + env_dict = env if env is not None else os.environ + + for var_name, setter in [ + ("PILLOW_ALIGNMENT", core.set_alignment), + ("PILLOW_BLOCK_SIZE", core.set_block_size), + ("PILLOW_BLOCKS_MAX", core.set_blocks_max), + ]: + if var_name not in env_dict: + continue + + var = env_dict[var_name].lower() + + units = 1 + for postfix, mul in [("k", 1024), ("m", 1024 * 1024)]: + if var.endswith(postfix): + units = mul + var = var[: -len(postfix)] + + try: + var_int = int(var) * units + except ValueError: + warnings.warn(f"{var_name} is not int") + continue + + try: + setter(var_int) + except ValueError as e: + warnings.warn(f"{var_name}: {e}") + + +_apply_env_variables() +atexit.register(core.clear_cache) + + +if TYPE_CHECKING: + _ExifBase = MutableMapping[int, Any] +else: + _ExifBase = MutableMapping + + +class Exif(_ExifBase): + """ + This class provides read and write access to EXIF image data:: + + from PIL import Image + im = Image.open("exif.png") + exif = im.getexif() # Returns an instance of this class + + Information can be read and written, iterated over or deleted:: + + print(exif[274]) # 1 + exif[274] = 2 + for k, v in exif.items(): + print("Tag", k, "Value", v) # Tag 274 Value 2 + del exif[274] + + To access information beyond IFD0, :py:meth:`~PIL.Image.Exif.get_ifd` + returns a dictionary:: + + from PIL import ExifTags + im = Image.open("exif_gps.jpg") + exif = im.getexif() + gps_ifd = exif.get_ifd(ExifTags.IFD.GPSInfo) + print(gps_ifd) + + Other IFDs include ``ExifTags.IFD.Exif``, ``ExifTags.IFD.MakerNote``, + ``ExifTags.IFD.Interop`` and ``ExifTags.IFD.IFD1``. + + :py:mod:`~PIL.ExifTags` also has enum classes to provide names for data:: + + print(exif[ExifTags.Base.Software]) # PIL + print(gps_ifd[ExifTags.GPS.GPSDateStamp]) # 1999:99:99 99:99:99 + """ + + endian: str | None = None + bigtiff = False + _loaded = False + + def __init__(self) -> None: + self._data: dict[int, Any] = {} + self._hidden_data: dict[int, Any] = {} + self._ifds: dict[int, dict[int, Any]] = {} + self._info: TiffImagePlugin.ImageFileDirectory_v2 | None = None + self._loaded_exif: bytes | None = None + + def _fixup(self, value: Any) -> Any: + try: + if len(value) == 1 and isinstance(value, tuple): + return value[0] + except Exception: + pass + return value + + def _fixup_dict(self, src_dict: dict[int, Any]) -> dict[int, Any]: + # Helper function + # returns a dict with any single item tuples/lists as individual values + return {k: self._fixup(v) for k, v in src_dict.items()} + + def _get_ifd_dict( + self, offset: int, group: int | None = None + ) -> dict[int, Any] | None: + try: + # an offset pointer to the location of the nested embedded IFD. + # It should be a long, but may be corrupted. + self.fp.seek(offset) + except (KeyError, TypeError): + return None + else: + from . import TiffImagePlugin + + info = TiffImagePlugin.ImageFileDirectory_v2(self.head, group=group) + info.load(self.fp) + return self._fixup_dict(dict(info)) + + def _get_head(self) -> bytes: + version = b"\x2b" if self.bigtiff else b"\x2a" + if self.endian == "<": + head = b"II" + version + b"\x00" + o32le(8) + else: + head = b"MM\x00" + version + o32be(8) + if self.bigtiff: + head += o32le(8) if self.endian == "<" else o32be(8) + head += b"\x00\x00\x00\x00" + return head + + def load(self, data: bytes) -> None: + # Extract EXIF information. This is highly experimental, + # and is likely to be replaced with something better in a future + # version. + + # The EXIF record consists of a TIFF file embedded in a JPEG + # application marker (!). + if data == self._loaded_exif: + return + self._loaded_exif = data + self._data.clear() + self._hidden_data.clear() + self._ifds.clear() + while data and data.startswith(b"Exif\x00\x00"): + data = data[6:] + if not data: + self._info = None + return + + self.fp: IO[bytes] = io.BytesIO(data) + self.head = self.fp.read(8) + # process dictionary + from . import TiffImagePlugin + + self._info = TiffImagePlugin.ImageFileDirectory_v2(self.head) + self.endian = self._info._endian + self.fp.seek(self._info.next) + self._info.load(self.fp) + + def load_from_fp(self, fp: IO[bytes], offset: int | None = None) -> None: + self._loaded_exif = None + self._data.clear() + self._hidden_data.clear() + self._ifds.clear() + + # process dictionary + from . import TiffImagePlugin + + self.fp = fp + if offset is not None: + self.head = self._get_head() + else: + self.head = self.fp.read(8) + self._info = TiffImagePlugin.ImageFileDirectory_v2(self.head) + if self.endian is None: + self.endian = self._info._endian + if offset is None: + offset = self._info.next + self.fp.tell() + self.fp.seek(offset) + self._info.load(self.fp) + + def _get_merged_dict(self) -> dict[int, Any]: + merged_dict = dict(self) + + # get EXIF extension + if ExifTags.IFD.Exif in self: + ifd = self._get_ifd_dict(self[ExifTags.IFD.Exif], ExifTags.IFD.Exif) + if ifd: + merged_dict.update(ifd) + + # GPS + if ExifTags.IFD.GPSInfo in self: + merged_dict[ExifTags.IFD.GPSInfo] = self._get_ifd_dict( + self[ExifTags.IFD.GPSInfo], ExifTags.IFD.GPSInfo + ) + + return merged_dict + + def tobytes(self, offset: int = 8) -> bytes: + from . import TiffImagePlugin + + head = self._get_head() + ifd = TiffImagePlugin.ImageFileDirectory_v2(ifh=head) + for tag, ifd_dict in self._ifds.items(): + if tag not in self: + ifd[tag] = ifd_dict + for tag, value in self.items(): + if tag in [ + ExifTags.IFD.Exif, + ExifTags.IFD.GPSInfo, + ] and not isinstance(value, dict): + value = self.get_ifd(tag) + if ( + tag == ExifTags.IFD.Exif + and ExifTags.IFD.Interop in value + and not isinstance(value[ExifTags.IFD.Interop], dict) + ): + value = value.copy() + value[ExifTags.IFD.Interop] = self.get_ifd(ExifTags.IFD.Interop) + ifd[tag] = value + return b"Exif\x00\x00" + head + ifd.tobytes(offset) + + def get_ifd(self, tag: int) -> dict[int, Any]: + if tag not in self._ifds: + if tag == ExifTags.IFD.IFD1: + if self._info is not None and self._info.next != 0: + ifd = self._get_ifd_dict(self._info.next) + if ifd is not None: + self._ifds[tag] = ifd + elif tag in [ExifTags.IFD.Exif, ExifTags.IFD.GPSInfo]: + offset = self._hidden_data.get(tag, self.get(tag)) + if offset is not None: + ifd = self._get_ifd_dict(offset, tag) + if ifd is not None: + self._ifds[tag] = ifd + elif tag in [ExifTags.IFD.Interop, ExifTags.IFD.MakerNote]: + if ExifTags.IFD.Exif not in self._ifds: + self.get_ifd(ExifTags.IFD.Exif) + tag_data = self._ifds[ExifTags.IFD.Exif][tag] + if tag == ExifTags.IFD.MakerNote: + from .TiffImagePlugin import ImageFileDirectory_v2 + + try: + if tag_data.startswith(b"FUJIFILM"): + ifd_offset = i32le(tag_data, 8) + ifd_data = tag_data[ifd_offset:] + + makernote = {} + for i in range(struct.unpack("<H", ifd_data[:2])[0]): + ifd_tag, typ, count, data = struct.unpack( + "<HHL4s", ifd_data[i * 12 + 2 : (i + 1) * 12 + 2] + ) + try: + ( + unit_size, + handler, + ) = ImageFileDirectory_v2._load_dispatch[typ] + except KeyError: + continue + size = count * unit_size + if size > 4: + (offset,) = struct.unpack("<L", data) + data = ifd_data[offset - 12 : offset + size - 12] + else: + data = data[:size] + + if len(data) != size: + warnings.warn( + "Possibly corrupt EXIF MakerNote data. " + f"Expecting to read {size} bytes but only got " + f"{len(data)}. Skipping tag {ifd_tag}" + ) + continue + + if not data: + continue + + makernote[ifd_tag] = handler( + ImageFileDirectory_v2(), data, False + ) + self._ifds[tag] = dict(self._fixup_dict(makernote)) + elif self.get(0x010F) == "Nintendo": + makernote = {} + for i in range(struct.unpack(">H", tag_data[:2])[0]): + ifd_tag, typ, count, data = struct.unpack( + ">HHL4s", tag_data[i * 12 + 2 : (i + 1) * 12 + 2] + ) + if ifd_tag == 0x1101: + # CameraInfo + (offset,) = struct.unpack(">L", data) + self.fp.seek(offset) + + camerainfo: dict[str, int | bytes] = { + "ModelID": self.fp.read(4) + } + + self.fp.read(4) + # Seconds since 2000 + camerainfo["TimeStamp"] = i32le(self.fp.read(12)) + + self.fp.read(4) + camerainfo["InternalSerialNumber"] = self.fp.read(4) + + self.fp.read(12) + parallax = self.fp.read(4) + handler = ImageFileDirectory_v2._load_dispatch[ + TiffTags.FLOAT + ][1] + camerainfo["Parallax"] = handler( + ImageFileDirectory_v2(), parallax, False + )[0] + + self.fp.read(4) + camerainfo["Category"] = self.fp.read(2) + + makernote = {0x1101: camerainfo} + self._ifds[tag] = makernote + except struct.error: + pass + else: + # Interop + ifd = self._get_ifd_dict(tag_data, tag) + if ifd is not None: + self._ifds[tag] = ifd + ifd = self._ifds.setdefault(tag, {}) + if tag == ExifTags.IFD.Exif and self._hidden_data: + ifd = { + k: v + for (k, v) in ifd.items() + if k not in (ExifTags.IFD.Interop, ExifTags.IFD.MakerNote) + } + return ifd + + def hide_offsets(self) -> None: + for tag in (ExifTags.IFD.Exif, ExifTags.IFD.GPSInfo): + if tag in self: + self._hidden_data[tag] = self[tag] + del self[tag] + + def __str__(self) -> str: + if self._info is not None: + # Load all keys into self._data + for tag in self._info: + self[tag] + + return str(self._data) + + def __len__(self) -> int: + keys = set(self._data) + if self._info is not None: + keys.update(self._info) + return len(keys) + + def __getitem__(self, tag: int) -> Any: + if self._info is not None and tag not in self._data and tag in self._info: + self._data[tag] = self._fixup(self._info[tag]) + del self._info[tag] + return self._data[tag] + + def __contains__(self, tag: object) -> bool: + return tag in self._data or (self._info is not None and tag in self._info) + + def __setitem__(self, tag: int, value: Any) -> None: + if self._info is not None and tag in self._info: + del self._info[tag] + self._data[tag] = value + + def __delitem__(self, tag: int) -> None: + if self._info is not None and tag in self._info: + del self._info[tag] + else: + del self._data[tag] + if tag in self._ifds: + del self._ifds[tag] + + def __iter__(self) -> Iterator[int]: + keys = set(self._data) + if self._info is not None: + keys.update(self._info) + return iter(keys) diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/ImageChops.py b/presentation/.venv/lib/python3.12/site-packages/PIL/ImageChops.py new file mode 100644 index 0000000..29a5c99 --- /dev/null +++ b/presentation/.venv/lib/python3.12/site-packages/PIL/ImageChops.py @@ -0,0 +1,311 @@ +# +# The Python Imaging Library. +# $Id$ +# +# standard channel operations +# +# History: +# 1996-03-24 fl Created +# 1996-08-13 fl Added logical operations (for "1" images) +# 2000-10-12 fl Added offset method (from Image.py) +# +# Copyright (c) 1997-2000 by Secret Labs AB +# Copyright (c) 1996-2000 by Fredrik Lundh +# +# See the README file for information on usage and redistribution. +# + +from __future__ import annotations + +from . import Image + + +def constant(image: Image.Image, value: int) -> Image.Image: + """Fill a channel with a given gray level. + + :rtype: :py:class:`~PIL.Image.Image` + """ + + return Image.new("L", image.size, value) + + +def duplicate(image: Image.Image) -> Image.Image: + """Copy a channel. Alias for :py:meth:`PIL.Image.Image.copy`. + + :rtype: :py:class:`~PIL.Image.Image` + """ + + return image.copy() + + +def invert(image: Image.Image) -> Image.Image: + """ + Invert an image (channel). :: + + out = MAX - image + + :rtype: :py:class:`~PIL.Image.Image` + """ + + image.load() + return image._new(image.im.chop_invert()) + + +def lighter(image1: Image.Image, image2: Image.Image) -> Image.Image: + """ + Compares the two images, pixel by pixel, and returns a new image containing + the lighter values. :: + + out = max(image1, image2) + + :rtype: :py:class:`~PIL.Image.Image` + """ + + image1.load() + image2.load() + return image1._new(image1.im.chop_lighter(image2.im)) + + +def darker(image1: Image.Image, image2: Image.Image) -> Image.Image: + """ + Compares the two images, pixel by pixel, and returns a new image containing + the darker values. :: + + out = min(image1, image2) + + :rtype: :py:class:`~PIL.Image.Image` + """ + + image1.load() + image2.load() + return image1._new(image1.im.chop_darker(image2.im)) + + +def difference(image1: Image.Image, image2: Image.Image) -> Image.Image: + """ + Returns the absolute value of the pixel-by-pixel difference between the two + images. :: + + out = abs(image1 - image2) + + :rtype: :py:class:`~PIL.Image.Image` + """ + + image1.load() + image2.load() + return image1._new(image1.im.chop_difference(image2.im)) + + +def multiply(image1: Image.Image, image2: Image.Image) -> Image.Image: + """ + Superimposes two images on top of each other. + + If you multiply an image with a solid black image, the result is black. If + you multiply with a solid white image, the image is unaffected. :: + + out = image1 * image2 / MAX + + :rtype: :py:class:`~PIL.Image.Image` + """ + + image1.load() + image2.load() + return image1._new(image1.im.chop_multiply(image2.im)) + + +def screen(image1: Image.Image, image2: Image.Image) -> Image.Image: + """ + Superimposes two inverted images on top of each other. :: + + out = MAX - ((MAX - image1) * (MAX - image2) / MAX) + + :rtype: :py:class:`~PIL.Image.Image` + """ + + image1.load() + image2.load() + return image1._new(image1.im.chop_screen(image2.im)) + + +def soft_light(image1: Image.Image, image2: Image.Image) -> Image.Image: + """ + Superimposes two images on top of each other using the Soft Light algorithm + + :rtype: :py:class:`~PIL.Image.Image` + """ + + image1.load() + image2.load() + return image1._new(image1.im.chop_soft_light(image2.im)) + + +def hard_light(image1: Image.Image, image2: Image.Image) -> Image.Image: + """ + Superimposes two images on top of each other using the Hard Light algorithm + + :rtype: :py:class:`~PIL.Image.Image` + """ + + image1.load() + image2.load() + return image1._new(image1.im.chop_hard_light(image2.im)) + + +def overlay(image1: Image.Image, image2: Image.Image) -> Image.Image: + """ + Superimposes two images on top of each other using the Overlay algorithm + + :rtype: :py:class:`~PIL.Image.Image` + """ + + image1.load() + image2.load() + return image1._new(image1.im.chop_overlay(image2.im)) + + +def add( + image1: Image.Image, image2: Image.Image, scale: float = 1.0, offset: float = 0 +) -> Image.Image: + """ + Adds two images, dividing the result by scale and adding the + offset. If omitted, scale defaults to 1.0, and offset to 0.0. :: + + out = ((image1 + image2) / scale + offset) + + :rtype: :py:class:`~PIL.Image.Image` + """ + + image1.load() + image2.load() + return image1._new(image1.im.chop_add(image2.im, scale, offset)) + + +def subtract( + image1: Image.Image, image2: Image.Image, scale: float = 1.0, offset: float = 0 +) -> Image.Image: + """ + Subtracts two images, dividing the result by scale and adding the offset. + If omitted, scale defaults to 1.0, and offset to 0.0. :: + + out = ((image1 - image2) / scale + offset) + + :rtype: :py:class:`~PIL.Image.Image` + """ + + image1.load() + image2.load() + return image1._new(image1.im.chop_subtract(image2.im, scale, offset)) + + +def add_modulo(image1: Image.Image, image2: Image.Image) -> Image.Image: + """Add two images, without clipping the result. :: + + out = ((image1 + image2) % MAX) + + :rtype: :py:class:`~PIL.Image.Image` + """ + + image1.load() + image2.load() + return image1._new(image1.im.chop_add_modulo(image2.im)) + + +def subtract_modulo(image1: Image.Image, image2: Image.Image) -> Image.Image: + """Subtract two images, without clipping the result. :: + + out = ((image1 - image2) % MAX) + + :rtype: :py:class:`~PIL.Image.Image` + """ + + image1.load() + image2.load() + return image1._new(image1.im.chop_subtract_modulo(image2.im)) + + +def logical_and(image1: Image.Image, image2: Image.Image) -> Image.Image: + """Logical AND between two images. + + Both of the images must have mode "1". If you would like to perform a + logical AND on an image with a mode other than "1", try + :py:meth:`~PIL.ImageChops.multiply` instead, using a black-and-white mask + as the second image. :: + + out = ((image1 and image2) % MAX) + + :rtype: :py:class:`~PIL.Image.Image` + """ + + image1.load() + image2.load() + return image1._new(image1.im.chop_and(image2.im)) + + +def logical_or(image1: Image.Image, image2: Image.Image) -> Image.Image: + """Logical OR between two images. + + Both of the images must have mode "1". :: + + out = ((image1 or image2) % MAX) + + :rtype: :py:class:`~PIL.Image.Image` + """ + + image1.load() + image2.load() + return image1._new(image1.im.chop_or(image2.im)) + + +def logical_xor(image1: Image.Image, image2: Image.Image) -> Image.Image: + """Logical XOR between two images. + + Both of the images must have mode "1". :: + + out = ((bool(image1) != bool(image2)) % MAX) + + :rtype: :py:class:`~PIL.Image.Image` + """ + + image1.load() + image2.load() + return image1._new(image1.im.chop_xor(image2.im)) + + +def blend(image1: Image.Image, image2: Image.Image, alpha: float) -> Image.Image: + """Blend images using constant transparency weight. Alias for + :py:func:`PIL.Image.blend`. + + :rtype: :py:class:`~PIL.Image.Image` + """ + + return Image.blend(image1, image2, alpha) + + +def composite( + image1: Image.Image, image2: Image.Image, mask: Image.Image +) -> Image.Image: + """Create composite using transparency mask. Alias for + :py:func:`PIL.Image.composite`. + + :rtype: :py:class:`~PIL.Image.Image` + """ + + return Image.composite(image1, image2, mask) + + +def offset(image: Image.Image, xoffset: int, yoffset: int | None = None) -> Image.Image: + """Returns a copy of the image where data has been offset by the given + distances. Data wraps around the edges. If ``yoffset`` is omitted, it + is assumed to be equal to ``xoffset``. + + :param image: Input image. + :param xoffset: The horizontal distance. + :param yoffset: The vertical distance. If omitted, both + distances are set to the same value. + :rtype: :py:class:`~PIL.Image.Image` + """ + + if yoffset is None: + yoffset = xoffset + image.load() + return image._new(image.im.offset(xoffset, yoffset)) diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/ImageCms.py b/presentation/.venv/lib/python3.12/site-packages/PIL/ImageCms.py new file mode 100644 index 0000000..513e28a --- /dev/null +++ b/presentation/.venv/lib/python3.12/site-packages/PIL/ImageCms.py @@ -0,0 +1,1076 @@ +# The Python Imaging Library. +# $Id$ + +# Optional color management support, based on Kevin Cazabon's PyCMS +# library. + +# Originally released under LGPL. Graciously donated to PIL in +# March 2009, for distribution under the standard PIL license + +# History: + +# 2009-03-08 fl Added to PIL. + +# Copyright (C) 2002-2003 Kevin Cazabon +# Copyright (c) 2009 by Fredrik Lundh +# Copyright (c) 2013 by Eric Soroos + +# See the README file for information on usage and redistribution. See +# below for the original description. +from __future__ import annotations + +import operator +import sys +from enum import IntEnum, IntFlag +from functools import reduce +from typing import Any, Literal, SupportsFloat, SupportsInt, Union + +from . import Image +from ._deprecate import deprecate +from ._typing import SupportsRead + +try: + from . import _imagingcms as core + + _CmsProfileCompatible = Union[ + str, SupportsRead[bytes], core.CmsProfile, "ImageCmsProfile" + ] +except ImportError as ex: + # Allow error import for doc purposes, but error out when accessing + # anything in core. + from ._util import DeferredError + + core = DeferredError.new(ex) + +_DESCRIPTION = """ +pyCMS + + a Python / PIL interface to the littleCMS ICC Color Management System + Copyright (C) 2002-2003 Kevin Cazabon + kevin@cazabon.com + https://www.cazabon.com + + pyCMS home page: https://www.cazabon.com/pyCMS + littleCMS home page: https://www.littlecms.com + (littleCMS is Copyright (C) 1998-2001 Marti Maria) + + Originally released under LGPL. Graciously donated to PIL in + March 2009, for distribution under the standard PIL license + + The pyCMS.py module provides a "clean" interface between Python/PIL and + pyCMSdll, taking care of some of the more complex handling of the direct + pyCMSdll functions, as well as error-checking and making sure that all + relevant data is kept together. + + While it is possible to call pyCMSdll functions directly, it's not highly + recommended. + + Version History: + + 1.0.0 pil Oct 2013 Port to LCMS 2. + + 0.1.0 pil mod March 10, 2009 + + Renamed display profile to proof profile. The proof + profile is the profile of the device that is being + simulated, not the profile of the device which is + actually used to display/print the final simulation + (that'd be the output profile) - also see LCMSAPI.txt + input colorspace -> using 'renderingIntent' -> proof + colorspace -> using 'proofRenderingIntent' -> output + colorspace + + Added LCMS FLAGS support. + Added FLAGS["SOFTPROOFING"] as default flag for + buildProofTransform (otherwise the proof profile/intent + would be ignored). + + 0.1.0 pil March 2009 - added to PIL, as PIL.ImageCms + + 0.0.2 alpha Jan 6, 2002 + + Added try/except statements around type() checks of + potential CObjects... Python won't let you use type() + on them, and raises a TypeError (stupid, if you ask + me!) + + Added buildProofTransformFromOpenProfiles() function. + Additional fixes in DLL, see DLL code for details. + + 0.0.1 alpha first public release, Dec. 26, 2002 + + Known to-do list with current version (of Python interface, not pyCMSdll): + + none + +""" + +_VERSION = "1.0.0 pil" + + +# --------------------------------------------------------------------. + + +# +# intent/direction values + + +class Intent(IntEnum): + PERCEPTUAL = 0 + RELATIVE_COLORIMETRIC = 1 + SATURATION = 2 + ABSOLUTE_COLORIMETRIC = 3 + + +class Direction(IntEnum): + INPUT = 0 + OUTPUT = 1 + PROOF = 2 + + +# +# flags + + +class Flags(IntFlag): + """Flags and documentation are taken from ``lcms2.h``.""" + + NONE = 0 + NOCACHE = 0x0040 + """Inhibit 1-pixel cache""" + NOOPTIMIZE = 0x0100 + """Inhibit optimizations""" + NULLTRANSFORM = 0x0200 + """Don't transform anyway""" + GAMUTCHECK = 0x1000 + """Out of Gamut alarm""" + SOFTPROOFING = 0x4000 + """Do softproofing""" + BLACKPOINTCOMPENSATION = 0x2000 + NOWHITEONWHITEFIXUP = 0x0004 + """Don't fix scum dot""" + HIGHRESPRECALC = 0x0400 + """Use more memory to give better accuracy""" + LOWRESPRECALC = 0x0800 + """Use less memory to minimize resources""" + # this should be 8BITS_DEVICELINK, but that is not a valid name in Python: + USE_8BITS_DEVICELINK = 0x0008 + """Create 8 bits devicelinks""" + GUESSDEVICECLASS = 0x0020 + """Guess device class (for ``transform2devicelink``)""" + KEEP_SEQUENCE = 0x0080 + """Keep profile sequence for devicelink creation""" + FORCE_CLUT = 0x0002 + """Force CLUT optimization""" + CLUT_POST_LINEARIZATION = 0x0001 + """create postlinearization tables if possible""" + CLUT_PRE_LINEARIZATION = 0x0010 + """create prelinearization tables if possible""" + NONEGATIVES = 0x8000 + """Prevent negative numbers in floating point transforms""" + COPY_ALPHA = 0x04000000 + """Alpha channels are copied on ``cmsDoTransform()``""" + NODEFAULTRESOURCEDEF = 0x01000000 + + _GRIDPOINTS_1 = 1 << 16 + _GRIDPOINTS_2 = 2 << 16 + _GRIDPOINTS_4 = 4 << 16 + _GRIDPOINTS_8 = 8 << 16 + _GRIDPOINTS_16 = 16 << 16 + _GRIDPOINTS_32 = 32 << 16 + _GRIDPOINTS_64 = 64 << 16 + _GRIDPOINTS_128 = 128 << 16 + + @staticmethod + def GRIDPOINTS(n: int) -> Flags: + """ + Fine-tune control over number of gridpoints + + :param n: :py:class:`int` in range ``0 <= n <= 255`` + """ + return Flags.NONE | ((n & 0xFF) << 16) + + +_MAX_FLAG = reduce(operator.or_, Flags) + + +_FLAGS = { + "MATRIXINPUT": 1, + "MATRIXOUTPUT": 2, + "MATRIXONLY": (1 | 2), + "NOWHITEONWHITEFIXUP": 4, # Don't hot fix scum dot + # Don't create prelinearization tables on precalculated transforms + # (internal use): + "NOPRELINEARIZATION": 16, + "GUESSDEVICECLASS": 32, # Guess device class (for transform2devicelink) + "NOTCACHE": 64, # Inhibit 1-pixel cache + "NOTPRECALC": 256, + "NULLTRANSFORM": 512, # Don't transform anyway + "HIGHRESPRECALC": 1024, # Use more memory to give better accuracy + "LOWRESPRECALC": 2048, # Use less memory to minimize resources + "WHITEBLACKCOMPENSATION": 8192, + "BLACKPOINTCOMPENSATION": 8192, + "GAMUTCHECK": 4096, # Out of Gamut alarm + "SOFTPROOFING": 16384, # Do softproofing + "PRESERVEBLACK": 32768, # Black preservation + "NODEFAULTRESOURCEDEF": 16777216, # CRD special + "GRIDPOINTS": lambda n: (n & 0xFF) << 16, # Gridpoints +} + + +# --------------------------------------------------------------------. +# Experimental PIL-level API +# --------------------------------------------------------------------. + +## +# Profile. + + +class ImageCmsProfile: + def __init__(self, profile: str | SupportsRead[bytes] | core.CmsProfile) -> None: + """ + :param profile: Either a string representing a filename, + a file like object containing a profile or a + low-level profile object + + """ + self.filename: str | None = None + + if isinstance(profile, str): + if sys.platform == "win32": + profile_bytes_path = profile.encode() + try: + profile_bytes_path.decode("ascii") + except UnicodeDecodeError: + with open(profile, "rb") as f: + self.profile = core.profile_frombytes(f.read()) + return + self.filename = profile + self.profile = core.profile_open(profile) + elif hasattr(profile, "read"): + self.profile = core.profile_frombytes(profile.read()) + elif isinstance(profile, core.CmsProfile): + self.profile = profile + else: + msg = "Invalid type for Profile" # type: ignore[unreachable] + raise TypeError(msg) + + def __getattr__(self, name: str) -> Any: + if name in ("product_name", "product_info"): + deprecate(f"ImageCms.ImageCmsProfile.{name}", 13) + return None + msg = f"'{self.__class__.__name__}' object has no attribute '{name}'" + raise AttributeError(msg) + + def tobytes(self) -> bytes: + """ + Returns the profile in a format suitable for embedding in + saved images. + + :returns: a bytes object containing the ICC profile. + """ + + return core.profile_tobytes(self.profile) + + +class ImageCmsTransform(Image.ImagePointHandler): + """ + Transform. This can be used with the procedural API, or with the standard + :py:func:`~PIL.Image.Image.point` method. + + Will return the output profile in the ``output.info['icc_profile']``. + """ + + def __init__( + self, + input: ImageCmsProfile, + output: ImageCmsProfile, + input_mode: str, + output_mode: str, + intent: Intent = Intent.PERCEPTUAL, + proof: ImageCmsProfile | None = None, + proof_intent: Intent = Intent.ABSOLUTE_COLORIMETRIC, + flags: Flags = Flags.NONE, + ): + if proof is None: + self.transform = core.buildTransform( + input.profile, output.profile, input_mode, output_mode, intent, flags + ) + else: + self.transform = core.buildProofTransform( + input.profile, + output.profile, + proof.profile, + input_mode, + output_mode, + intent, + proof_intent, + flags, + ) + # Note: inputMode and outputMode are for pyCMS compatibility only + self.input_mode = self.inputMode = input_mode + self.output_mode = self.outputMode = output_mode + + self.output_profile = output + + def point(self, im: Image.Image) -> Image.Image: + return self.apply(im) + + def apply(self, im: Image.Image, imOut: Image.Image | None = None) -> Image.Image: + if imOut is None: + imOut = Image.new(self.output_mode, im.size, None) + self.transform.apply(im.getim(), imOut.getim()) + imOut.info["icc_profile"] = self.output_profile.tobytes() + return imOut + + def apply_in_place(self, im: Image.Image) -> Image.Image: + if im.mode != self.output_mode: + msg = "mode mismatch" + raise ValueError(msg) # wrong output mode + self.transform.apply(im.getim(), im.getim()) + im.info["icc_profile"] = self.output_profile.tobytes() + return im + + +def get_display_profile(handle: SupportsInt | None = None) -> ImageCmsProfile | None: + """ + (experimental) Fetches the profile for the current display device. + + :returns: ``None`` if the profile is not known. + """ + + if sys.platform != "win32": + return None + + from . import ImageWin # type: ignore[unused-ignore, unreachable] + + if isinstance(handle, ImageWin.HDC): + profile = core.get_display_profile_win32(int(handle), 1) + else: + profile = core.get_display_profile_win32(int(handle or 0)) + if profile is None: + return None + return ImageCmsProfile(profile) + + +# --------------------------------------------------------------------. +# pyCMS compatible layer +# --------------------------------------------------------------------. + + +class PyCMSError(Exception): + """(pyCMS) Exception class. + This is used for all errors in the pyCMS API.""" + + pass + + +def profileToProfile( + im: Image.Image, + inputProfile: _CmsProfileCompatible, + outputProfile: _CmsProfileCompatible, + renderingIntent: Intent = Intent.PERCEPTUAL, + outputMode: str | None = None, + inPlace: bool = False, + flags: Flags = Flags.NONE, +) -> Image.Image | None: + """ + (pyCMS) Applies an ICC transformation to a given image, mapping from + ``inputProfile`` to ``outputProfile``. + + If the input or output profiles specified are not valid filenames, a + :exc:`PyCMSError` will be raised. If ``inPlace`` is ``True`` and + ``outputMode != im.mode``, a :exc:`PyCMSError` will be raised. + If an error occurs during application of the profiles, + a :exc:`PyCMSError` will be raised. + If ``outputMode`` is not a mode supported by the ``outputProfile`` (or by pyCMS), + a :exc:`PyCMSError` will be raised. + + This function applies an ICC transformation to im from ``inputProfile``'s + color space to ``outputProfile``'s color space using the specified rendering + intent to decide how to handle out-of-gamut colors. + + ``outputMode`` can be used to specify that a color mode conversion is to + be done using these profiles, but the specified profiles must be able + to handle that mode. I.e., if converting im from RGB to CMYK using + profiles, the input profile must handle RGB data, and the output + profile must handle CMYK data. + + :param im: An open :py:class:`~PIL.Image.Image` object (i.e. Image.new(...) + or Image.open(...), etc.) + :param inputProfile: String, as a valid filename path to the ICC input + profile you wish to use for this image, or a profile object + :param outputProfile: String, as a valid filename path to the ICC output + profile you wish to use for this image, or a profile object + :param renderingIntent: Integer (0-3) specifying the rendering intent you + wish to use for the transform + + ImageCms.Intent.PERCEPTUAL = 0 (DEFAULT) + ImageCms.Intent.RELATIVE_COLORIMETRIC = 1 + ImageCms.Intent.SATURATION = 2 + ImageCms.Intent.ABSOLUTE_COLORIMETRIC = 3 + + see the pyCMS documentation for details on rendering intents and what + they do. + :param outputMode: A valid PIL mode for the output image (i.e. "RGB", + "CMYK", etc.). Note: if rendering the image "inPlace", outputMode + MUST be the same mode as the input, or omitted completely. If + omitted, the outputMode will be the same as the mode of the input + image (im.mode) + :param inPlace: Boolean. If ``True``, the original image is modified in-place, + and ``None`` is returned. If ``False`` (default), a new + :py:class:`~PIL.Image.Image` object is returned with the transform applied. + :param flags: Integer (0-...) specifying additional flags + :returns: Either None or a new :py:class:`~PIL.Image.Image` object, depending on + the value of ``inPlace`` + :exception PyCMSError: + """ + + if outputMode is None: + outputMode = im.mode + + if not isinstance(renderingIntent, int) or not (0 <= renderingIntent <= 3): + msg = "renderingIntent must be an integer between 0 and 3" + raise PyCMSError(msg) + + if not isinstance(flags, int) or not (0 <= flags <= _MAX_FLAG): + msg = f"flags must be an integer between 0 and {_MAX_FLAG}" + raise PyCMSError(msg) + + try: + if not isinstance(inputProfile, ImageCmsProfile): + inputProfile = ImageCmsProfile(inputProfile) + if not isinstance(outputProfile, ImageCmsProfile): + outputProfile = ImageCmsProfile(outputProfile) + transform = ImageCmsTransform( + inputProfile, + outputProfile, + im.mode, + outputMode, + renderingIntent, + flags=flags, + ) + if inPlace: + transform.apply_in_place(im) + imOut = None + else: + imOut = transform.apply(im) + except (OSError, TypeError, ValueError) as v: + raise PyCMSError(v) from v + + return imOut + + +def getOpenProfile( + profileFilename: str | SupportsRead[bytes] | core.CmsProfile, +) -> ImageCmsProfile: + """ + (pyCMS) Opens an ICC profile file. + + The PyCMSProfile object can be passed back into pyCMS for use in creating + transforms and such (as in ImageCms.buildTransformFromOpenProfiles()). + + If ``profileFilename`` is not a valid filename for an ICC profile, + a :exc:`PyCMSError` will be raised. + + :param profileFilename: String, as a valid filename path to the ICC profile + you wish to open, or a file-like object. + :returns: A CmsProfile class object. + :exception PyCMSError: + """ + + try: + return ImageCmsProfile(profileFilename) + except (OSError, TypeError, ValueError) as v: + raise PyCMSError(v) from v + + +def buildTransform( + inputProfile: _CmsProfileCompatible, + outputProfile: _CmsProfileCompatible, + inMode: str, + outMode: str, + renderingIntent: Intent = Intent.PERCEPTUAL, + flags: Flags = Flags.NONE, +) -> ImageCmsTransform: + """ + (pyCMS) Builds an ICC transform mapping from the ``inputProfile`` to the + ``outputProfile``. Use applyTransform to apply the transform to a given + image. + + If the input or output profiles specified are not valid filenames, a + :exc:`PyCMSError` will be raised. If an error occurs during creation + of the transform, a :exc:`PyCMSError` will be raised. + + If ``inMode`` or ``outMode`` are not a mode supported by the ``outputProfile`` + (or by pyCMS), a :exc:`PyCMSError` will be raised. + + This function builds and returns an ICC transform from the ``inputProfile`` + to the ``outputProfile`` using the ``renderingIntent`` to determine what to do + with out-of-gamut colors. It will ONLY work for converting images that + are in ``inMode`` to images that are in ``outMode`` color format (PIL mode, + i.e. "RGB", "RGBA", "CMYK", etc.). + + Building the transform is a fair part of the overhead in + ImageCms.profileToProfile(), so if you're planning on converting multiple + images using the same input/output settings, this can save you time. + Once you have a transform object, it can be used with + ImageCms.applyProfile() to convert images without the need to re-compute + the lookup table for the transform. + + The reason pyCMS returns a class object rather than a handle directly + to the transform is that it needs to keep track of the PIL input/output + modes that the transform is meant for. These attributes are stored in + the ``inMode`` and ``outMode`` attributes of the object (which can be + manually overridden if you really want to, but I don't know of any + time that would be of use, or would even work). + + :param inputProfile: String, as a valid filename path to the ICC input + profile you wish to use for this transform, or a profile object + :param outputProfile: String, as a valid filename path to the ICC output + profile you wish to use for this transform, or a profile object + :param inMode: String, as a valid PIL mode that the appropriate profile + also supports (i.e. "RGB", "RGBA", "CMYK", etc.) + :param outMode: String, as a valid PIL mode that the appropriate profile + also supports (i.e. "RGB", "RGBA", "CMYK", etc.) + :param renderingIntent: Integer (0-3) specifying the rendering intent you + wish to use for the transform + + ImageCms.Intent.PERCEPTUAL = 0 (DEFAULT) + ImageCms.Intent.RELATIVE_COLORIMETRIC = 1 + ImageCms.Intent.SATURATION = 2 + ImageCms.Intent.ABSOLUTE_COLORIMETRIC = 3 + + see the pyCMS documentation for details on rendering intents and what + they do. + :param flags: Integer (0-...) specifying additional flags + :returns: A CmsTransform class object. + :exception PyCMSError: + """ + + if not isinstance(renderingIntent, int) or not (0 <= renderingIntent <= 3): + msg = "renderingIntent must be an integer between 0 and 3" + raise PyCMSError(msg) + + if not isinstance(flags, int) or not (0 <= flags <= _MAX_FLAG): + msg = f"flags must be an integer between 0 and {_MAX_FLAG}" + raise PyCMSError(msg) + + try: + if not isinstance(inputProfile, ImageCmsProfile): + inputProfile = ImageCmsProfile(inputProfile) + if not isinstance(outputProfile, ImageCmsProfile): + outputProfile = ImageCmsProfile(outputProfile) + return ImageCmsTransform( + inputProfile, outputProfile, inMode, outMode, renderingIntent, flags=flags + ) + except (OSError, TypeError, ValueError) as v: + raise PyCMSError(v) from v + + +def buildProofTransform( + inputProfile: _CmsProfileCompatible, + outputProfile: _CmsProfileCompatible, + proofProfile: _CmsProfileCompatible, + inMode: str, + outMode: str, + renderingIntent: Intent = Intent.PERCEPTUAL, + proofRenderingIntent: Intent = Intent.ABSOLUTE_COLORIMETRIC, + flags: Flags = Flags.SOFTPROOFING, +) -> ImageCmsTransform: + """ + (pyCMS) Builds an ICC transform mapping from the ``inputProfile`` to the + ``outputProfile``, but tries to simulate the result that would be + obtained on the ``proofProfile`` device. + + If the input, output, or proof profiles specified are not valid + filenames, a :exc:`PyCMSError` will be raised. + + If an error occurs during creation of the transform, + a :exc:`PyCMSError` will be raised. + + If ``inMode`` or ``outMode`` are not a mode supported by the ``outputProfile`` + (or by pyCMS), a :exc:`PyCMSError` will be raised. + + This function builds and returns an ICC transform from the ``inputProfile`` + to the ``outputProfile``, but tries to simulate the result that would be + obtained on the ``proofProfile`` device using ``renderingIntent`` and + ``proofRenderingIntent`` to determine what to do with out-of-gamut + colors. This is known as "soft-proofing". It will ONLY work for + converting images that are in ``inMode`` to images that are in outMode + color format (PIL mode, i.e. "RGB", "RGBA", "CMYK", etc.). + + Usage of the resulting transform object is exactly the same as with + ImageCms.buildTransform(). + + Proof profiling is generally used when using an output device to get a + good idea of what the final printed/displayed image would look like on + the ``proofProfile`` device when it's quicker and easier to use the + output device for judging color. Generally, this means that the + output device is a monitor, or a dye-sub printer (etc.), and the simulated + device is something more expensive, complicated, or time consuming + (making it difficult to make a real print for color judgement purposes). + + Soft-proofing basically functions by adjusting the colors on the + output device to match the colors of the device being simulated. However, + when the simulated device has a much wider gamut than the output + device, you may obtain marginal results. + + :param inputProfile: String, as a valid filename path to the ICC input + profile you wish to use for this transform, or a profile object + :param outputProfile: String, as a valid filename path to the ICC output + (monitor, usually) profile you wish to use for this transform, or a + profile object + :param proofProfile: String, as a valid filename path to the ICC proof + profile you wish to use for this transform, or a profile object + :param inMode: String, as a valid PIL mode that the appropriate profile + also supports (i.e. "RGB", "RGBA", "CMYK", etc.) + :param outMode: String, as a valid PIL mode that the appropriate profile + also supports (i.e. "RGB", "RGBA", "CMYK", etc.) + :param renderingIntent: Integer (0-3) specifying the rendering intent you + wish to use for the input->proof (simulated) transform + + ImageCms.Intent.PERCEPTUAL = 0 (DEFAULT) + ImageCms.Intent.RELATIVE_COLORIMETRIC = 1 + ImageCms.Intent.SATURATION = 2 + ImageCms.Intent.ABSOLUTE_COLORIMETRIC = 3 + + see the pyCMS documentation for details on rendering intents and what + they do. + :param proofRenderingIntent: Integer (0-3) specifying the rendering intent + you wish to use for proof->output transform + + ImageCms.Intent.PERCEPTUAL = 0 (DEFAULT) + ImageCms.Intent.RELATIVE_COLORIMETRIC = 1 + ImageCms.Intent.SATURATION = 2 + ImageCms.Intent.ABSOLUTE_COLORIMETRIC = 3 + + see the pyCMS documentation for details on rendering intents and what + they do. + :param flags: Integer (0-...) specifying additional flags + :returns: A CmsTransform class object. + :exception PyCMSError: + """ + + if not isinstance(renderingIntent, int) or not (0 <= renderingIntent <= 3): + msg = "renderingIntent must be an integer between 0 and 3" + raise PyCMSError(msg) + + if not isinstance(flags, int) or not (0 <= flags <= _MAX_FLAG): + msg = f"flags must be an integer between 0 and {_MAX_FLAG}" + raise PyCMSError(msg) + + try: + if not isinstance(inputProfile, ImageCmsProfile): + inputProfile = ImageCmsProfile(inputProfile) + if not isinstance(outputProfile, ImageCmsProfile): + outputProfile = ImageCmsProfile(outputProfile) + if not isinstance(proofProfile, ImageCmsProfile): + proofProfile = ImageCmsProfile(proofProfile) + return ImageCmsTransform( + inputProfile, + outputProfile, + inMode, + outMode, + renderingIntent, + proofProfile, + proofRenderingIntent, + flags, + ) + except (OSError, TypeError, ValueError) as v: + raise PyCMSError(v) from v + + +buildTransformFromOpenProfiles = buildTransform +buildProofTransformFromOpenProfiles = buildProofTransform + + +def applyTransform( + im: Image.Image, transform: ImageCmsTransform, inPlace: bool = False +) -> Image.Image | None: + """ + (pyCMS) Applies a transform to a given image. + + If ``im.mode != transform.input_mode``, a :exc:`PyCMSError` is raised. + + If ``inPlace`` is ``True`` and ``transform.input_mode != transform.output_mode``, a + :exc:`PyCMSError` is raised. + + If ``im.mode``, ``transform.input_mode`` or ``transform.output_mode`` is not + supported by pyCMSdll or the profiles you used for the transform, a + :exc:`PyCMSError` is raised. + + If an error occurs while the transform is being applied, + a :exc:`PyCMSError` is raised. + + This function applies a pre-calculated transform (from + ImageCms.buildTransform() or ImageCms.buildTransformFromOpenProfiles()) + to an image. The transform can be used for multiple images, saving + considerable calculation time if doing the same conversion multiple times. + + If you want to modify im in-place instead of receiving a new image as + the return value, set ``inPlace`` to ``True``. This can only be done if + ``transform.input_mode`` and ``transform.output_mode`` are the same, because we + can't change the mode in-place (the buffer sizes for some modes are + different). The default behavior is to return a new :py:class:`~PIL.Image.Image` + object of the same dimensions in mode ``transform.output_mode``. + + :param im: An :py:class:`~PIL.Image.Image` object, and ``im.mode`` must be the same + as the ``input_mode`` supported by the transform. + :param transform: A valid CmsTransform class object + :param inPlace: Bool. If ``True``, ``im`` is modified in place and ``None`` is + returned, if ``False``, a new :py:class:`~PIL.Image.Image` object with the + transform applied is returned (and ``im`` is not changed). The default is + ``False``. + :returns: Either ``None``, or a new :py:class:`~PIL.Image.Image` object, + depending on the value of ``inPlace``. The profile will be returned in + the image's ``info['icc_profile']``. + :exception PyCMSError: + """ + + try: + if inPlace: + transform.apply_in_place(im) + imOut = None + else: + imOut = transform.apply(im) + except (TypeError, ValueError) as v: + raise PyCMSError(v) from v + + return imOut + + +def createProfile( + colorSpace: Literal["LAB", "XYZ", "sRGB"], colorTemp: SupportsFloat = 0 +) -> core.CmsProfile: + """ + (pyCMS) Creates a profile. + + If colorSpace not in ``["LAB", "XYZ", "sRGB"]``, + a :exc:`PyCMSError` is raised. + + If using LAB and ``colorTemp`` is not a positive integer, + a :exc:`PyCMSError` is raised. + + If an error occurs while creating the profile, + a :exc:`PyCMSError` is raised. + + Use this function to create common profiles on-the-fly instead of + having to supply a profile on disk and knowing the path to it. It + returns a normal CmsProfile object that can be passed to + ImageCms.buildTransformFromOpenProfiles() to create a transform to apply + to images. + + :param colorSpace: String, the color space of the profile you wish to + create. + Currently only "LAB", "XYZ", and "sRGB" are supported. + :param colorTemp: Positive number for the white point for the profile, in + degrees Kelvin (i.e. 5000, 6500, 9600, etc.). The default is for D50 + illuminant if omitted (5000k). colorTemp is ONLY applied to LAB + profiles, and is ignored for XYZ and sRGB. + :returns: A CmsProfile class object + :exception PyCMSError: + """ + + if colorSpace not in ["LAB", "XYZ", "sRGB"]: + msg = ( + f"Color space not supported for on-the-fly profile creation ({colorSpace})" + ) + raise PyCMSError(msg) + + if colorSpace == "LAB": + try: + colorTemp = float(colorTemp) + except (TypeError, ValueError) as e: + msg = f'Color temperature must be numeric, "{colorTemp}" not valid' + raise PyCMSError(msg) from e + + try: + return core.createProfile(colorSpace, colorTemp) + except (TypeError, ValueError) as v: + raise PyCMSError(v) from v + + +def getProfileName(profile: _CmsProfileCompatible) -> str: + """ + + (pyCMS) Gets the internal product name for the given profile. + + If ``profile`` isn't a valid CmsProfile object or filename to a profile, + a :exc:`PyCMSError` is raised If an error occurs while trying + to obtain the name tag, a :exc:`PyCMSError` is raised. + + Use this function to obtain the INTERNAL name of the profile (stored + in an ICC tag in the profile itself), usually the one used when the + profile was originally created. Sometimes this tag also contains + additional information supplied by the creator. + + :param profile: EITHER a valid CmsProfile object, OR a string of the + filename of an ICC profile. + :returns: A string containing the internal name of the profile as stored + in an ICC tag. + :exception PyCMSError: + """ + + try: + # add an extra newline to preserve pyCMS compatibility + if not isinstance(profile, ImageCmsProfile): + profile = ImageCmsProfile(profile) + # do it in python, not c. + # // name was "%s - %s" (model, manufacturer) || Description , + # // but if the Model and Manufacturer were the same or the model + # // was long, Just the model, in 1.x + model = profile.profile.model + manufacturer = profile.profile.manufacturer + + if not (model or manufacturer): + return (profile.profile.profile_description or "") + "\n" + if not manufacturer or (model and len(model) > 30): + return f"{model}\n" + return f"{model} - {manufacturer}\n" + + except (AttributeError, OSError, TypeError, ValueError) as v: + raise PyCMSError(v) from v + + +def getProfileInfo(profile: _CmsProfileCompatible) -> str: + """ + (pyCMS) Gets the internal product information for the given profile. + + If ``profile`` isn't a valid CmsProfile object or filename to a profile, + a :exc:`PyCMSError` is raised. + + If an error occurs while trying to obtain the info tag, + a :exc:`PyCMSError` is raised. + + Use this function to obtain the information stored in the profile's + info tag. This often contains details about the profile, and how it + was created, as supplied by the creator. + + :param profile: EITHER a valid CmsProfile object, OR a string of the + filename of an ICC profile. + :returns: A string containing the internal profile information stored in + an ICC tag. + :exception PyCMSError: + """ + + try: + if not isinstance(profile, ImageCmsProfile): + profile = ImageCmsProfile(profile) + # add an extra newline to preserve pyCMS compatibility + # Python, not C. the white point bits weren't working well, + # so skipping. + # info was description \r\n\r\n copyright \r\n\r\n K007 tag \r\n\r\n whitepoint + description = profile.profile.profile_description + cpright = profile.profile.copyright + elements = [element for element in (description, cpright) if element] + return "\r\n\r\n".join(elements) + "\r\n\r\n" + + except (AttributeError, OSError, TypeError, ValueError) as v: + raise PyCMSError(v) from v + + +def getProfileCopyright(profile: _CmsProfileCompatible) -> str: + """ + (pyCMS) Gets the copyright for the given profile. + + If ``profile`` isn't a valid CmsProfile object or filename to a profile, a + :exc:`PyCMSError` is raised. + + If an error occurs while trying to obtain the copyright tag, + a :exc:`PyCMSError` is raised. + + Use this function to obtain the information stored in the profile's + copyright tag. + + :param profile: EITHER a valid CmsProfile object, OR a string of the + filename of an ICC profile. + :returns: A string containing the internal profile information stored in + an ICC tag. + :exception PyCMSError: + """ + try: + # add an extra newline to preserve pyCMS compatibility + if not isinstance(profile, ImageCmsProfile): + profile = ImageCmsProfile(profile) + return (profile.profile.copyright or "") + "\n" + except (AttributeError, OSError, TypeError, ValueError) as v: + raise PyCMSError(v) from v + + +def getProfileManufacturer(profile: _CmsProfileCompatible) -> str: + """ + (pyCMS) Gets the manufacturer for the given profile. + + If ``profile`` isn't a valid CmsProfile object or filename to a profile, a + :exc:`PyCMSError` is raised. + + If an error occurs while trying to obtain the manufacturer tag, a + :exc:`PyCMSError` is raised. + + Use this function to obtain the information stored in the profile's + manufacturer tag. + + :param profile: EITHER a valid CmsProfile object, OR a string of the + filename of an ICC profile. + :returns: A string containing the internal profile information stored in + an ICC tag. + :exception PyCMSError: + """ + try: + # add an extra newline to preserve pyCMS compatibility + if not isinstance(profile, ImageCmsProfile): + profile = ImageCmsProfile(profile) + return (profile.profile.manufacturer or "") + "\n" + except (AttributeError, OSError, TypeError, ValueError) as v: + raise PyCMSError(v) from v + + +def getProfileModel(profile: _CmsProfileCompatible) -> str: + """ + (pyCMS) Gets the model for the given profile. + + If ``profile`` isn't a valid CmsProfile object or filename to a profile, a + :exc:`PyCMSError` is raised. + + If an error occurs while trying to obtain the model tag, + a :exc:`PyCMSError` is raised. + + Use this function to obtain the information stored in the profile's + model tag. + + :param profile: EITHER a valid CmsProfile object, OR a string of the + filename of an ICC profile. + :returns: A string containing the internal profile information stored in + an ICC tag. + :exception PyCMSError: + """ + + try: + # add an extra newline to preserve pyCMS compatibility + if not isinstance(profile, ImageCmsProfile): + profile = ImageCmsProfile(profile) + return (profile.profile.model or "") + "\n" + except (AttributeError, OSError, TypeError, ValueError) as v: + raise PyCMSError(v) from v + + +def getProfileDescription(profile: _CmsProfileCompatible) -> str: + """ + (pyCMS) Gets the description for the given profile. + + If ``profile`` isn't a valid CmsProfile object or filename to a profile, a + :exc:`PyCMSError` is raised. + + If an error occurs while trying to obtain the description tag, + a :exc:`PyCMSError` is raised. + + Use this function to obtain the information stored in the profile's + description tag. + + :param profile: EITHER a valid CmsProfile object, OR a string of the + filename of an ICC profile. + :returns: A string containing the internal profile information stored in an + ICC tag. + :exception PyCMSError: + """ + + try: + # add an extra newline to preserve pyCMS compatibility + if not isinstance(profile, ImageCmsProfile): + profile = ImageCmsProfile(profile) + return (profile.profile.profile_description or "") + "\n" + except (AttributeError, OSError, TypeError, ValueError) as v: + raise PyCMSError(v) from v + + +def getDefaultIntent(profile: _CmsProfileCompatible) -> int: + """ + (pyCMS) Gets the default intent name for the given profile. + + If ``profile`` isn't a valid CmsProfile object or filename to a profile, a + :exc:`PyCMSError` is raised. + + If an error occurs while trying to obtain the default intent, a + :exc:`PyCMSError` is raised. + + Use this function to determine the default (and usually best optimized) + rendering intent for this profile. Most profiles support multiple + rendering intents, but are intended mostly for one type of conversion. + If you wish to use a different intent than returned, use + ImageCms.isIntentSupported() to verify it will work first. + + :param profile: EITHER a valid CmsProfile object, OR a string of the + filename of an ICC profile. + :returns: Integer 0-3 specifying the default rendering intent for this + profile. + + ImageCms.Intent.PERCEPTUAL = 0 (DEFAULT) + ImageCms.Intent.RELATIVE_COLORIMETRIC = 1 + ImageCms.Intent.SATURATION = 2 + ImageCms.Intent.ABSOLUTE_COLORIMETRIC = 3 + + see the pyCMS documentation for details on rendering intents and what + they do. + :exception PyCMSError: + """ + + try: + if not isinstance(profile, ImageCmsProfile): + profile = ImageCmsProfile(profile) + return profile.profile.rendering_intent + except (AttributeError, OSError, TypeError, ValueError) as v: + raise PyCMSError(v) from v + + +def isIntentSupported( + profile: _CmsProfileCompatible, intent: Intent, direction: Direction +) -> Literal[-1, 1]: + """ + (pyCMS) Checks if a given intent is supported. + + Use this function to verify that you can use your desired + ``intent`` with ``profile``, and that ``profile`` can be used for the + input/output/proof profile as you desire. + + Some profiles are created specifically for one "direction", can cannot + be used for others. Some profiles can only be used for certain + rendering intents, so it's best to either verify this before trying + to create a transform with them (using this function), or catch the + potential :exc:`PyCMSError` that will occur if they don't + support the modes you select. + + :param profile: EITHER a valid CmsProfile object, OR a string of the + filename of an ICC profile. + :param intent: Integer (0-3) specifying the rendering intent you wish to + use with this profile + + ImageCms.Intent.PERCEPTUAL = 0 (DEFAULT) + ImageCms.Intent.RELATIVE_COLORIMETRIC = 1 + ImageCms.Intent.SATURATION = 2 + ImageCms.Intent.ABSOLUTE_COLORIMETRIC = 3 + + see the pyCMS documentation for details on rendering intents and what + they do. + :param direction: Integer specifying if the profile is to be used for + input, output, or proof + + INPUT = 0 (or use ImageCms.Direction.INPUT) + OUTPUT = 1 (or use ImageCms.Direction.OUTPUT) + PROOF = 2 (or use ImageCms.Direction.PROOF) + + :returns: 1 if the intent/direction are supported, -1 if they are not. + :exception PyCMSError: + """ + + try: + if not isinstance(profile, ImageCmsProfile): + profile = ImageCmsProfile(profile) + # FIXME: I get different results for the same data w. different + # compilers. Bug in LittleCMS or in the binding? + if profile.profile.is_intent_supported(intent, direction): + return 1 + else: + return -1 + except (AttributeError, OSError, TypeError, ValueError) as v: + raise PyCMSError(v) from v diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/ImageColor.py b/presentation/.venv/lib/python3.12/site-packages/PIL/ImageColor.py new file mode 100644 index 0000000..9a15a8e --- /dev/null +++ b/presentation/.venv/lib/python3.12/site-packages/PIL/ImageColor.py @@ -0,0 +1,320 @@ +# +# The Python Imaging Library +# $Id$ +# +# map CSS3-style colour description strings to RGB +# +# History: +# 2002-10-24 fl Added support for CSS-style color strings +# 2002-12-15 fl Added RGBA support +# 2004-03-27 fl Fixed remaining int() problems for Python 1.5.2 +# 2004-07-19 fl Fixed gray/grey spelling issues +# 2009-03-05 fl Fixed rounding error in grayscale calculation +# +# Copyright (c) 2002-2004 by Secret Labs AB +# Copyright (c) 2002-2004 by Fredrik Lundh +# +# See the README file for information on usage and redistribution. +# +from __future__ import annotations + +import re +from functools import lru_cache + +from . import Image + + +@lru_cache +def getrgb(color: str) -> tuple[int, int, int] | tuple[int, int, int, int]: + """ + Convert a color string to an RGB or RGBA tuple. If the string cannot be + parsed, this function raises a :py:exc:`ValueError` exception. + + .. versionadded:: 1.1.4 + + :param color: A color string + :return: ``(red, green, blue[, alpha])`` + """ + if len(color) > 100: + msg = "color specifier is too long" + raise ValueError(msg) + color = color.lower() + + rgb = colormap.get(color, None) + if rgb: + if isinstance(rgb, tuple): + return rgb + rgb_tuple = getrgb(rgb) + assert len(rgb_tuple) == 3 + colormap[color] = rgb_tuple + return rgb_tuple + + # check for known string formats + if re.match("#[a-f0-9]{3}$", color): + return int(color[1] * 2, 16), int(color[2] * 2, 16), int(color[3] * 2, 16) + + if re.match("#[a-f0-9]{4}$", color): + return ( + int(color[1] * 2, 16), + int(color[2] * 2, 16), + int(color[3] * 2, 16), + int(color[4] * 2, 16), + ) + + if re.match("#[a-f0-9]{6}$", color): + return int(color[1:3], 16), int(color[3:5], 16), int(color[5:7], 16) + + if re.match("#[a-f0-9]{8}$", color): + return ( + int(color[1:3], 16), + int(color[3:5], 16), + int(color[5:7], 16), + int(color[7:9], 16), + ) + + m = re.match(r"rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)$", color) + if m: + return int(m.group(1)), int(m.group(2)), int(m.group(3)) + + m = re.match(r"rgb\(\s*(\d+)%\s*,\s*(\d+)%\s*,\s*(\d+)%\s*\)$", color) + if m: + return ( + int((int(m.group(1)) * 255) / 100.0 + 0.5), + int((int(m.group(2)) * 255) / 100.0 + 0.5), + int((int(m.group(3)) * 255) / 100.0 + 0.5), + ) + + m = re.match( + r"hsl\(\s*(\d+\.?\d*)\s*,\s*(\d+\.?\d*)%\s*,\s*(\d+\.?\d*)%\s*\)$", color + ) + if m: + from colorsys import hls_to_rgb + + rgb_floats = hls_to_rgb( + float(m.group(1)) / 360.0, + float(m.group(3)) / 100.0, + float(m.group(2)) / 100.0, + ) + return ( + int(rgb_floats[0] * 255 + 0.5), + int(rgb_floats[1] * 255 + 0.5), + int(rgb_floats[2] * 255 + 0.5), + ) + + m = re.match( + r"hs[bv]\(\s*(\d+\.?\d*)\s*,\s*(\d+\.?\d*)%\s*,\s*(\d+\.?\d*)%\s*\)$", color + ) + if m: + from colorsys import hsv_to_rgb + + rgb_floats = hsv_to_rgb( + float(m.group(1)) / 360.0, + float(m.group(2)) / 100.0, + float(m.group(3)) / 100.0, + ) + return ( + int(rgb_floats[0] * 255 + 0.5), + int(rgb_floats[1] * 255 + 0.5), + int(rgb_floats[2] * 255 + 0.5), + ) + + m = re.match(r"rgba\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)$", color) + if m: + return int(m.group(1)), int(m.group(2)), int(m.group(3)), int(m.group(4)) + msg = f"unknown color specifier: {repr(color)}" + raise ValueError(msg) + + +@lru_cache +def getcolor(color: str, mode: str) -> int | tuple[int, ...]: + """ + Same as :py:func:`~PIL.ImageColor.getrgb` for most modes. However, if + ``mode`` is HSV, converts the RGB value to a HSV value, or if ``mode`` is + not color or a palette image, converts the RGB value to a grayscale value. + If the string cannot be parsed, this function raises a :py:exc:`ValueError` + exception. + + .. versionadded:: 1.1.4 + + :param color: A color string + :param mode: Convert result to this mode + :return: ``graylevel, (graylevel, alpha) or (red, green, blue[, alpha])`` + """ + # same as getrgb, but converts the result to the given mode + rgb, alpha = getrgb(color), 255 + if len(rgb) == 4: + alpha = rgb[3] + rgb = rgb[:3] + + if mode == "HSV": + from colorsys import rgb_to_hsv + + r, g, b = rgb + h, s, v = rgb_to_hsv(r / 255, g / 255, b / 255) + return int(h * 255), int(s * 255), int(v * 255) + elif Image.getmodebase(mode) == "L": + r, g, b = rgb + # ITU-R Recommendation 601-2 for nonlinear RGB + # scaled to 24 bits to match the convert's implementation. + graylevel = (r * 19595 + g * 38470 + b * 7471 + 0x8000) >> 16 + if mode[-1] == "A": + return graylevel, alpha + return graylevel + elif mode[-1] == "A": + return rgb + (alpha,) + return rgb + + +colormap: dict[str, str | tuple[int, int, int]] = { + # X11 colour table from https://drafts.csswg.org/css-color-4/, with + # gray/grey spelling issues fixed. This is a superset of HTML 4.0 + # colour names used in CSS 1. + "aliceblue": "#f0f8ff", + "antiquewhite": "#faebd7", + "aqua": "#00ffff", + "aquamarine": "#7fffd4", + "azure": "#f0ffff", + "beige": "#f5f5dc", + "bisque": "#ffe4c4", + "black": "#000000", + "blanchedalmond": "#ffebcd", + "blue": "#0000ff", + "blueviolet": "#8a2be2", + "brown": "#a52a2a", + "burlywood": "#deb887", + "cadetblue": "#5f9ea0", + "chartreuse": "#7fff00", + "chocolate": "#d2691e", + "coral": "#ff7f50", + "cornflowerblue": "#6495ed", + "cornsilk": "#fff8dc", + "crimson": "#dc143c", + "cyan": "#00ffff", + "darkblue": "#00008b", + "darkcyan": "#008b8b", + "darkgoldenrod": "#b8860b", + "darkgray": "#a9a9a9", + "darkgrey": "#a9a9a9", + "darkgreen": "#006400", + "darkkhaki": "#bdb76b", + "darkmagenta": "#8b008b", + "darkolivegreen": "#556b2f", + "darkorange": "#ff8c00", + "darkorchid": "#9932cc", + "darkred": "#8b0000", + "darksalmon": "#e9967a", + "darkseagreen": "#8fbc8f", + "darkslateblue": "#483d8b", + "darkslategray": "#2f4f4f", + "darkslategrey": "#2f4f4f", + "darkturquoise": "#00ced1", + "darkviolet": "#9400d3", + "deeppink": "#ff1493", + "deepskyblue": "#00bfff", + "dimgray": "#696969", + "dimgrey": "#696969", + "dodgerblue": "#1e90ff", + "firebrick": "#b22222", + "floralwhite": "#fffaf0", + "forestgreen": "#228b22", + "fuchsia": "#ff00ff", + "gainsboro": "#dcdcdc", + "ghostwhite": "#f8f8ff", + "gold": "#ffd700", + "goldenrod": "#daa520", + "gray": "#808080", + "grey": "#808080", + "green": "#008000", + "greenyellow": "#adff2f", + "honeydew": "#f0fff0", + "hotpink": "#ff69b4", + "indianred": "#cd5c5c", + "indigo": "#4b0082", + "ivory": "#fffff0", + "khaki": "#f0e68c", + "lavender": "#e6e6fa", + "lavenderblush": "#fff0f5", + "lawngreen": "#7cfc00", + "lemonchiffon": "#fffacd", + "lightblue": "#add8e6", + "lightcoral": "#f08080", + "lightcyan": "#e0ffff", + "lightgoldenrodyellow": "#fafad2", + "lightgreen": "#90ee90", + "lightgray": "#d3d3d3", + "lightgrey": "#d3d3d3", + "lightpink": "#ffb6c1", + "lightsalmon": "#ffa07a", + "lightseagreen": "#20b2aa", + "lightskyblue": "#87cefa", + "lightslategray": "#778899", + "lightslategrey": "#778899", + "lightsteelblue": "#b0c4de", + "lightyellow": "#ffffe0", + "lime": "#00ff00", + "limegreen": "#32cd32", + "linen": "#faf0e6", + "magenta": "#ff00ff", + "maroon": "#800000", + "mediumaquamarine": "#66cdaa", + "mediumblue": "#0000cd", + "mediumorchid": "#ba55d3", + "mediumpurple": "#9370db", + "mediumseagreen": "#3cb371", + "mediumslateblue": "#7b68ee", + "mediumspringgreen": "#00fa9a", + "mediumturquoise": "#48d1cc", + "mediumvioletred": "#c71585", + "midnightblue": "#191970", + "mintcream": "#f5fffa", + "mistyrose": "#ffe4e1", + "moccasin": "#ffe4b5", + "navajowhite": "#ffdead", + "navy": "#000080", + "oldlace": "#fdf5e6", + "olive": "#808000", + "olivedrab": "#6b8e23", + "orange": "#ffa500", + "orangered": "#ff4500", + "orchid": "#da70d6", + "palegoldenrod": "#eee8aa", + "palegreen": "#98fb98", + "paleturquoise": "#afeeee", + "palevioletred": "#db7093", + "papayawhip": "#ffefd5", + "peachpuff": "#ffdab9", + "peru": "#cd853f", + "pink": "#ffc0cb", + "plum": "#dda0dd", + "powderblue": "#b0e0e6", + "purple": "#800080", + "rebeccapurple": "#663399", + "red": "#ff0000", + "rosybrown": "#bc8f8f", + "royalblue": "#4169e1", + "saddlebrown": "#8b4513", + "salmon": "#fa8072", + "sandybrown": "#f4a460", + "seagreen": "#2e8b57", + "seashell": "#fff5ee", + "sienna": "#a0522d", + "silver": "#c0c0c0", + "skyblue": "#87ceeb", + "slateblue": "#6a5acd", + "slategray": "#708090", + "slategrey": "#708090", + "snow": "#fffafa", + "springgreen": "#00ff7f", + "steelblue": "#4682b4", + "tan": "#d2b48c", + "teal": "#008080", + "thistle": "#d8bfd8", + "tomato": "#ff6347", + "turquoise": "#40e0d0", + "violet": "#ee82ee", + "wheat": "#f5deb3", + "white": "#ffffff", + "whitesmoke": "#f5f5f5", + "yellow": "#ffff00", + "yellowgreen": "#9acd32", +} diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/ImageDraw.py b/presentation/.venv/lib/python3.12/site-packages/PIL/ImageDraw.py new file mode 100644 index 0000000..9b0864d --- /dev/null +++ b/presentation/.venv/lib/python3.12/site-packages/PIL/ImageDraw.py @@ -0,0 +1,1035 @@ +# +# The Python Imaging Library +# $Id$ +# +# drawing interface operations +# +# History: +# 1996-04-13 fl Created (experimental) +# 1996-08-07 fl Filled polygons, ellipses. +# 1996-08-13 fl Added text support +# 1998-06-28 fl Handle I and F images +# 1998-12-29 fl Added arc; use arc primitive to draw ellipses +# 1999-01-10 fl Added shape stuff (experimental) +# 1999-02-06 fl Added bitmap support +# 1999-02-11 fl Changed all primitives to take options +# 1999-02-20 fl Fixed backwards compatibility +# 2000-10-12 fl Copy on write, when necessary +# 2001-02-18 fl Use default ink for bitmap/text also in fill mode +# 2002-10-24 fl Added support for CSS-style color strings +# 2002-12-10 fl Added experimental support for RGBA-on-RGB drawing +# 2002-12-11 fl Refactored low-level drawing API (work in progress) +# 2004-08-26 fl Made Draw() a factory function, added getdraw() support +# 2004-09-04 fl Added width support to line primitive +# 2004-09-10 fl Added font mode handling +# 2006-06-19 fl Added font bearing support (getmask2) +# +# Copyright (c) 1997-2006 by Secret Labs AB +# Copyright (c) 1996-2006 by Fredrik Lundh +# +# See the README file for information on usage and redistribution. +# +from __future__ import annotations + +import math +import struct +from collections.abc import Sequence +from typing import cast + +from . import Image, ImageColor, ImageText + +TYPE_CHECKING = False +if TYPE_CHECKING: + from collections.abc import Callable + from types import ModuleType + from typing import Any, AnyStr + + from . import ImageDraw2, ImageFont + from ._typing import Coords, _Ink + +# experimental access to the outline API +Outline: Callable[[], Image.core._Outline] = Image.core.outline + +""" +A simple 2D drawing interface for PIL images. +<p> +Application code should use the <b>Draw</b> factory, instead of +directly. +""" + + +class ImageDraw: + font: ( + ImageFont.ImageFont | ImageFont.FreeTypeFont | ImageFont.TransposedFont | None + ) = None + + def __init__(self, im: Image.Image, mode: str | None = None) -> None: + """ + Create a drawing instance. + + :param im: The image to draw in. + :param mode: Optional mode to use for color values. For RGB + images, this argument can be RGB or RGBA (to blend the + drawing into the image). For all other modes, this argument + must be the same as the image mode. If omitted, the mode + defaults to the mode of the image. + """ + im._ensure_mutable() + blend = 0 + if mode is None: + mode = im.mode + if mode != im.mode: + if mode == "RGBA" and im.mode == "RGB": + blend = 1 + else: + msg = "mode mismatch" + raise ValueError(msg) + if mode == "P": + self.palette = im.palette + else: + self.palette = None + self._image = im + self.im = im.im + self.draw = Image.core.draw(self.im, blend) + self.mode = mode + if mode in ("I", "F"): + self.ink = self.draw.draw_ink(1) + else: + self.ink = self.draw.draw_ink(-1) + if mode in ("1", "P", "I", "F"): + # FIXME: fix Fill2 to properly support matte for I+F images + self.fontmode = "1" + else: + self.fontmode = "L" # aliasing is okay for other modes + self.fill = False + + def getfont( + self, + ) -> ImageFont.ImageFont | ImageFont.FreeTypeFont | ImageFont.TransposedFont: + """ + Get the current default font. + + To set the default font for this ImageDraw instance:: + + from PIL import ImageDraw, ImageFont + draw.font = ImageFont.truetype("Tests/fonts/FreeMono.ttf") + + To set the default font for all future ImageDraw instances:: + + from PIL import ImageDraw, ImageFont + ImageDraw.ImageDraw.font = ImageFont.truetype("Tests/fonts/FreeMono.ttf") + + If the current default font is ``None``, + it is initialized with ``ImageFont.load_default()``. + + :returns: An image font.""" + if not self.font: + # FIXME: should add a font repository + from . import ImageFont + + self.font = ImageFont.load_default() + return self.font + + def _getfont( + self, font_size: float | None + ) -> ImageFont.ImageFont | ImageFont.FreeTypeFont | ImageFont.TransposedFont: + if font_size is not None: + from . import ImageFont + + return ImageFont.load_default(font_size) + else: + return self.getfont() + + def _getink( + self, ink: _Ink | None, fill: _Ink | None = None + ) -> tuple[int | None, int | None]: + result_ink = None + result_fill = None + if ink is None and fill is None: + if self.fill: + result_fill = self.ink + else: + result_ink = self.ink + else: + if ink is not None: + if isinstance(ink, str): + ink = ImageColor.getcolor(ink, self.mode) + if self.palette and isinstance(ink, tuple): + ink = self.palette.getcolor(ink, self._image) + result_ink = self.draw.draw_ink(ink) + if fill is not None: + if isinstance(fill, str): + fill = ImageColor.getcolor(fill, self.mode) + if self.palette and isinstance(fill, tuple): + fill = self.palette.getcolor(fill, self._image) + result_fill = self.draw.draw_ink(fill) + return result_ink, result_fill + + def arc( + self, + xy: Coords, + start: float, + end: float, + fill: _Ink | None = None, + width: int = 1, + ) -> None: + """Draw an arc.""" + ink, fill = self._getink(fill) + if ink is not None: + self.draw.draw_arc(xy, start, end, ink, width) + + def bitmap( + self, xy: Sequence[int], bitmap: Image.Image, fill: _Ink | None = None + ) -> None: + """Draw a bitmap.""" + bitmap.load() + ink, fill = self._getink(fill) + if ink is None: + ink = fill + if ink is not None: + self.draw.draw_bitmap(xy, bitmap.im, ink) + + def chord( + self, + xy: Coords, + start: float, + end: float, + fill: _Ink | None = None, + outline: _Ink | None = None, + width: int = 1, + ) -> None: + """Draw a chord.""" + ink, fill_ink = self._getink(outline, fill) + if fill_ink is not None: + self.draw.draw_chord(xy, start, end, fill_ink, 1) + if ink is not None and ink != fill_ink and width != 0: + self.draw.draw_chord(xy, start, end, ink, 0, width) + + def ellipse( + self, + xy: Coords, + fill: _Ink | None = None, + outline: _Ink | None = None, + width: int = 1, + ) -> None: + """Draw an ellipse.""" + ink, fill_ink = self._getink(outline, fill) + if fill_ink is not None: + self.draw.draw_ellipse(xy, fill_ink, 1) + if ink is not None and ink != fill_ink and width != 0: + self.draw.draw_ellipse(xy, ink, 0, width) + + def circle( + self, + xy: Sequence[float], + radius: float, + fill: _Ink | None = None, + outline: _Ink | None = None, + width: int = 1, + ) -> None: + """Draw a circle given center coordinates and a radius.""" + ellipse_xy = (xy[0] - radius, xy[1] - radius, xy[0] + radius, xy[1] + radius) + self.ellipse(ellipse_xy, fill, outline, width) + + def line( + self, + xy: Coords, + fill: _Ink | None = None, + width: int = 0, + joint: str | None = None, + ) -> None: + """Draw a line, or a connected sequence of line segments.""" + ink = self._getink(fill)[0] + if ink is not None: + self.draw.draw_lines(xy, ink, width) + if joint == "curve" and width > 4: + points: Sequence[Sequence[float]] + if isinstance(xy[0], (list, tuple)): + points = cast(Sequence[Sequence[float]], xy) + else: + points = [ + cast(Sequence[float], tuple(xy[i : i + 2])) + for i in range(0, len(xy), 2) + ] + for i in range(1, len(points) - 1): + point = points[i] + angles = [ + math.degrees(math.atan2(end[0] - start[0], start[1] - end[1])) + % 360 + for start, end in ( + (points[i - 1], point), + (point, points[i + 1]), + ) + ] + if angles[0] == angles[1]: + # This is a straight line, so no joint is required + continue + + def coord_at_angle( + coord: Sequence[float], angle: float + ) -> tuple[float, ...]: + x, y = coord + angle -= 90 + distance = width / 2 - 1 + return tuple( + p + (math.floor(p_d) if p_d > 0 else math.ceil(p_d)) + for p, p_d in ( + (x, distance * math.cos(math.radians(angle))), + (y, distance * math.sin(math.radians(angle))), + ) + ) + + flipped = ( + angles[1] > angles[0] and angles[1] - 180 > angles[0] + ) or (angles[1] < angles[0] and angles[1] + 180 > angles[0]) + coords = [ + (point[0] - width / 2 + 1, point[1] - width / 2 + 1), + (point[0] + width / 2 - 1, point[1] + width / 2 - 1), + ] + if flipped: + start, end = (angles[1] + 90, angles[0] + 90) + else: + start, end = (angles[0] - 90, angles[1] - 90) + self.pieslice(coords, start - 90, end - 90, fill) + + if width > 8: + # Cover potential gaps between the line and the joint + if flipped: + gap_coords = [ + coord_at_angle(point, angles[0] + 90), + point, + coord_at_angle(point, angles[1] + 90), + ] + else: + gap_coords = [ + coord_at_angle(point, angles[0] - 90), + point, + coord_at_angle(point, angles[1] - 90), + ] + self.line(gap_coords, fill, width=3) + + def shape( + self, + shape: Image.core._Outline, + fill: _Ink | None = None, + outline: _Ink | None = None, + ) -> None: + """(Experimental) Draw a shape.""" + shape.close() + ink, fill_ink = self._getink(outline, fill) + if fill_ink is not None: + self.draw.draw_outline(shape, fill_ink, 1) + if ink is not None and ink != fill_ink: + self.draw.draw_outline(shape, ink, 0) + + def pieslice( + self, + xy: Coords, + start: float, + end: float, + fill: _Ink | None = None, + outline: _Ink | None = None, + width: int = 1, + ) -> None: + """Draw a pieslice.""" + ink, fill_ink = self._getink(outline, fill) + if fill_ink is not None: + self.draw.draw_pieslice(xy, start, end, fill_ink, 1) + if ink is not None and ink != fill_ink and width != 0: + self.draw.draw_pieslice(xy, start, end, ink, 0, width) + + def point(self, xy: Coords, fill: _Ink | None = None) -> None: + """Draw one or more individual pixels.""" + ink, fill = self._getink(fill) + if ink is not None: + self.draw.draw_points(xy, ink) + + def polygon( + self, + xy: Coords, + fill: _Ink | None = None, + outline: _Ink | None = None, + width: int = 1, + ) -> None: + """Draw a polygon.""" + ink, fill_ink = self._getink(outline, fill) + if fill_ink is not None: + self.draw.draw_polygon(xy, fill_ink, 1) + if ink is not None and ink != fill_ink and width != 0: + if width == 1: + self.draw.draw_polygon(xy, ink, 0, width) + elif self.im is not None: + # To avoid expanding the polygon outwards, + # use the fill as a mask + mask = Image.new("1", self.im.size) + mask_ink = self._getink(1)[0] + draw = Draw(mask) + draw.draw.draw_polygon(xy, mask_ink, 1) + + self.draw.draw_polygon(xy, ink, 0, width * 2 - 1, mask.im) + + def regular_polygon( + self, + bounding_circle: Sequence[Sequence[float] | float], + n_sides: int, + rotation: float = 0, + fill: _Ink | None = None, + outline: _Ink | None = None, + width: int = 1, + ) -> None: + """Draw a regular polygon.""" + xy = _compute_regular_polygon_vertices(bounding_circle, n_sides, rotation) + self.polygon(xy, fill, outline, width) + + def rectangle( + self, + xy: Coords, + fill: _Ink | None = None, + outline: _Ink | None = None, + width: int = 1, + ) -> None: + """Draw a rectangle.""" + ink, fill_ink = self._getink(outline, fill) + if fill_ink is not None: + self.draw.draw_rectangle(xy, fill_ink, 1) + if ink is not None and ink != fill_ink and width != 0: + self.draw.draw_rectangle(xy, ink, 0, width) + + def rounded_rectangle( + self, + xy: Coords, + radius: float = 0, + fill: _Ink | None = None, + outline: _Ink | None = None, + width: int = 1, + *, + corners: tuple[bool, bool, bool, bool] | None = None, + ) -> None: + """Draw a rounded rectangle.""" + if isinstance(xy[0], (list, tuple)): + (x0, y0), (x1, y1) = cast(Sequence[Sequence[float]], xy) + else: + x0, y0, x1, y1 = cast(Sequence[float], xy) + if x1 < x0: + msg = "x1 must be greater than or equal to x0" + raise ValueError(msg) + if y1 < y0: + msg = "y1 must be greater than or equal to y0" + raise ValueError(msg) + if corners is None: + corners = (True, True, True, True) + + d = radius * 2 + + x0 = round(x0) + y0 = round(y0) + x1 = round(x1) + y1 = round(y1) + full_x, full_y = False, False + if all(corners): + full_x = d >= x1 - x0 - 1 + if full_x: + # The two left and two right corners are joined + d = x1 - x0 + full_y = d >= y1 - y0 - 1 + if full_y: + # The two top and two bottom corners are joined + d = y1 - y0 + if full_x and full_y: + # If all corners are joined, that is a circle + return self.ellipse(xy, fill, outline, width) + + if d == 0 or not any(corners): + # If the corners have no curve, + # or there are no corners, + # that is a rectangle + return self.rectangle(xy, fill, outline, width) + + r = int(d // 2) + ink, fill_ink = self._getink(outline, fill) + + def draw_corners(pieslice: bool) -> None: + parts: tuple[tuple[tuple[float, float, float, float], int, int], ...] + if full_x: + # Draw top and bottom halves + parts = ( + ((x0, y0, x0 + d, y0 + d), 180, 360), + ((x0, y1 - d, x0 + d, y1), 0, 180), + ) + elif full_y: + # Draw left and right halves + parts = ( + ((x0, y0, x0 + d, y0 + d), 90, 270), + ((x1 - d, y0, x1, y0 + d), 270, 90), + ) + else: + # Draw four separate corners + parts = tuple( + part + for i, part in enumerate( + ( + ((x0, y0, x0 + d, y0 + d), 180, 270), + ((x1 - d, y0, x1, y0 + d), 270, 360), + ((x1 - d, y1 - d, x1, y1), 0, 90), + ((x0, y1 - d, x0 + d, y1), 90, 180), + ) + ) + if corners[i] + ) + for part in parts: + if pieslice: + self.draw.draw_pieslice(*(part + (fill_ink, 1))) + else: + self.draw.draw_arc(*(part + (ink, width))) + + if fill_ink is not None: + draw_corners(True) + + if full_x: + self.draw.draw_rectangle((x0, y0 + r + 1, x1, y1 - r - 1), fill_ink, 1) + elif x1 - r - 1 >= x0 + r + 1: + self.draw.draw_rectangle((x0 + r + 1, y0, x1 - r - 1, y1), fill_ink, 1) + if not full_x and not full_y: + left = [x0, y0, x0 + r, y1] + if corners[0]: + left[1] += r + 1 + if corners[3]: + left[3] -= r + 1 + self.draw.draw_rectangle(left, fill_ink, 1) + + right = [x1 - r, y0, x1, y1] + if corners[1]: + right[1] += r + 1 + if corners[2]: + right[3] -= r + 1 + self.draw.draw_rectangle(right, fill_ink, 1) + if ink is not None and ink != fill_ink and width != 0: + draw_corners(False) + + if not full_x: + top = [x0, y0, x1, y0 + width - 1] + if corners[0]: + top[0] += r + 1 + if corners[1]: + top[2] -= r + 1 + self.draw.draw_rectangle(top, ink, 1) + + bottom = [x0, y1 - width + 1, x1, y1] + if corners[3]: + bottom[0] += r + 1 + if corners[2]: + bottom[2] -= r + 1 + self.draw.draw_rectangle(bottom, ink, 1) + if not full_y: + left = [x0, y0, x0 + width - 1, y1] + if corners[0]: + left[1] += r + 1 + if corners[3]: + left[3] -= r + 1 + self.draw.draw_rectangle(left, ink, 1) + + right = [x1 - width + 1, y0, x1, y1] + if corners[1]: + right[1] += r + 1 + if corners[2]: + right[3] -= r + 1 + self.draw.draw_rectangle(right, ink, 1) + + def text( + self, + xy: tuple[float, float], + text: AnyStr | ImageText.Text[AnyStr], + fill: _Ink | None = None, + font: ( + ImageFont.ImageFont + | ImageFont.FreeTypeFont + | ImageFont.TransposedFont + | None + ) = None, + anchor: str | None = None, + spacing: float = 4, + align: str = "left", + direction: str | None = None, + features: list[str] | None = None, + language: str | None = None, + stroke_width: float = 0, + stroke_fill: _Ink | None = None, + embedded_color: bool = False, + *args: Any, + **kwargs: Any, + ) -> None: + """Draw text.""" + if isinstance(text, ImageText.Text): + image_text = text + else: + if font is None: + font = self._getfont(kwargs.get("font_size")) + image_text = ImageText.Text( + text, font, self.mode, spacing, direction, features, language + ) + if embedded_color: + image_text.embed_color() + if stroke_width: + image_text.stroke(stroke_width, stroke_fill) + + def getink(fill: _Ink | None) -> int: + ink, fill_ink = self._getink(fill) + if ink is None: + assert fill_ink is not None + return fill_ink + return ink + + ink = getink(fill) + if ink is None: + return + + stroke_ink = None + if image_text.stroke_width: + stroke_ink = ( + getink(image_text.stroke_fill) + if image_text.stroke_fill is not None + else ink + ) + + for line in image_text._split(xy, anchor, align): + + def draw_text(ink: int, stroke_width: float = 0) -> None: + mode = self.fontmode + if stroke_width == 0 and embedded_color: + mode = "RGBA" + x = int(line.x) + y = int(line.y) + start = (math.modf(line.x)[0], math.modf(line.y)[0]) + try: + mask, offset = image_text.font.getmask2( # type: ignore[union-attr,misc] + line.text, + mode, + direction=direction, + features=features, + language=language, + stroke_width=stroke_width, + stroke_filled=True, + anchor=line.anchor, + ink=ink, + start=start, + *args, + **kwargs, + ) + x += offset[0] + y += offset[1] + except AttributeError: + try: + mask = image_text.font.getmask( # type: ignore[misc] + line.text, + mode, + direction, + features, + language, + stroke_width, + line.anchor, + ink, + start=start, + *args, + **kwargs, + ) + except TypeError: + mask = image_text.font.getmask(line.text) + if mode == "RGBA": + # image_text.font.getmask2(mode="RGBA") + # returns color in RGB bands and mask in A + # extract mask and set text alpha + color, mask = mask, mask.getband(3) + ink_alpha = struct.pack("i", ink)[3] + color.fillband(3, ink_alpha) + if self.im is not None: + self.im.paste( + color, (x, y, x + mask.size[0], y + mask.size[1]), mask + ) + else: + self.draw.draw_bitmap((x, y), mask, ink) + + if stroke_ink is not None: + # Draw stroked text + draw_text(stroke_ink, image_text.stroke_width) + + # Draw normal text + if ink != stroke_ink: + draw_text(ink) + else: + # Only draw normal text + draw_text(ink) + + def multiline_text( + self, + xy: tuple[float, float], + text: AnyStr, + fill: _Ink | None = None, + font: ( + ImageFont.ImageFont + | ImageFont.FreeTypeFont + | ImageFont.TransposedFont + | None + ) = None, + anchor: str | None = None, + spacing: float = 4, + align: str = "left", + direction: str | None = None, + features: list[str] | None = None, + language: str | None = None, + stroke_width: float = 0, + stroke_fill: _Ink | None = None, + embedded_color: bool = False, + *, + font_size: float | None = None, + ) -> None: + return self.text( + xy, + text, + fill, + font, + anchor, + spacing, + align, + direction, + features, + language, + stroke_width, + stroke_fill, + embedded_color, + font_size=font_size, + ) + + def textlength( + self, + text: AnyStr, + font: ( + ImageFont.ImageFont + | ImageFont.FreeTypeFont + | ImageFont.TransposedFont + | None + ) = None, + direction: str | None = None, + features: list[str] | None = None, + language: str | None = None, + embedded_color: bool = False, + *, + font_size: float | None = None, + ) -> float: + """Get the length of a given string, in pixels with 1/64 precision.""" + if font is None: + font = self._getfont(font_size) + image_text = ImageText.Text( + text, + font, + self.mode, + direction=direction, + features=features, + language=language, + ) + if embedded_color: + image_text.embed_color() + return image_text.get_length() + + def textbbox( + self, + xy: tuple[float, float], + text: AnyStr, + font: ( + ImageFont.ImageFont + | ImageFont.FreeTypeFont + | ImageFont.TransposedFont + | None + ) = None, + anchor: str | None = None, + spacing: float = 4, + align: str = "left", + direction: str | None = None, + features: list[str] | None = None, + language: str | None = None, + stroke_width: float = 0, + embedded_color: bool = False, + *, + font_size: float | None = None, + ) -> tuple[float, float, float, float]: + """Get the bounding box of a given string, in pixels.""" + if font is None: + font = self._getfont(font_size) + image_text = ImageText.Text( + text, font, self.mode, spacing, direction, features, language + ) + if embedded_color: + image_text.embed_color() + if stroke_width: + image_text.stroke(stroke_width) + return image_text.get_bbox(xy, anchor, align) + + def multiline_textbbox( + self, + xy: tuple[float, float], + text: AnyStr, + font: ( + ImageFont.ImageFont + | ImageFont.FreeTypeFont + | ImageFont.TransposedFont + | None + ) = None, + anchor: str | None = None, + spacing: float = 4, + align: str = "left", + direction: str | None = None, + features: list[str] | None = None, + language: str | None = None, + stroke_width: float = 0, + embedded_color: bool = False, + *, + font_size: float | None = None, + ) -> tuple[float, float, float, float]: + return self.textbbox( + xy, + text, + font, + anchor, + spacing, + align, + direction, + features, + language, + stroke_width, + embedded_color, + font_size=font_size, + ) + + +def Draw(im: Image.Image, mode: str | None = None) -> ImageDraw: + """ + A simple 2D drawing interface for PIL images. + + :param im: The image to draw in. + :param mode: Optional mode to use for color values. For RGB + images, this argument can be RGB or RGBA (to blend the + drawing into the image). For all other modes, this argument + must be the same as the image mode. If omitted, the mode + defaults to the mode of the image. + """ + try: + return getattr(im, "getdraw")(mode) + except AttributeError: + return ImageDraw(im, mode) + + +def getdraw(im: Image.Image | None = None) -> tuple[ImageDraw2.Draw | None, ModuleType]: + """ + :param im: The image to draw in. + :returns: A (drawing context, drawing resource factory) tuple. + """ + from . import ImageDraw2 + + draw = ImageDraw2.Draw(im) if im is not None else None + return draw, ImageDraw2 + + +def floodfill( + image: Image.Image, + xy: tuple[int, int], + value: float | tuple[int, ...], + border: float | tuple[int, ...] | None = None, + thresh: float = 0, +) -> None: + """ + .. warning:: This method is experimental. + + Fills a bounded region with a given color. + + :param image: Target image. + :param xy: Seed position (a 2-item coordinate tuple). See + :ref:`coordinate-system`. + :param value: Fill color. + :param border: Optional border value. If given, the region consists of + pixels with a color different from the border color. If not given, + the region consists of pixels having the same color as the seed + pixel. + :param thresh: Optional threshold value which specifies a maximum + tolerable difference of a pixel value from the 'background' in + order for it to be replaced. Useful for filling regions of + non-homogeneous, but similar, colors. + """ + # based on an implementation by Eric S. Raymond + # amended by yo1995 @20180806 + pixel = image.load() + assert pixel is not None + x, y = xy + try: + background = pixel[x, y] + if _color_diff(value, background) <= thresh: + return # seed point already has fill color + pixel[x, y] = value + except (ValueError, IndexError): + return # seed point outside image + edge = {(x, y)} + # use a set to keep record of current and previous edge pixels + # to reduce memory consumption + full_edge = set() + while edge: + new_edge = set() + for x, y in edge: # 4 adjacent method + for s, t in ((x + 1, y), (x - 1, y), (x, y + 1), (x, y - 1)): + # If already processed, or if a coordinate is negative, skip + if (s, t) in full_edge or s < 0 or t < 0: + continue + try: + p = pixel[s, t] + except (ValueError, IndexError): + pass + else: + full_edge.add((s, t)) + if border is None: + fill = _color_diff(p, background) <= thresh + else: + fill = p not in (value, border) + if fill: + pixel[s, t] = value + new_edge.add((s, t)) + full_edge = edge # discard pixels processed + edge = new_edge + + +def _compute_regular_polygon_vertices( + bounding_circle: Sequence[Sequence[float] | float], n_sides: int, rotation: float +) -> list[tuple[float, float]]: + """ + Generate a list of vertices for a 2D regular polygon. + + :param bounding_circle: The bounding circle is a sequence defined + by a point and radius. The polygon is inscribed in this circle. + (e.g. ``bounding_circle=(x, y, r)`` or ``((x, y), r)``) + :param n_sides: Number of sides + (e.g. ``n_sides=3`` for a triangle, ``6`` for a hexagon) + :param rotation: Apply an arbitrary rotation to the polygon + (e.g. ``rotation=90``, applies a 90 degree rotation) + :return: List of regular polygon vertices + (e.g. ``[(25, 50), (50, 50), (50, 25), (25, 25)]``) + + How are the vertices computed? + 1. Compute the following variables + - theta: Angle between the apothem & the nearest polygon vertex + - side_length: Length of each polygon edge + - centroid: Center of bounding circle (1st, 2nd elements of bounding_circle) + - polygon_radius: Polygon radius (last element of bounding_circle) + - angles: Location of each polygon vertex in polar grid + (e.g. A square with 0 degree rotation => [225.0, 315.0, 45.0, 135.0]) + + 2. For each angle in angles, get the polygon vertex at that angle + The vertex is computed using the equation below. + X= xcos(φ) + ysin(φ) + Y= −xsin(φ) + ycos(φ) + + Note: + φ = angle in degrees + x = 0 + y = polygon_radius + + The formula above assumes rotation around the origin. + In our case, we are rotating around the centroid. + To account for this, we use the formula below + X = xcos(φ) + ysin(φ) + centroid_x + Y = −xsin(φ) + ycos(φ) + centroid_y + """ + # 1. Error Handling + # 1.1 Check `n_sides` has an appropriate value + if not isinstance(n_sides, int): + msg = "n_sides should be an int" # type: ignore[unreachable] + raise TypeError(msg) + if n_sides < 3: + msg = "n_sides should be an int > 2" + raise ValueError(msg) + + # 1.2 Check `bounding_circle` has an appropriate value + if not isinstance(bounding_circle, (list, tuple)): + msg = "bounding_circle should be a sequence" + raise TypeError(msg) + + if len(bounding_circle) == 3: + if not all(isinstance(i, (int, float)) for i in bounding_circle): + msg = "bounding_circle should only contain numeric data" + raise ValueError(msg) + + *centroid, polygon_radius = cast(list[float], list(bounding_circle)) + elif len(bounding_circle) == 2 and isinstance(bounding_circle[0], (list, tuple)): + if not all( + isinstance(i, (int, float)) for i in bounding_circle[0] + ) or not isinstance(bounding_circle[1], (int, float)): + msg = "bounding_circle should only contain numeric data" + raise ValueError(msg) + + if len(bounding_circle[0]) != 2: + msg = "bounding_circle centre should contain 2D coordinates (e.g. (x, y))" + raise ValueError(msg) + + centroid = cast(list[float], list(bounding_circle[0])) + polygon_radius = cast(float, bounding_circle[1]) + else: + msg = ( + "bounding_circle should contain 2D coordinates " + "and a radius (e.g. (x, y, r) or ((x, y), r) )" + ) + raise ValueError(msg) + + if polygon_radius <= 0: + msg = "bounding_circle radius should be > 0" + raise ValueError(msg) + + # 1.3 Check `rotation` has an appropriate value + if not isinstance(rotation, (int, float)): + msg = "rotation should be an int or float" # type: ignore[unreachable] + raise ValueError(msg) + + # 2. Define Helper Functions + def _apply_rotation(point: list[float], degrees: float) -> tuple[float, float]: + return ( + round( + point[0] * math.cos(math.radians(360 - degrees)) + - point[1] * math.sin(math.radians(360 - degrees)) + + centroid[0], + 2, + ), + round( + point[1] * math.cos(math.radians(360 - degrees)) + + point[0] * math.sin(math.radians(360 - degrees)) + + centroid[1], + 2, + ), + ) + + def _compute_polygon_vertex(angle: float) -> tuple[float, float]: + start_point = [polygon_radius, 0] + return _apply_rotation(start_point, angle) + + def _get_angles(n_sides: int, rotation: float) -> list[float]: + angles = [] + degrees = 360 / n_sides + # Start with the bottom left polygon vertex + current_angle = (270 - 0.5 * degrees) + rotation + for _ in range(n_sides): + angles.append(current_angle) + current_angle += degrees + if current_angle > 360: + current_angle -= 360 + return angles + + # 3. Variable Declarations + angles = _get_angles(n_sides, rotation) + + # 4. Compute Vertices + return [_compute_polygon_vertex(angle) for angle in angles] + + +def _color_diff( + color1: float | tuple[int, ...], color2: float | tuple[int, ...] +) -> float: + """ + Uses 1-norm distance to calculate difference between two values. + """ + first = color1 if isinstance(color1, tuple) else (color1,) + second = color2 if isinstance(color2, tuple) else (color2,) + + return sum(abs(first[i] - second[i]) for i in range(len(second))) diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/ImageDraw2.py b/presentation/.venv/lib/python3.12/site-packages/PIL/ImageDraw2.py new file mode 100644 index 0000000..2c9e39b --- /dev/null +++ b/presentation/.venv/lib/python3.12/site-packages/PIL/ImageDraw2.py @@ -0,0 +1,244 @@ +# +# The Python Imaging Library +# $Id$ +# +# WCK-style drawing interface operations +# +# History: +# 2003-12-07 fl created +# 2005-05-15 fl updated; added to PIL as ImageDraw2 +# 2005-05-15 fl added text support +# 2005-05-20 fl added arc/chord/pieslice support +# +# Copyright (c) 2003-2005 by Secret Labs AB +# Copyright (c) 2003-2005 by Fredrik Lundh +# +# See the README file for information on usage and redistribution. +# + + +""" +(Experimental) WCK-style drawing interface operations + +.. seealso:: :py:mod:`PIL.ImageDraw` +""" + +from __future__ import annotations + +from typing import Any, AnyStr, BinaryIO + +from . import Image, ImageColor, ImageDraw, ImageFont, ImagePath +from ._typing import Coords, StrOrBytesPath + + +class Pen: + """Stores an outline color and width.""" + + def __init__(self, color: str, width: int = 1, opacity: int = 255) -> None: + self.color = ImageColor.getrgb(color) + self.width = width + + +class Brush: + """Stores a fill color""" + + def __init__(self, color: str, opacity: int = 255) -> None: + self.color = ImageColor.getrgb(color) + + +class Font: + """Stores a TrueType font and color""" + + def __init__( + self, color: str, file: StrOrBytesPath | BinaryIO, size: float = 12 + ) -> None: + # FIXME: add support for bitmap fonts + self.color = ImageColor.getrgb(color) + self.font = ImageFont.truetype(file, size) + + +class Draw: + """ + (Experimental) WCK-style drawing interface + """ + + def __init__( + self, + image: Image.Image | str, + size: tuple[int, int] | list[int] | None = None, + color: float | tuple[float, ...] | str | None = None, + ) -> None: + if isinstance(image, str): + if size is None: + msg = "If image argument is mode string, size must be a list or tuple" + raise ValueError(msg) + image = Image.new(image, size, color) + self.draw = ImageDraw.Draw(image) + self.image = image + self.transform: tuple[float, float, float, float, float, float] | None = None + + def flush(self) -> Image.Image: + return self.image + + def render( + self, + op: str, + xy: Coords, + pen: Pen | Brush | None, + brush: Brush | Pen | None = None, + **kwargs: Any, + ) -> None: + # handle color arguments + outline = fill = None + width = 1 + if isinstance(pen, Pen): + outline = pen.color + width = pen.width + elif isinstance(brush, Pen): + outline = brush.color + width = brush.width + if isinstance(brush, Brush): + fill = brush.color + elif isinstance(pen, Brush): + fill = pen.color + # handle transformation + if self.transform: + path = ImagePath.Path(xy) + path.transform(self.transform) + xy = path + # render the item + if op in ("arc", "line"): + kwargs.setdefault("fill", outline) + else: + kwargs.setdefault("fill", fill) + kwargs.setdefault("outline", outline) + if op == "line": + kwargs.setdefault("width", width) + getattr(self.draw, op)(xy, **kwargs) + + def settransform(self, offset: tuple[float, float]) -> None: + """Sets a transformation offset.""" + xoffset, yoffset = offset + self.transform = (1, 0, xoffset, 0, 1, yoffset) + + def arc( + self, + xy: Coords, + pen: Pen | Brush | None, + start: float, + end: float, + *options: Any, + ) -> None: + """ + Draws an arc (a portion of a circle outline) between the start and end + angles, inside the given bounding box. + + .. seealso:: :py:meth:`PIL.ImageDraw.ImageDraw.arc` + """ + self.render("arc", xy, pen, *options, start=start, end=end) + + def chord( + self, + xy: Coords, + pen: Pen | Brush | None, + start: float, + end: float, + *options: Any, + ) -> None: + """ + Same as :py:meth:`~PIL.ImageDraw2.Draw.arc`, but connects the end points + with a straight line. + + .. seealso:: :py:meth:`PIL.ImageDraw.ImageDraw.chord` + """ + self.render("chord", xy, pen, *options, start=start, end=end) + + def ellipse(self, xy: Coords, pen: Pen | Brush | None, *options: Any) -> None: + """ + Draws an ellipse inside the given bounding box. + + .. seealso:: :py:meth:`PIL.ImageDraw.ImageDraw.ellipse` + """ + self.render("ellipse", xy, pen, *options) + + def line(self, xy: Coords, pen: Pen | Brush | None, *options: Any) -> None: + """ + Draws a line between the coordinates in the ``xy`` list. + + .. seealso:: :py:meth:`PIL.ImageDraw.ImageDraw.line` + """ + self.render("line", xy, pen, *options) + + def pieslice( + self, + xy: Coords, + pen: Pen | Brush | None, + start: float, + end: float, + *options: Any, + ) -> None: + """ + Same as arc, but also draws straight lines between the end points and the + center of the bounding box. + + .. seealso:: :py:meth:`PIL.ImageDraw.ImageDraw.pieslice` + """ + self.render("pieslice", xy, pen, *options, start=start, end=end) + + def polygon(self, xy: Coords, pen: Pen | Brush | None, *options: Any) -> None: + """ + Draws a polygon. + + The polygon outline consists of straight lines between the given + coordinates, plus a straight line between the last and the first + coordinate. + + + .. seealso:: :py:meth:`PIL.ImageDraw.ImageDraw.polygon` + """ + self.render("polygon", xy, pen, *options) + + def rectangle(self, xy: Coords, pen: Pen | Brush | None, *options: Any) -> None: + """ + Draws a rectangle. + + .. seealso:: :py:meth:`PIL.ImageDraw.ImageDraw.rectangle` + """ + self.render("rectangle", xy, pen, *options) + + def text(self, xy: tuple[float, float], text: AnyStr, font: Font) -> None: + """ + Draws the string at the given position. + + .. seealso:: :py:meth:`PIL.ImageDraw.ImageDraw.text` + """ + if self.transform: + path = ImagePath.Path(xy) + path.transform(self.transform) + xy = path + self.draw.text(xy, text, font=font.font, fill=font.color) + + def textbbox( + self, xy: tuple[float, float], text: AnyStr, font: Font + ) -> tuple[float, float, float, float]: + """ + Returns bounding box (in pixels) of given text. + + :return: ``(left, top, right, bottom)`` bounding box + + .. seealso:: :py:meth:`PIL.ImageDraw.ImageDraw.textbbox` + """ + if self.transform: + path = ImagePath.Path(xy) + path.transform(self.transform) + xy = path + return self.draw.textbbox(xy, text, font=font.font) + + def textlength(self, text: AnyStr, font: Font) -> float: + """ + Returns length (in pixels) of given text. + This is the amount by which following text should be offset. + + .. seealso:: :py:meth:`PIL.ImageDraw.ImageDraw.textlength` + """ + return self.draw.textlength(text, font=font.font) diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/ImageEnhance.py b/presentation/.venv/lib/python3.12/site-packages/PIL/ImageEnhance.py new file mode 100644 index 0000000..0e7e6dd --- /dev/null +++ b/presentation/.venv/lib/python3.12/site-packages/PIL/ImageEnhance.py @@ -0,0 +1,113 @@ +# +# The Python Imaging Library. +# $Id$ +# +# image enhancement classes +# +# For a background, see "Image Processing By Interpolation and +# Extrapolation", Paul Haeberli and Douglas Voorhies. Available +# at http://www.graficaobscura.com/interp/index.html +# +# History: +# 1996-03-23 fl Created +# 2009-06-16 fl Fixed mean calculation +# +# Copyright (c) Secret Labs AB 1997. +# Copyright (c) Fredrik Lundh 1996. +# +# See the README file for information on usage and redistribution. +# +from __future__ import annotations + +from . import Image, ImageFilter, ImageStat + + +class _Enhance: + image: Image.Image + degenerate: Image.Image + + def enhance(self, factor: float) -> Image.Image: + """ + Returns an enhanced image. + + :param factor: A floating point value controlling the enhancement. + Factor 1.0 always returns a copy of the original image, + lower factors mean less color (brightness, contrast, + etc), and higher values more. There are no restrictions + on this value. + :rtype: :py:class:`~PIL.Image.Image` + """ + return Image.blend(self.degenerate, self.image, factor) + + +class Color(_Enhance): + """Adjust image color balance. + + This class can be used to adjust the colour balance of an image, in + a manner similar to the controls on a colour TV set. An enhancement + factor of 0.0 gives a black and white image. A factor of 1.0 gives + the original image. + """ + + def __init__(self, image: Image.Image) -> None: + self.image = image + self.intermediate_mode = "L" + if "A" in image.getbands(): + self.intermediate_mode = "LA" + + if self.intermediate_mode != image.mode: + image = image.convert(self.intermediate_mode).convert(image.mode) + self.degenerate = image + + +class Contrast(_Enhance): + """Adjust image contrast. + + This class can be used to control the contrast of an image, similar + to the contrast control on a TV set. An enhancement factor of 0.0 + gives a solid gray image. A factor of 1.0 gives the original image. + """ + + def __init__(self, image: Image.Image) -> None: + self.image = image + if image.mode != "L": + image = image.convert("L") + mean = int(ImageStat.Stat(image).mean[0] + 0.5) + self.degenerate = Image.new("L", image.size, mean) + if self.degenerate.mode != self.image.mode: + self.degenerate = self.degenerate.convert(self.image.mode) + + if "A" in self.image.getbands(): + self.degenerate.putalpha(self.image.getchannel("A")) + + +class Brightness(_Enhance): + """Adjust image brightness. + + This class can be used to control the brightness of an image. An + enhancement factor of 0.0 gives a black image. A factor of 1.0 gives the + original image. + """ + + def __init__(self, image: Image.Image) -> None: + self.image = image + self.degenerate = Image.new(image.mode, image.size, 0) + + if "A" in image.getbands(): + self.degenerate.putalpha(image.getchannel("A")) + + +class Sharpness(_Enhance): + """Adjust image sharpness. + + This class can be used to adjust the sharpness of an image. An + enhancement factor of 0.0 gives a blurred image, a factor of 1.0 gives the + original image, and a factor of 2.0 gives a sharpened image. + """ + + def __init__(self, image: Image.Image) -> None: + self.image = image + self.degenerate = image.filter(ImageFilter.SMOOTH) + + if "A" in image.getbands(): + self.degenerate.putalpha(image.getchannel("A")) diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/ImageFile.py b/presentation/.venv/lib/python3.12/site-packages/PIL/ImageFile.py new file mode 100644 index 0000000..c70d93f --- /dev/null +++ b/presentation/.venv/lib/python3.12/site-packages/PIL/ImageFile.py @@ -0,0 +1,935 @@ +# +# The Python Imaging Library. +# $Id$ +# +# base class for image file handlers +# +# history: +# 1995-09-09 fl Created +# 1996-03-11 fl Fixed load mechanism. +# 1996-04-15 fl Added pcx/xbm decoders. +# 1996-04-30 fl Added encoders. +# 1996-12-14 fl Added load helpers +# 1997-01-11 fl Use encode_to_file where possible +# 1997-08-27 fl Flush output in _save +# 1998-03-05 fl Use memory mapping for some modes +# 1999-02-04 fl Use memory mapping also for "I;16" and "I;16B" +# 1999-05-31 fl Added image parser +# 2000-10-12 fl Set readonly flag on memory-mapped images +# 2002-03-20 fl Use better messages for common decoder errors +# 2003-04-21 fl Fall back on mmap/map_buffer if map is not available +# 2003-10-30 fl Added StubImageFile class +# 2004-02-25 fl Made incremental parser more robust +# +# Copyright (c) 1997-2004 by Secret Labs AB +# Copyright (c) 1995-2004 by Fredrik Lundh +# +# See the README file for information on usage and redistribution. +# +from __future__ import annotations + +import abc +import io +import itertools +import logging +import os +import struct +from typing import IO, Any, NamedTuple, cast + +from . import ExifTags, Image +from ._util import DeferredError, is_path + +TYPE_CHECKING = False +if TYPE_CHECKING: + from ._typing import StrOrBytesPath + +logger = logging.getLogger(__name__) + +MAXBLOCK = 65536 +""" +By default, Pillow processes image data in blocks. This helps to prevent excessive use +of resources. Codecs may disable this behaviour with ``_pulls_fd`` or ``_pushes_fd``. + +When reading an image, this is the number of bytes to read at once. + +When writing an image, this is the number of bytes to write at once. +If the image width times 4 is greater, then that will be used instead. +Plugins may also set a greater number. + +User code may set this to another number. +""" + +SAFEBLOCK = 1024 * 1024 + +LOAD_TRUNCATED_IMAGES = False +"""Whether or not to load truncated image files. User code may change this.""" + +ERRORS = { + -1: "image buffer overrun error", + -2: "decoding error", + -3: "unknown error", + -8: "bad configuration", + -9: "out of memory error", +} +""" +Dict of known error codes returned from :meth:`.PyDecoder.decode`, +:meth:`.PyEncoder.encode` :meth:`.PyEncoder.encode_to_pyfd` and +:meth:`.PyEncoder.encode_to_file`. +""" + + +# +# -------------------------------------------------------------------- +# Helpers + + +def _get_oserror(error: int, *, encoder: bool) -> OSError: + try: + msg = Image.core.getcodecstatus(error) + except AttributeError: + msg = ERRORS.get(error) + if not msg: + msg = f"{'encoder' if encoder else 'decoder'} error {error}" + msg += f" when {'writing' if encoder else 'reading'} image file" + return OSError(msg) + + +def _tilesort(t: _Tile) -> int: + # sort on offset + return t[2] + + +class _Tile(NamedTuple): + codec_name: str + extents: tuple[int, int, int, int] | None + offset: int = 0 + args: tuple[Any, ...] | str | None = None + + +# +# -------------------------------------------------------------------- +# ImageFile base class + + +class ImageFile(Image.Image): + """Base class for image file format handlers.""" + + def __init__( + self, fp: StrOrBytesPath | IO[bytes], filename: str | bytes | None = None + ) -> None: + super().__init__() + + self._min_frame = 0 + + self.custom_mimetype: str | None = None + + self.tile: list[_Tile] = [] + """ A list of tile descriptors """ + + self.readonly = 1 # until we know better + + self.decoderconfig: tuple[Any, ...] = () + self.decodermaxblock = MAXBLOCK + + self.fp: IO[bytes] | None + self._fp: IO[bytes] | DeferredError + if is_path(fp): + # filename + self.fp = open(fp, "rb") + self.filename = os.fspath(fp) + self._exclusive_fp = True + else: + # stream + self.fp = cast(IO[bytes], fp) + self.filename = filename if filename is not None else "" + # can be overridden + self._exclusive_fp = False + + try: + try: + self._open() + + if isinstance(self, StubImageFile): + if loader := self._load(): + loader.open(self) + except ( + IndexError, # end of data + TypeError, # end of data (ord) + KeyError, # unsupported mode + EOFError, # got header but not the first frame + struct.error, + ) as v: + raise SyntaxError(v) from v + + if not self.mode or self.size[0] <= 0 or self.size[1] <= 0: + msg = "not identified by this driver" + raise SyntaxError(msg) + except BaseException: + # close the file only if we have opened it this constructor + if self._exclusive_fp: + self.fp.close() + raise + + def _open(self) -> None: + pass + + # Context manager support + def __enter__(self) -> ImageFile: + return self + + def _close_fp(self) -> None: + if getattr(self, "_fp", False) and not isinstance(self._fp, DeferredError): + if self._fp != self.fp: + self._fp.close() + self._fp = DeferredError(ValueError("Operation on closed image")) + if self.fp: + self.fp.close() + + def __exit__(self, *args: object) -> None: + if getattr(self, "_exclusive_fp", False): + self._close_fp() + self.fp = None + + def close(self) -> None: + """ + Closes the file pointer, if possible. + + This operation will destroy the image core and release its memory. + The image data will be unusable afterward. + + This function is required to close images that have multiple frames or + have not had their file read and closed by the + :py:meth:`~PIL.Image.Image.load` method. See :ref:`file-handling` for + more information. + """ + try: + self._close_fp() + self.fp = None + except Exception as msg: + logger.debug("Error closing: %s", msg) + + super().close() + + def get_child_images(self) -> list[ImageFile]: + child_images = [] + exif = self.getexif() + ifds = [] + if ExifTags.Base.SubIFDs in exif: + subifd_offsets = exif[ExifTags.Base.SubIFDs] + if subifd_offsets: + if not isinstance(subifd_offsets, tuple): + subifd_offsets = (subifd_offsets,) + ifds = [ + (exif._get_ifd_dict(subifd_offset), subifd_offset) + for subifd_offset in subifd_offsets + ] + ifd1 = exif.get_ifd(ExifTags.IFD.IFD1) + if ifd1 and ifd1.get(ExifTags.Base.JpegIFOffset): + assert exif._info is not None + ifds.append((ifd1, exif._info.next)) + + offset = None + for ifd, ifd_offset in ifds: + assert self.fp is not None + current_offset = self.fp.tell() + if offset is None: + offset = current_offset + + fp = self.fp + if ifd is not None: + thumbnail_offset = ifd.get(ExifTags.Base.JpegIFOffset) + if thumbnail_offset is not None: + thumbnail_offset += getattr(self, "_exif_offset", 0) + self.fp.seek(thumbnail_offset) + + length = ifd.get(ExifTags.Base.JpegIFByteCount) + assert isinstance(length, int) + data = self.fp.read(length) + fp = io.BytesIO(data) + + with Image.open(fp) as im: + from . import TiffImagePlugin + + if thumbnail_offset is None and isinstance( + im, TiffImagePlugin.TiffImageFile + ): + im._frame_pos = [ifd_offset] + im._seek(0) + im.load() + child_images.append(im) + + if offset is not None: + assert self.fp is not None + self.fp.seek(offset) + return child_images + + def get_format_mimetype(self) -> str | None: + if self.custom_mimetype: + return self.custom_mimetype + if self.format is not None: + return Image.MIME.get(self.format.upper()) + return None + + def __getstate__(self) -> list[Any]: + return super().__getstate__() + [self.filename] + + def __setstate__(self, state: list[Any]) -> None: + self.tile = [] + if len(state) > 5: + self.filename = state[5] + super().__setstate__(state) + + def verify(self) -> None: + """Check file integrity""" + + # raise exception if something's wrong. must be called + # directly after open, and closes file when finished. + if self._exclusive_fp and self.fp: + self.fp.close() + self.fp = None + + def load(self) -> Image.core.PixelAccess | None: + """Load image data based on tile list""" + + if not self.tile and self._im is None: + msg = "cannot load this image" + raise OSError(msg) + + pixel = Image.Image.load(self) + if not self.tile: + return pixel + + self.map: mmap.mmap | None = None + use_mmap = self.filename and len(self.tile) == 1 + + assert self.fp is not None + readonly = 0 + + # look for read/seek overrides + if hasattr(self, "load_read"): + read = self.load_read + # don't use mmap if there are custom read/seek functions + use_mmap = False + else: + read = self.fp.read + + if hasattr(self, "load_seek"): + seek = self.load_seek + use_mmap = False + else: + seek = self.fp.seek + + if use_mmap: + # try memory mapping + decoder_name, extents, offset, args = self.tile[0] + if isinstance(args, str): + args = (args, 0, 1) + if ( + decoder_name == "raw" + and isinstance(args, tuple) + and len(args) >= 3 + and args[0] == self.mode + and args[0] in Image._MAPMODES + ): + if offset < 0: + msg = "Tile offset cannot be negative" + raise ValueError(msg) + try: + # use mmap, if possible + import mmap + + with open(self.filename) as fp: + self.map = mmap.mmap(fp.fileno(), 0, access=mmap.ACCESS_READ) + if offset + self.size[1] * args[1] > self.map.size(): + msg = "buffer is not large enough" + raise OSError(msg) + self.im = Image.core.map_buffer( + self.map, self.size, decoder_name, offset, args + ) + readonly = 1 + # After trashing self.im, + # we might need to reload the palette data. + if self.palette: + self.palette.dirty = 1 + except (AttributeError, OSError, ImportError): + self.map = None + + self.load_prepare() + err_code = -3 # initialize to unknown error + if not self.map: + # sort tiles in file order + self.tile.sort(key=_tilesort) + + # FIXME: This is a hack to handle TIFF's JpegTables tag. + prefix = getattr(self, "tile_prefix", b"") + + # Remove consecutive duplicates that only differ by their offset + self.tile = [ + list(tiles)[-1] + for _, tiles in itertools.groupby( + self.tile, lambda tile: (tile[0], tile[1], tile[3]) + ) + ] + for i, (decoder_name, extents, offset, args) in enumerate(self.tile): + seek(offset) + decoder = Image._getdecoder( + self.mode, decoder_name, args, self.decoderconfig + ) + try: + decoder.setimage(self.im, extents) + if decoder.pulls_fd: + decoder.setfd(self.fp) + err_code = decoder.decode(b"")[1] + else: + b = prefix + while True: + read_bytes = self.decodermaxblock + if i + 1 < len(self.tile): + next_offset = self.tile[i + 1].offset + if next_offset > offset: + read_bytes = next_offset - offset + try: + s = read(read_bytes) + except (IndexError, struct.error) as e: + # truncated png/gif + if LOAD_TRUNCATED_IMAGES: + break + else: + msg = "image file is truncated" + raise OSError(msg) from e + + if not s: # truncated jpeg + if LOAD_TRUNCATED_IMAGES: + break + else: + msg = ( + "image file is truncated " + f"({len(b)} bytes not processed)" + ) + raise OSError(msg) + + b = b + s + n, err_code = decoder.decode(b) + if n < 0: + break + b = b[n:] + finally: + # Need to cleanup here to prevent leaks + decoder.cleanup() + + self.tile = [] + self.readonly = readonly + + self.load_end() + + if self._exclusive_fp and self._close_exclusive_fp_after_loading: + self.fp.close() + self.fp = None + + if not self.map and not LOAD_TRUNCATED_IMAGES and err_code < 0: + # still raised if decoder fails to return anything + raise _get_oserror(err_code, encoder=False) + + return Image.Image.load(self) + + def load_prepare(self) -> None: + # create image memory if necessary + if self._im is None: + self.im = Image.core.new(self.mode, self.size) + # create palette (optional) + if self.mode == "P": + Image.Image.load(self) + + def load_end(self) -> None: + # may be overridden + pass + + # may be defined for contained formats + # def load_seek(self, pos: int) -> None: + # pass + + # may be defined for blocked formats (e.g. PNG) + # def load_read(self, read_bytes: int) -> bytes: + # pass + + def _seek_check(self, frame: int) -> bool: + if ( + frame < self._min_frame + # Only check upper limit on frames if additional seek operations + # are not required to do so + or ( + not (hasattr(self, "_n_frames") and self._n_frames is None) + and frame >= getattr(self, "n_frames") + self._min_frame + ) + ): + msg = "attempt to seek outside sequence" + raise EOFError(msg) + + return self.tell() != frame + + +class StubHandler(abc.ABC): + def open(self, im: StubImageFile) -> None: + pass + + @abc.abstractmethod + def load(self, im: StubImageFile) -> Image.Image: + pass + + +class StubImageFile(ImageFile, metaclass=abc.ABCMeta): + """ + Base class for stub image loaders. + + A stub loader is an image loader that can identify files of a + certain format, but relies on external code to load the file. + """ + + @abc.abstractmethod + def _open(self) -> None: + pass + + def load(self) -> Image.core.PixelAccess | None: + loader = self._load() + if loader is None: + msg = f"cannot find loader for this {self.format} file" + raise OSError(msg) + image = loader.load(self) + assert image is not None + # become the other object (!) + self.__class__ = image.__class__ # type: ignore[assignment] + self.__dict__ = image.__dict__ + return image.load() + + @abc.abstractmethod + def _load(self) -> StubHandler | None: + """(Hook) Find actual image loader.""" + pass + + +class Parser: + """ + Incremental image parser. This class implements the standard + feed/close consumer interface. + """ + + incremental = None + image: Image.Image | None = None + data: bytes | None = None + decoder: Image.core.ImagingDecoder | PyDecoder | None = None + offset = 0 + finished = 0 + + def reset(self) -> None: + """ + (Consumer) Reset the parser. Note that you can only call this + method immediately after you've created a parser; parser + instances cannot be reused. + """ + assert self.data is None, "cannot reuse parsers" + + def feed(self, data: bytes) -> None: + """ + (Consumer) Feed data to the parser. + + :param data: A string buffer. + :exception OSError: If the parser failed to parse the image file. + """ + # collect data + + if self.finished: + return + + if self.data is None: + self.data = data + else: + self.data = self.data + data + + # parse what we have + if self.decoder: + if self.offset > 0: + # skip header + skip = min(len(self.data), self.offset) + self.data = self.data[skip:] + self.offset = self.offset - skip + if self.offset > 0 or not self.data: + return + + n, e = self.decoder.decode(self.data) + + if n < 0: + # end of stream + self.data = None + self.finished = 1 + if e < 0: + # decoding error + self.image = None + raise _get_oserror(e, encoder=False) + else: + # end of image + return + self.data = self.data[n:] + + elif self.image: + # if we end up here with no decoder, this file cannot + # be incrementally parsed. wait until we've gotten all + # available data + pass + + else: + # attempt to open this file + try: + with io.BytesIO(self.data) as fp: + im = Image.open(fp) + except OSError: + pass # not enough data + else: + flag = hasattr(im, "load_seek") or hasattr(im, "load_read") + if not flag and len(im.tile) == 1: + # initialize decoder + im.load_prepare() + d, e, o, a = im.tile[0] + im.tile = [] + self.decoder = Image._getdecoder(im.mode, d, a, im.decoderconfig) + self.decoder.setimage(im.im, e) + + # calculate decoder offset + self.offset = o + if self.offset <= len(self.data): + self.data = self.data[self.offset :] + self.offset = 0 + + self.image = im + + def __enter__(self) -> Parser: + return self + + def __exit__(self, *args: object) -> None: + self.close() + + def close(self) -> Image.Image: + """ + (Consumer) Close the stream. + + :returns: An image object. + :exception OSError: If the parser failed to parse the image file either + because it cannot be identified or cannot be + decoded. + """ + # finish decoding + if self.decoder: + # get rid of what's left in the buffers + self.feed(b"") + self.data = self.decoder = None + if not self.finished: + msg = "image was incomplete" + raise OSError(msg) + if not self.image: + msg = "cannot parse this image" + raise OSError(msg) + if self.data: + # incremental parsing not possible; reopen the file + # not that we have all data + with io.BytesIO(self.data) as fp: + try: + self.image = Image.open(fp) + finally: + self.image.load() + return self.image + + +# -------------------------------------------------------------------- + + +def _save(im: Image.Image, fp: IO[bytes], tile: list[_Tile], bufsize: int = 0) -> None: + """Helper to save image based on tile list + + :param im: Image object. + :param fp: File object. + :param tile: Tile list. + :param bufsize: Optional buffer size + """ + + im.load() + if not hasattr(im, "encoderconfig"): + im.encoderconfig = () + tile.sort(key=_tilesort) + # FIXME: make MAXBLOCK a configuration parameter + # It would be great if we could have the encoder specify what it needs + # But, it would need at least the image size in most cases. RawEncode is + # a tricky case. + bufsize = max(MAXBLOCK, bufsize, im.size[0] * 4) # see RawEncode.c + try: + fh = fp.fileno() + fp.flush() + _encode_tile(im, fp, tile, bufsize, fh) + except (AttributeError, io.UnsupportedOperation) as exc: + _encode_tile(im, fp, tile, bufsize, None, exc) + if hasattr(fp, "flush"): + fp.flush() + + +def _encode_tile( + im: Image.Image, + fp: IO[bytes], + tile: list[_Tile], + bufsize: int, + fh: int | None, + exc: BaseException | None = None, +) -> None: + for encoder_name, extents, offset, args in tile: + if offset > 0: + fp.seek(offset) + encoder = Image._getencoder(im.mode, encoder_name, args, im.encoderconfig) + try: + encoder.setimage(im.im, extents) + if encoder.pushes_fd: + encoder.setfd(fp) + errcode = encoder.encode_to_pyfd()[1] + else: + if exc: + # compress to Python file-compatible object + while True: + errcode, data = encoder.encode(bufsize)[1:] + fp.write(data) + if errcode: + break + else: + # slight speedup: compress to real file object + assert fh is not None + errcode = encoder.encode_to_file(fh, bufsize) + if errcode < 0: + raise _get_oserror(errcode, encoder=True) from exc + finally: + encoder.cleanup() + + +def _safe_read(fp: IO[bytes], size: int) -> bytes: + """ + Reads large blocks in a safe way. Unlike fp.read(n), this function + doesn't trust the user. If the requested size is larger than + SAFEBLOCK, the file is read block by block. + + :param fp: File handle. Must implement a <b>read</b> method. + :param size: Number of bytes to read. + :returns: A string containing <i>size</i> bytes of data. + + Raises an OSError if the file is truncated and the read cannot be completed + + """ + if size <= 0: + return b"" + if size <= SAFEBLOCK: + data = fp.read(size) + if len(data) < size: + msg = "Truncated File Read" + raise OSError(msg) + return data + blocks: list[bytes] = [] + remaining_size = size + while remaining_size > 0: + block = fp.read(min(remaining_size, SAFEBLOCK)) + if not block: + break + blocks.append(block) + remaining_size -= len(block) + if sum(len(block) for block in blocks) < size: + msg = "Truncated File Read" + raise OSError(msg) + return b"".join(blocks) + + +class PyCodecState: + def __init__(self) -> None: + self.xsize = 0 + self.ysize = 0 + self.xoff = 0 + self.yoff = 0 + + def extents(self) -> tuple[int, int, int, int]: + return self.xoff, self.yoff, self.xoff + self.xsize, self.yoff + self.ysize + + +class PyCodec: + fd: IO[bytes] | None + + def __init__(self, mode: str, *args: Any) -> None: + self.im: Image.core.ImagingCore | None = None + self.state = PyCodecState() + self.fd = None + self.mode = mode + self.init(args) + + def init(self, args: tuple[Any, ...]) -> None: + """ + Override to perform codec specific initialization + + :param args: Tuple of arg items from the tile entry + :returns: None + """ + self.args = args + + def cleanup(self) -> None: + """ + Override to perform codec specific cleanup + + :returns: None + """ + pass + + def setfd(self, fd: IO[bytes]) -> None: + """ + Called from ImageFile to set the Python file-like object + + :param fd: A Python file-like object + :returns: None + """ + self.fd = fd + + def setimage( + self, + im: Image.core.ImagingCore, + extents: tuple[int, int, int, int] | None = None, + ) -> None: + """ + Called from ImageFile to set the core output image for the codec + + :param im: A core image object + :param extents: a 4 tuple of (x0, y0, x1, y1) defining the rectangle + for this tile + :returns: None + """ + + # following c code + self.im = im + + if extents: + x0, y0, x1, y1 = extents + + if x0 < 0 or y0 < 0 or x1 > self.im.size[0] or y1 > self.im.size[1]: + msg = "Tile cannot extend outside image" + raise ValueError(msg) + + self.state.xoff = x0 + self.state.yoff = y0 + self.state.xsize = x1 - x0 + self.state.ysize = y1 - y0 + else: + self.state.xsize, self.state.ysize = self.im.size + + if self.state.xsize <= 0 or self.state.ysize <= 0: + msg = "Size must be positive" + raise ValueError(msg) + + +class PyDecoder(PyCodec): + """ + Python implementation of a format decoder. Override this class and + add the decoding logic in the :meth:`decode` method. + + See :ref:`Writing Your Own File Codec in Python<file-codecs-py>` + """ + + _pulls_fd = False + + @property + def pulls_fd(self) -> bool: + return self._pulls_fd + + def decode(self, buffer: bytes | Image.SupportsArrayInterface) -> tuple[int, int]: + """ + Override to perform the decoding process. + + :param buffer: A bytes object with the data to be decoded. + :returns: A tuple of ``(bytes consumed, errcode)``. + If finished with decoding return -1 for the bytes consumed. + Err codes are from :data:`.ImageFile.ERRORS`. + """ + msg = "unavailable in base decoder" + raise NotImplementedError(msg) + + def set_as_raw( + self, data: bytes, rawmode: str | None = None, extra: tuple[Any, ...] = () + ) -> None: + """ + Convenience method to set the internal image from a stream of raw data + + :param data: Bytes to be set + :param rawmode: The rawmode to be used for the decoder. + If not specified, it will default to the mode of the image + :param extra: Extra arguments for the decoder. + :returns: None + """ + + if not rawmode: + rawmode = self.mode + d = Image._getdecoder(self.mode, "raw", rawmode, extra) + assert self.im is not None + d.setimage(self.im, self.state.extents()) + s = d.decode(data) + + if s[0] >= 0: + msg = "not enough image data" + raise ValueError(msg) + if s[1] != 0: + msg = "cannot decode image data" + raise ValueError(msg) + + +class PyEncoder(PyCodec): + """ + Python implementation of a format encoder. Override this class and + add the decoding logic in the :meth:`encode` method. + + See :ref:`Writing Your Own File Codec in Python<file-codecs-py>` + """ + + _pushes_fd = False + + @property + def pushes_fd(self) -> bool: + return self._pushes_fd + + def encode(self, bufsize: int) -> tuple[int, int, bytes]: + """ + Override to perform the encoding process. + + :param bufsize: Buffer size. + :returns: A tuple of ``(bytes encoded, errcode, bytes)``. + If finished with encoding return 1 for the error code. + Err codes are from :data:`.ImageFile.ERRORS`. + """ + msg = "unavailable in base encoder" + raise NotImplementedError(msg) + + def encode_to_pyfd(self) -> tuple[int, int]: + """ + If ``pushes_fd`` is ``True``, then this method will be used, + and ``encode()`` will only be called once. + + :returns: A tuple of ``(bytes consumed, errcode)``. + Err codes are from :data:`.ImageFile.ERRORS`. + """ + if not self.pushes_fd: + return 0, -8 # bad configuration + bytes_consumed, errcode, data = self.encode(0) + if data: + assert self.fd is not None + self.fd.write(data) + return bytes_consumed, errcode + + def encode_to_file(self, fh: int, bufsize: int) -> int: + """ + :param fh: File handle. + :param bufsize: Buffer size. + + :returns: If finished successfully, return 0. + Otherwise, return an error code. Err codes are from + :data:`.ImageFile.ERRORS`. + """ + errcode = 0 + while errcode == 0: + status, errcode, buf = self.encode(bufsize) + if status > 0: + os.write(fh, buf[status:]) + return errcode diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/ImageFilter.py b/presentation/.venv/lib/python3.12/site-packages/PIL/ImageFilter.py new file mode 100644 index 0000000..9326eee --- /dev/null +++ b/presentation/.venv/lib/python3.12/site-packages/PIL/ImageFilter.py @@ -0,0 +1,607 @@ +# +# The Python Imaging Library. +# $Id$ +# +# standard filters +# +# History: +# 1995-11-27 fl Created +# 2002-06-08 fl Added rank and mode filters +# 2003-09-15 fl Fixed rank calculation in rank filter; added expand call +# +# Copyright (c) 1997-2003 by Secret Labs AB. +# Copyright (c) 1995-2002 by Fredrik Lundh. +# +# See the README file for information on usage and redistribution. +# +from __future__ import annotations + +import abc +import functools +from collections.abc import Sequence +from typing import cast + +TYPE_CHECKING = False +if TYPE_CHECKING: + from collections.abc import Callable + from types import ModuleType + from typing import Any + + from . import _imaging + from ._typing import NumpyArray + + +class Filter(abc.ABC): + @abc.abstractmethod + def filter(self, image: _imaging.ImagingCore) -> _imaging.ImagingCore: + pass + + +class MultibandFilter(Filter): + pass + + +class BuiltinFilter(MultibandFilter): + filterargs: tuple[Any, ...] + + def filter(self, image: _imaging.ImagingCore) -> _imaging.ImagingCore: + if image.mode == "P": + msg = "cannot filter palette images" + raise ValueError(msg) + return image.filter(*self.filterargs) + + +class Kernel(BuiltinFilter): + """ + Create a convolution kernel. This only supports 3x3 and 5x5 integer and floating + point kernels. + + Kernels can only be applied to "L" and "RGB" images. + + :param size: Kernel size, given as (width, height). This must be (3,3) or (5,5). + :param kernel: A sequence containing kernel weights. The kernel will be flipped + vertically before being applied to the image. + :param scale: Scale factor. If given, the result for each pixel is divided by this + value. The default is the sum of the kernel weights. + :param offset: Offset. If given, this value is added to the result, after it has + been divided by the scale factor. + """ + + name = "Kernel" + + def __init__( + self, + size: tuple[int, int], + kernel: Sequence[float], + scale: float | None = None, + offset: float = 0, + ) -> None: + if scale is None: + # default scale is sum of kernel + scale = functools.reduce(lambda a, b: a + b, kernel) + if size[0] * size[1] != len(kernel): + msg = "not enough coefficients in kernel" + raise ValueError(msg) + self.filterargs = size, scale, offset, kernel + + +class RankFilter(Filter): + """ + Create a rank filter. The rank filter sorts all pixels in + a window of the given size, and returns the ``rank``'th value. + + :param size: The kernel size, in pixels. + :param rank: What pixel value to pick. Use 0 for a min filter, + ``size * size / 2`` for a median filter, ``size * size - 1`` + for a max filter, etc. + """ + + name = "Rank" + + def __init__(self, size: int, rank: int) -> None: + self.size = size + self.rank = rank + + def filter(self, image: _imaging.ImagingCore) -> _imaging.ImagingCore: + if image.mode == "P": + msg = "cannot filter palette images" + raise ValueError(msg) + image = image.expand(self.size // 2, self.size // 2) + return image.rankfilter(self.size, self.rank) + + +class MedianFilter(RankFilter): + """ + Create a median filter. Picks the median pixel value in a window with the + given size. + + :param size: The kernel size, in pixels. + """ + + name = "Median" + + def __init__(self, size: int = 3) -> None: + self.size = size + self.rank = size * size // 2 + + +class MinFilter(RankFilter): + """ + Create a min filter. Picks the lowest pixel value in a window with the + given size. + + :param size: The kernel size, in pixels. + """ + + name = "Min" + + def __init__(self, size: int = 3) -> None: + self.size = size + self.rank = 0 + + +class MaxFilter(RankFilter): + """ + Create a max filter. Picks the largest pixel value in a window with the + given size. + + :param size: The kernel size, in pixels. + """ + + name = "Max" + + def __init__(self, size: int = 3) -> None: + self.size = size + self.rank = size * size - 1 + + +class ModeFilter(Filter): + """ + Create a mode filter. Picks the most frequent pixel value in a box with the + given size. Pixel values that occur only once or twice are ignored; if no + pixel value occurs more than twice, the original pixel value is preserved. + + :param size: The kernel size, in pixels. + """ + + name = "Mode" + + def __init__(self, size: int = 3) -> None: + self.size = size + + def filter(self, image: _imaging.ImagingCore) -> _imaging.ImagingCore: + return image.modefilter(self.size) + + +class GaussianBlur(MultibandFilter): + """Blurs the image with a sequence of extended box filters, which + approximates a Gaussian kernel. For details on accuracy see + <https://www.mia.uni-saarland.de/Publications/gwosdek-ssvm11.pdf> + + :param radius: Standard deviation of the Gaussian kernel. Either a sequence of two + numbers for x and y, or a single number for both. + """ + + name = "GaussianBlur" + + def __init__(self, radius: float | Sequence[float] = 2) -> None: + self.radius = radius + + def filter(self, image: _imaging.ImagingCore) -> _imaging.ImagingCore: + xy = self.radius + if isinstance(xy, (int, float)): + xy = (xy, xy) + if xy == (0, 0): + return image.copy() + return image.gaussian_blur(xy) + + +class BoxBlur(MultibandFilter): + """Blurs the image by setting each pixel to the average value of the pixels + in a square box extending radius pixels in each direction. + Supports float radius of arbitrary size. Uses an optimized implementation + which runs in linear time relative to the size of the image + for any radius value. + + :param radius: Size of the box in a direction. Either a sequence of two numbers for + x and y, or a single number for both. + + Radius 0 does not blur, returns an identical image. + Radius 1 takes 1 pixel in each direction, i.e. 9 pixels in total. + """ + + name = "BoxBlur" + + def __init__(self, radius: float | Sequence[float]) -> None: + xy = radius if isinstance(radius, (tuple, list)) else (radius, radius) + if xy[0] < 0 or xy[1] < 0: + msg = "radius must be >= 0" + raise ValueError(msg) + self.radius = radius + + def filter(self, image: _imaging.ImagingCore) -> _imaging.ImagingCore: + xy = self.radius + if isinstance(xy, (int, float)): + xy = (xy, xy) + if xy == (0, 0): + return image.copy() + return image.box_blur(xy) + + +class UnsharpMask(MultibandFilter): + """Unsharp mask filter. + + See Wikipedia's entry on `digital unsharp masking`_ for an explanation of + the parameters. + + :param radius: Blur Radius + :param percent: Unsharp strength, in percent + :param threshold: Threshold controls the minimum brightness change that + will be sharpened + + .. _digital unsharp masking: https://en.wikipedia.org/wiki/Unsharp_masking#Digital_unsharp_masking + + """ + + name = "UnsharpMask" + + def __init__( + self, radius: float = 2, percent: int = 150, threshold: int = 3 + ) -> None: + self.radius = radius + self.percent = percent + self.threshold = threshold + + def filter(self, image: _imaging.ImagingCore) -> _imaging.ImagingCore: + return image.unsharp_mask(self.radius, self.percent, self.threshold) + + +class BLUR(BuiltinFilter): + name = "Blur" + # fmt: off + filterargs = (5, 5), 16, 0, ( + 1, 1, 1, 1, 1, + 1, 0, 0, 0, 1, + 1, 0, 0, 0, 1, + 1, 0, 0, 0, 1, + 1, 1, 1, 1, 1, + ) + # fmt: on + + +class CONTOUR(BuiltinFilter): + name = "Contour" + # fmt: off + filterargs = (3, 3), 1, 255, ( + -1, -1, -1, + -1, 8, -1, + -1, -1, -1, + ) + # fmt: on + + +class DETAIL(BuiltinFilter): + name = "Detail" + # fmt: off + filterargs = (3, 3), 6, 0, ( + 0, -1, 0, + -1, 10, -1, + 0, -1, 0, + ) + # fmt: on + + +class EDGE_ENHANCE(BuiltinFilter): + name = "Edge-enhance" + # fmt: off + filterargs = (3, 3), 2, 0, ( + -1, -1, -1, + -1, 10, -1, + -1, -1, -1, + ) + # fmt: on + + +class EDGE_ENHANCE_MORE(BuiltinFilter): + name = "Edge-enhance More" + # fmt: off + filterargs = (3, 3), 1, 0, ( + -1, -1, -1, + -1, 9, -1, + -1, -1, -1, + ) + # fmt: on + + +class EMBOSS(BuiltinFilter): + name = "Emboss" + # fmt: off + filterargs = (3, 3), 1, 128, ( + -1, 0, 0, + 0, 1, 0, + 0, 0, 0, + ) + # fmt: on + + +class FIND_EDGES(BuiltinFilter): + name = "Find Edges" + # fmt: off + filterargs = (3, 3), 1, 0, ( + -1, -1, -1, + -1, 8, -1, + -1, -1, -1, + ) + # fmt: on + + +class SHARPEN(BuiltinFilter): + name = "Sharpen" + # fmt: off + filterargs = (3, 3), 16, 0, ( + -2, -2, -2, + -2, 32, -2, + -2, -2, -2, + ) + # fmt: on + + +class SMOOTH(BuiltinFilter): + name = "Smooth" + # fmt: off + filterargs = (3, 3), 13, 0, ( + 1, 1, 1, + 1, 5, 1, + 1, 1, 1, + ) + # fmt: on + + +class SMOOTH_MORE(BuiltinFilter): + name = "Smooth More" + # fmt: off + filterargs = (5, 5), 100, 0, ( + 1, 1, 1, 1, 1, + 1, 5, 5, 5, 1, + 1, 5, 44, 5, 1, + 1, 5, 5, 5, 1, + 1, 1, 1, 1, 1, + ) + # fmt: on + + +class Color3DLUT(MultibandFilter): + """Three-dimensional color lookup table. + + Transforms 3-channel pixels using the values of the channels as coordinates + in the 3D lookup table and interpolating the nearest elements. + + This method allows you to apply almost any color transformation + in constant time by using pre-calculated decimated tables. + + .. versionadded:: 5.2.0 + + :param size: Size of the table. One int or tuple of (int, int, int). + Minimal size in any dimension is 2, maximum is 65. + :param table: Flat lookup table. A list of ``channels * size**3`` + float elements or a list of ``size**3`` channels-sized + tuples with floats. Channels are changed first, + then first dimension, then second, then third. + Value 0.0 corresponds lowest value of output, 1.0 highest. + :param channels: Number of channels in the table. Could be 3 or 4. + Default is 3. + :param target_mode: A mode for the result image. Should have not less + than ``channels`` channels. Default is ``None``, + which means that mode wouldn't be changed. + """ + + name = "Color 3D LUT" + + def __init__( + self, + size: int | tuple[int, int, int], + table: Sequence[float] | Sequence[Sequence[int]] | NumpyArray, + channels: int = 3, + target_mode: str | None = None, + **kwargs: bool, + ) -> None: + if channels not in (3, 4): + msg = "Only 3 or 4 output channels are supported" + raise ValueError(msg) + self.size = size = self._check_size(size) + self.channels = channels + self.mode = target_mode + + # Hidden flag `_copy_table=False` could be used to avoid extra copying + # of the table if the table is specially made for the constructor. + copy_table = kwargs.get("_copy_table", True) + items = size[0] * size[1] * size[2] + wrong_size = False + + numpy: ModuleType | None = None + if hasattr(table, "shape"): + try: + import numpy + except ImportError: + pass + + if numpy and isinstance(table, numpy.ndarray): + numpy_table: NumpyArray = table + if copy_table: + numpy_table = numpy_table.copy() + + if numpy_table.shape in [ + (items * channels,), + (items, channels), + (size[2], size[1], size[0], channels), + ]: + table = numpy_table.reshape(items * channels) + else: + wrong_size = True + + else: + if copy_table: + table = list(table) + + # Convert to a flat list + if table and isinstance(table[0], (list, tuple)): + raw_table = cast(Sequence[Sequence[int]], table) + flat_table: list[int] = [] + for pixel in raw_table: + if len(pixel) != channels: + msg = ( + "The elements of the table should " + f"have a length of {channels}." + ) + raise ValueError(msg) + flat_table.extend(pixel) + table = flat_table + + if wrong_size or len(table) != items * channels: + msg = ( + "The table should have either channels * size**3 float items " + "or size**3 items of channels-sized tuples with floats. " + f"Table should be: {channels}x{size[0]}x{size[1]}x{size[2]}. " + f"Actual length: {len(table)}" + ) + raise ValueError(msg) + self.table = table + + @staticmethod + def _check_size(size: Any) -> tuple[int, int, int]: + try: + _, _, _ = size + except ValueError as e: + msg = "Size should be either an integer or a tuple of three integers." + raise ValueError(msg) from e + except TypeError: + size = (size, size, size) + size = tuple(int(x) for x in size) + for size_1d in size: + if not 2 <= size_1d <= 65: + msg = "Size should be in [2, 65] range." + raise ValueError(msg) + return size + + @classmethod + def generate( + cls, + size: int | tuple[int, int, int], + callback: Callable[[float, float, float], tuple[float, ...]], + channels: int = 3, + target_mode: str | None = None, + ) -> Color3DLUT: + """Generates new LUT using provided callback. + + :param size: Size of the table. Passed to the constructor. + :param callback: Function with three parameters which correspond + three color channels. Will be called ``size**3`` + times with values from 0.0 to 1.0 and should return + a tuple with ``channels`` elements. + :param channels: The number of channels which should return callback. + :param target_mode: Passed to the constructor of the resulting + lookup table. + """ + size_1d, size_2d, size_3d = cls._check_size(size) + if channels not in (3, 4): + msg = "Only 3 or 4 output channels are supported" + raise ValueError(msg) + + table: list[float] = [0] * (size_1d * size_2d * size_3d * channels) + idx_out = 0 + for b in range(size_3d): + for g in range(size_2d): + for r in range(size_1d): + table[idx_out : idx_out + channels] = callback( + r / (size_1d - 1), g / (size_2d - 1), b / (size_3d - 1) + ) + idx_out += channels + + return cls( + (size_1d, size_2d, size_3d), + table, + channels=channels, + target_mode=target_mode, + _copy_table=False, + ) + + def transform( + self, + callback: Callable[..., tuple[float, ...]], + with_normals: bool = False, + channels: int | None = None, + target_mode: str | None = None, + ) -> Color3DLUT: + """Transforms the table values using provided callback and returns + a new LUT with altered values. + + :param callback: A function which takes old lookup table values + and returns a new set of values. The number + of arguments which function should take is + ``self.channels`` or ``3 + self.channels`` + if ``with_normals`` flag is set. + Should return a tuple of ``self.channels`` or + ``channels`` elements if it is set. + :param with_normals: If true, ``callback`` will be called with + coordinates in the color cube as the first + three arguments. Otherwise, ``callback`` + will be called only with actual color values. + :param channels: The number of channels in the resulting lookup table. + :param target_mode: Passed to the constructor of the resulting + lookup table. + """ + if channels not in (None, 3, 4): + msg = "Only 3 or 4 output channels are supported" + raise ValueError(msg) + ch_in = self.channels + ch_out = channels or ch_in + size_1d, size_2d, size_3d = self.size + + table: list[float] = [0] * (size_1d * size_2d * size_3d * ch_out) + idx_in = 0 + idx_out = 0 + for b in range(size_3d): + for g in range(size_2d): + for r in range(size_1d): + values = self.table[idx_in : idx_in + ch_in] + if with_normals: + values = callback( + r / (size_1d - 1), + g / (size_2d - 1), + b / (size_3d - 1), + *values, + ) + else: + values = callback(*values) + table[idx_out : idx_out + ch_out] = values + idx_in += ch_in + idx_out += ch_out + + return type(self)( + self.size, + table, + channels=ch_out, + target_mode=target_mode or self.mode, + _copy_table=False, + ) + + def __repr__(self) -> str: + r = [ + f"{self.__class__.__name__} from {self.table.__class__.__name__}", + "size={:d}x{:d}x{:d}".format(*self.size), + f"channels={self.channels:d}", + ] + if self.mode: + r.append(f"target_mode={self.mode}") + return "<{}>".format(" ".join(r)) + + def filter(self, image: _imaging.ImagingCore) -> _imaging.ImagingCore: + from . import Image + + return image.color_lut_3d( + self.mode or image.mode, + Image.Resampling.BILINEAR, + self.channels, + self.size, + self.table, + ) diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/ImageFont.py b/presentation/.venv/lib/python3.12/site-packages/PIL/ImageFont.py new file mode 100644 index 0000000..06ea035 --- /dev/null +++ b/presentation/.venv/lib/python3.12/site-packages/PIL/ImageFont.py @@ -0,0 +1,1309 @@ +# +# The Python Imaging Library. +# $Id$ +# +# PIL raster font management +# +# History: +# 1996-08-07 fl created (experimental) +# 1997-08-25 fl minor adjustments to handle fonts from pilfont 0.3 +# 1999-02-06 fl rewrote most font management stuff in C +# 1999-03-17 fl take pth files into account in load_path (from Richard Jones) +# 2001-02-17 fl added freetype support +# 2001-05-09 fl added TransposedFont wrapper class +# 2002-03-04 fl make sure we have a "L" or "1" font +# 2002-12-04 fl skip non-directory entries in the system path +# 2003-04-29 fl add embedded default font +# 2003-09-27 fl added support for truetype charmap encodings +# +# Todo: +# Adapt to PILFONT2 format (16-bit fonts, compressed, single file) +# +# Copyright (c) 1997-2003 by Secret Labs AB +# Copyright (c) 1996-2003 by Fredrik Lundh +# +# See the README file for information on usage and redistribution. +# + +from __future__ import annotations + +import base64 +import os +import sys +import warnings +from enum import IntEnum +from io import BytesIO +from types import ModuleType +from typing import IO, Any, BinaryIO, TypedDict, cast + +from . import Image +from ._typing import StrOrBytesPath +from ._util import DeferredError, is_path + +TYPE_CHECKING = False +if TYPE_CHECKING: + from . import ImageFile + from ._imaging import ImagingFont + from ._imagingft import Font + + +class Axis(TypedDict): + minimum: int | None + default: int | None + maximum: int | None + name: bytes | None + + +class Layout(IntEnum): + BASIC = 0 + RAQM = 1 + + +MAX_STRING_LENGTH = 1_000_000 + + +core: ModuleType | DeferredError +try: + from . import _imagingft as core +except ImportError as ex: + core = DeferredError.new(ex) + + +def _string_length_check(text: str | bytes | bytearray) -> None: + if MAX_STRING_LENGTH is not None and len(text) > MAX_STRING_LENGTH: + msg = "too many characters in string" + raise ValueError(msg) + + +# FIXME: add support for pilfont2 format (see FontFile.py) + +# -------------------------------------------------------------------- +# Font metrics format: +# "PILfont" LF +# fontdescriptor LF +# (optional) key=value... LF +# "DATA" LF +# binary data: 256*10*2 bytes (dx, dy, dstbox, srcbox) +# +# To place a character, cut out srcbox and paste at dstbox, +# relative to the character position. Then move the character +# position according to dx, dy. +# -------------------------------------------------------------------- + + +class ImageFont: + """PIL font wrapper""" + + font: ImagingFont + + def _load_pilfont(self, filename: str) -> None: + with open(filename, "rb") as fp: + image: ImageFile.ImageFile | None = None + root = os.path.splitext(filename)[0] + + for ext in (".png", ".gif", ".pbm"): + if image: + image.close() + try: + fullname = root + ext + image = Image.open(fullname) + except Exception: + pass + else: + if image.mode in ("1", "L"): + break + else: + if image: + image.close() + + msg = f"cannot find glyph data file {root}.{{gif|pbm|png}}" + raise OSError(msg) + + self.file = fullname + + self._load_pilfont_data(fp, image) + image.close() + + def _load_pilfont_data(self, file: IO[bytes], image: Image.Image) -> None: + # check image + if image.mode not in ("1", "L"): + image.close() + + msg = "invalid font image mode" + raise TypeError(msg) + + # read PILfont header + if file.read(8) != b"PILfont\n": + image.close() + + msg = "Not a PILfont file" + raise SyntaxError(msg) + file.readline() + self.info = [] # FIXME: should be a dictionary + while True: + s = file.readline() + if not s or s == b"DATA\n": + break + self.info.append(s) + + # read PILfont metrics + data = file.read(256 * 20) + + self._load(image, data) + + def _load(self, image: Image.Image, data: bytes) -> None: + image.load() + + self.font = Image.core.font(image.im, data) + + def getmask( + self, text: str | bytes, mode: str = "", *args: Any, **kwargs: Any + ) -> Image.core.ImagingCore: + """ + Create a bitmap for the text. + + If the font uses antialiasing, the bitmap should have mode ``L`` and use a + maximum value of 255. Otherwise, it should have mode ``1``. + + :param text: Text to render. + :param mode: Used by some graphics drivers to indicate what mode the + driver prefers; if empty, the renderer may return either + mode. Note that the mode is always a string, to simplify + C-level implementations. + + .. versionadded:: 1.1.5 + + :return: An internal PIL storage memory instance as defined by the + :py:mod:`PIL.Image.core` interface module. + """ + _string_length_check(text) + Image._decompression_bomb_check(self.font.getsize(text)) + return self.font.getmask(text, mode) + + def getbbox( + self, text: str | bytes | bytearray, *args: Any, **kwargs: Any + ) -> tuple[int, int, int, int]: + """ + Returns bounding box (in pixels) of given text. + + .. versionadded:: 9.2.0 + + :param text: Text to render. + + :return: ``(left, top, right, bottom)`` bounding box + """ + _string_length_check(text) + width, height = self.font.getsize(text) + return 0, 0, width, height + + def getlength( + self, text: str | bytes | bytearray, *args: Any, **kwargs: Any + ) -> int: + """ + Returns length (in pixels) of given text. + This is the amount by which following text should be offset. + + .. versionadded:: 9.2.0 + """ + _string_length_check(text) + width, height = self.font.getsize(text) + return width + + +## +# Wrapper for FreeType fonts. Application code should use the +# <b>truetype</b> factory function to create font objects. + + +class FreeTypeFont: + """FreeType font wrapper (requires _imagingft service)""" + + font: Font + font_bytes: bytes + + def __init__( + self, + font: StrOrBytesPath | BinaryIO, + size: float = 10, + index: int = 0, + encoding: str = "", + layout_engine: Layout | None = None, + ) -> None: + # FIXME: use service provider instead + + if isinstance(core, DeferredError): + raise core.ex + + if size <= 0: + msg = f"font size must be greater than 0, not {size}" + raise ValueError(msg) + + self.path = font + self.size = size + self.index = index + self.encoding = encoding + + if layout_engine not in (Layout.BASIC, Layout.RAQM): + layout_engine = Layout.BASIC + if core.HAVE_RAQM: + layout_engine = Layout.RAQM + elif layout_engine == Layout.RAQM and not core.HAVE_RAQM: + warnings.warn( + "Raqm layout was requested, but Raqm is not available. " + "Falling back to basic layout." + ) + layout_engine = Layout.BASIC + + self.layout_engine = layout_engine + + def load_from_bytes(f: IO[bytes]) -> None: + self.font_bytes = f.read() + self.font = core.getfont( + "", size, index, encoding, self.font_bytes, layout_engine + ) + + if is_path(font): + font = os.fspath(font) + if sys.platform == "win32": + font_bytes_path = font if isinstance(font, bytes) else font.encode() + try: + font_bytes_path.decode("ascii") + except UnicodeDecodeError: + # FreeType cannot load fonts with non-ASCII characters on Windows + # So load it into memory first + with open(font, "rb") as f: + load_from_bytes(f) + return + self.font = core.getfont( + font, size, index, encoding, layout_engine=layout_engine + ) + else: + load_from_bytes(cast(IO[bytes], font)) + + def __getstate__(self) -> list[Any]: + return [self.path, self.size, self.index, self.encoding, self.layout_engine] + + def __setstate__(self, state: list[Any]) -> None: + path, size, index, encoding, layout_engine = state + FreeTypeFont.__init__(self, path, size, index, encoding, layout_engine) + + def getname(self) -> tuple[str | None, str | None]: + """ + :return: A tuple of the font family (e.g. Helvetica) and the font style + (e.g. Bold) + """ + return self.font.family, self.font.style + + def getmetrics(self) -> tuple[int, int]: + """ + :return: A tuple of the font ascent (the distance from the baseline to + the highest outline point) and descent (the distance from the + baseline to the lowest outline point, a negative value) + """ + return self.font.ascent, self.font.descent + + def getlength( + self, + text: str | bytes, + mode: str = "", + direction: str | None = None, + features: list[str] | None = None, + language: str | None = None, + ) -> float: + """ + Returns length (in pixels with 1/64 precision) of given text when rendered + in font with provided direction, features, and language. + + This is the amount by which following text should be offset. + Text bounding box may extend past the length in some fonts, + e.g. when using italics or accents. + + The result is returned as a float; it is a whole number if using basic layout. + + Note that the sum of two lengths may not equal the length of a concatenated + string due to kerning. If you need to adjust for kerning, include the following + character and subtract its length. + + For example, instead of :: + + hello = font.getlength("Hello") + world = font.getlength("World") + hello_world = hello + world # not adjusted for kerning + assert hello_world == font.getlength("HelloWorld") # may fail + + use :: + + hello = font.getlength("HelloW") - font.getlength("W") # adjusted for kerning + world = font.getlength("World") + hello_world = hello + world # adjusted for kerning + assert hello_world == font.getlength("HelloWorld") # True + + or disable kerning with (requires libraqm) :: + + hello = draw.textlength("Hello", font, features=["-kern"]) + world = draw.textlength("World", font, features=["-kern"]) + hello_world = hello + world # kerning is disabled, no need to adjust + assert hello_world == draw.textlength("HelloWorld", font, features=["-kern"]) + + .. versionadded:: 8.0.0 + + :param text: Text to measure. + :param mode: Used by some graphics drivers to indicate what mode the + driver prefers; if empty, the renderer may return either + mode. Note that the mode is always a string, to simplify + C-level implementations. + + :param direction: Direction of the text. It can be 'rtl' (right to + left), 'ltr' (left to right) or 'ttb' (top to bottom). + Requires libraqm. + + :param features: A list of OpenType font features to be used during text + layout. This is usually used to turn on optional + font features that are not enabled by default, + for example 'dlig' or 'ss01', but can be also + used to turn off default font features for + example '-liga' to disable ligatures or '-kern' + to disable kerning. To get all supported + features, see + https://learn.microsoft.com/en-us/typography/opentype/spec/featurelist + Requires libraqm. + + :param language: Language of the text. Different languages may use + different glyph shapes or ligatures. This parameter tells + the font which language the text is in, and to apply the + correct substitutions as appropriate, if available. + It should be a `BCP 47 language code + <https://www.w3.org/International/articles/language-tags/>`_ + Requires libraqm. + + :return: Either width for horizontal text, or height for vertical text. + """ + _string_length_check(text) + return self.font.getlength(text, mode, direction, features, language) / 64 + + def getbbox( + self, + text: str | bytes, + mode: str = "", + direction: str | None = None, + features: list[str] | None = None, + language: str | None = None, + stroke_width: float = 0, + anchor: str | None = None, + ) -> tuple[float, float, float, float]: + """ + Returns bounding box (in pixels) of given text relative to given anchor + when rendered in font with provided direction, features, and language. + + Use :py:meth:`getlength()` to get the offset of following text with + 1/64 pixel precision. The bounding box includes extra margins for + some fonts, e.g. italics or accents. + + .. versionadded:: 8.0.0 + + :param text: Text to render. + :param mode: Used by some graphics drivers to indicate what mode the + driver prefers; if empty, the renderer may return either + mode. Note that the mode is always a string, to simplify + C-level implementations. + + :param direction: Direction of the text. It can be 'rtl' (right to + left), 'ltr' (left to right) or 'ttb' (top to bottom). + Requires libraqm. + + :param features: A list of OpenType font features to be used during text + layout. This is usually used to turn on optional + font features that are not enabled by default, + for example 'dlig' or 'ss01', but can be also + used to turn off default font features for + example '-liga' to disable ligatures or '-kern' + to disable kerning. To get all supported + features, see + https://learn.microsoft.com/en-us/typography/opentype/spec/featurelist + Requires libraqm. + + :param language: Language of the text. Different languages may use + different glyph shapes or ligatures. This parameter tells + the font which language the text is in, and to apply the + correct substitutions as appropriate, if available. + It should be a `BCP 47 language code + <https://www.w3.org/International/articles/language-tags/>`_ + Requires libraqm. + + :param stroke_width: The width of the text stroke. + + :param anchor: The text anchor alignment. Determines the relative location of + the anchor to the text. The default alignment is top left, + specifically ``la`` for horizontal text and ``lt`` for + vertical text. See :ref:`text-anchors` for details. + + :return: ``(left, top, right, bottom)`` bounding box + """ + _string_length_check(text) + size, offset = self.font.getsize( + text, mode, direction, features, language, anchor + ) + left, top = offset[0] - stroke_width, offset[1] - stroke_width + width, height = size[0] + 2 * stroke_width, size[1] + 2 * stroke_width + return left, top, left + width, top + height + + def getmask( + self, + text: str | bytes, + mode: str = "", + direction: str | None = None, + features: list[str] | None = None, + language: str | None = None, + stroke_width: float = 0, + anchor: str | None = None, + ink: int = 0, + start: tuple[float, float] | None = None, + ) -> Image.core.ImagingCore: + """ + Create a bitmap for the text. + + If the font uses antialiasing, the bitmap should have mode ``L`` and use a + maximum value of 255. If the font has embedded color data, the bitmap + should have mode ``RGBA``. Otherwise, it should have mode ``1``. + + :param text: Text to render. + :param mode: Used by some graphics drivers to indicate what mode the + driver prefers; if empty, the renderer may return either + mode. Note that the mode is always a string, to simplify + C-level implementations. + + .. versionadded:: 1.1.5 + + :param direction: Direction of the text. It can be 'rtl' (right to + left), 'ltr' (left to right) or 'ttb' (top to bottom). + Requires libraqm. + + .. versionadded:: 4.2.0 + + :param features: A list of OpenType font features to be used during text + layout. This is usually used to turn on optional + font features that are not enabled by default, + for example 'dlig' or 'ss01', but can be also + used to turn off default font features for + example '-liga' to disable ligatures or '-kern' + to disable kerning. To get all supported + features, see + https://learn.microsoft.com/en-us/typography/opentype/spec/featurelist + Requires libraqm. + + .. versionadded:: 4.2.0 + + :param language: Language of the text. Different languages may use + different glyph shapes or ligatures. This parameter tells + the font which language the text is in, and to apply the + correct substitutions as appropriate, if available. + It should be a `BCP 47 language code + <https://www.w3.org/International/articles/language-tags/>`_ + Requires libraqm. + + .. versionadded:: 6.0.0 + + :param stroke_width: The width of the text stroke. + + .. versionadded:: 6.2.0 + + :param anchor: The text anchor alignment. Determines the relative location of + the anchor to the text. The default alignment is top left, + specifically ``la`` for horizontal text and ``lt`` for + vertical text. See :ref:`text-anchors` for details. + + .. versionadded:: 8.0.0 + + :param ink: Foreground ink for rendering in RGBA mode. + + .. versionadded:: 8.0.0 + + :param start: Tuple of horizontal and vertical offset, as text may render + differently when starting at fractional coordinates. + + .. versionadded:: 9.4.0 + + :return: An internal PIL storage memory instance as defined by the + :py:mod:`PIL.Image.core` interface module. + """ + return self.getmask2( + text, + mode, + direction=direction, + features=features, + language=language, + stroke_width=stroke_width, + anchor=anchor, + ink=ink, + start=start, + )[0] + + def getmask2( + self, + text: str | bytes, + mode: str = "", + direction: str | None = None, + features: list[str] | None = None, + language: str | None = None, + stroke_width: float = 0, + anchor: str | None = None, + ink: int = 0, + start: tuple[float, float] | None = None, + *args: Any, + **kwargs: Any, + ) -> tuple[Image.core.ImagingCore, tuple[int, int]]: + """ + Create a bitmap for the text. + + If the font uses antialiasing, the bitmap should have mode ``L`` and use a + maximum value of 255. If the font has embedded color data, the bitmap + should have mode ``RGBA``. Otherwise, it should have mode ``1``. + + :param text: Text to render. + :param mode: Used by some graphics drivers to indicate what mode the + driver prefers; if empty, the renderer may return either + mode. Note that the mode is always a string, to simplify + C-level implementations. + + .. versionadded:: 1.1.5 + + :param direction: Direction of the text. It can be 'rtl' (right to + left), 'ltr' (left to right) or 'ttb' (top to bottom). + Requires libraqm. + + .. versionadded:: 4.2.0 + + :param features: A list of OpenType font features to be used during text + layout. This is usually used to turn on optional + font features that are not enabled by default, + for example 'dlig' or 'ss01', but can be also + used to turn off default font features for + example '-liga' to disable ligatures or '-kern' + to disable kerning. To get all supported + features, see + https://learn.microsoft.com/en-us/typography/opentype/spec/featurelist + Requires libraqm. + + .. versionadded:: 4.2.0 + + :param language: Language of the text. Different languages may use + different glyph shapes or ligatures. This parameter tells + the font which language the text is in, and to apply the + correct substitutions as appropriate, if available. + It should be a `BCP 47 language code + <https://www.w3.org/International/articles/language-tags/>`_ + Requires libraqm. + + .. versionadded:: 6.0.0 + + :param stroke_width: The width of the text stroke. + + .. versionadded:: 6.2.0 + + :param anchor: The text anchor alignment. Determines the relative location of + the anchor to the text. The default alignment is top left, + specifically ``la`` for horizontal text and ``lt`` for + vertical text. See :ref:`text-anchors` for details. + + .. versionadded:: 8.0.0 + + :param ink: Foreground ink for rendering in RGBA mode. + + .. versionadded:: 8.0.0 + + :param start: Tuple of horizontal and vertical offset, as text may render + differently when starting at fractional coordinates. + + .. versionadded:: 9.4.0 + + :return: A tuple of an internal PIL storage memory instance as defined by the + :py:mod:`PIL.Image.core` interface module, and the text offset, the + gap between the starting coordinate and the first marking + """ + _string_length_check(text) + if start is None: + start = (0, 0) + + def fill(width: int, height: int) -> Image.core.ImagingCore: + size = (width, height) + Image._decompression_bomb_check(size) + return Image.core.fill("RGBA" if mode == "RGBA" else "L", size) + + return self.font.render( + text, + fill, + mode, + direction, + features, + language, + stroke_width, + kwargs.get("stroke_filled", False), + anchor, + ink, + start, + ) + + def font_variant( + self, + font: StrOrBytesPath | BinaryIO | None = None, + size: float | None = None, + index: int | None = None, + encoding: str | None = None, + layout_engine: Layout | None = None, + ) -> FreeTypeFont: + """ + Create a copy of this FreeTypeFont object, + using any specified arguments to override the settings. + + Parameters are identical to the parameters used to initialize this + object. + + :return: A FreeTypeFont object. + """ + if font is None: + try: + font = BytesIO(self.font_bytes) + except AttributeError: + font = self.path + return FreeTypeFont( + font=font, + size=self.size if size is None else size, + index=self.index if index is None else index, + encoding=self.encoding if encoding is None else encoding, + layout_engine=layout_engine or self.layout_engine, + ) + + def get_variation_names(self) -> list[bytes]: + """ + :returns: A list of the named styles in a variation font. + :exception OSError: If the font is not a variation font. + """ + names = [] + for name in self.font.getvarnames(): + name = name.replace(b"\x00", b"") + if name not in names: + names.append(name) + return names + + def set_variation_by_name(self, name: str | bytes) -> None: + """ + :param name: The name of the style. + :exception OSError: If the font is not a variation font. + """ + names = self.get_variation_names() + if not isinstance(name, bytes): + name = name.encode() + index = names.index(name) + 1 + + if index == getattr(self, "_last_variation_index", None): + # When the same name is set twice in a row, + # there is an 'unknown freetype error' + # https://savannah.nongnu.org/bugs/?56186 + return + self._last_variation_index = index + + self.font.setvarname(index) + + def get_variation_axes(self) -> list[Axis]: + """ + :returns: A list of the axes in a variation font. + :exception OSError: If the font is not a variation font. + """ + axes = self.font.getvaraxes() + for axis in axes: + if axis["name"]: + axis["name"] = axis["name"].replace(b"\x00", b"") + return axes + + def set_variation_by_axes(self, axes: list[float]) -> None: + """ + :param axes: A list of values for each axis. + :exception OSError: If the font is not a variation font. + """ + self.font.setvaraxes(axes) + + +class TransposedFont: + """Wrapper for writing rotated or mirrored text""" + + def __init__( + self, font: ImageFont | FreeTypeFont, orientation: Image.Transpose | None = None + ): + """ + Wrapper that creates a transposed font from any existing font + object. + + :param font: A font object. + :param orientation: An optional orientation. If given, this should + be one of Image.Transpose.FLIP_LEFT_RIGHT, Image.Transpose.FLIP_TOP_BOTTOM, + Image.Transpose.ROTATE_90, Image.Transpose.ROTATE_180, or + Image.Transpose.ROTATE_270. + """ + self.font = font + self.orientation = orientation # any 'transpose' argument, or None + + def getmask( + self, text: str | bytes, mode: str = "", *args: Any, **kwargs: Any + ) -> Image.core.ImagingCore: + im = self.font.getmask(text, mode, *args, **kwargs) + if self.orientation is not None: + return im.transpose(self.orientation) + return im + + def getbbox( + self, text: str | bytes, *args: Any, **kwargs: Any + ) -> tuple[int, int, float, float]: + # TransposedFont doesn't support getmask2, move top-left point to (0, 0) + # this has no effect on ImageFont and simulates anchor="lt" for FreeTypeFont + left, top, right, bottom = self.font.getbbox(text, *args, **kwargs) + width = right - left + height = bottom - top + if self.orientation in (Image.Transpose.ROTATE_90, Image.Transpose.ROTATE_270): + return 0, 0, height, width + return 0, 0, width, height + + def getlength(self, text: str | bytes, *args: Any, **kwargs: Any) -> float: + if self.orientation in (Image.Transpose.ROTATE_90, Image.Transpose.ROTATE_270): + msg = "text length is undefined for text rotated by 90 or 270 degrees" + raise ValueError(msg) + return self.font.getlength(text, *args, **kwargs) + + +def load(filename: str) -> ImageFont: + """ + Load a font file. This function loads a font object from the given + bitmap font file, and returns the corresponding font object. For loading TrueType + or OpenType fonts instead, see :py:func:`~PIL.ImageFont.truetype`. + + :param filename: Name of font file. + :return: A font object. + :exception OSError: If the file could not be read. + """ + f = ImageFont() + f._load_pilfont(filename) + return f + + +def truetype( + font: StrOrBytesPath | BinaryIO, + size: float = 10, + index: int = 0, + encoding: str = "", + layout_engine: Layout | None = None, +) -> FreeTypeFont: + """ + Load a TrueType or OpenType font from a file or file-like object, + and create a font object. This function loads a font object from the given + file or file-like object, and creates a font object for a font of the given + size. For loading bitmap fonts instead, see :py:func:`~PIL.ImageFont.load` + and :py:func:`~PIL.ImageFont.load_path`. + + Pillow uses FreeType to open font files. On Windows, be aware that FreeType + will keep the file open as long as the FreeTypeFont object exists. Windows + limits the number of files that can be open in C at once to 512, so if many + fonts are opened simultaneously and that limit is approached, an + ``OSError`` may be thrown, reporting that FreeType "cannot open resource". + A workaround would be to copy the file(s) into memory, and open that instead. + + This function requires the _imagingft service. + + :param font: A filename or file-like object containing a TrueType font. + If the file is not found in this filename, the loader may also + search in other directories, such as: + + * The :file:`fonts/` directory on Windows, + * :file:`/Library/Fonts/`, :file:`/System/Library/Fonts/` + and :file:`~/Library/Fonts/` on macOS. + * :file:`~/.local/share/fonts`, :file:`/usr/local/share/fonts`, + and :file:`/usr/share/fonts` on Linux; or those specified by + the ``XDG_DATA_HOME`` and ``XDG_DATA_DIRS`` environment variables + for user-installed and system-wide fonts, respectively. + + :param size: The requested size, in pixels. + :param index: Which font face to load (default is first available face). + :param encoding: Which font encoding to use (default is Unicode). Possible + encodings include (see the FreeType documentation for more + information): + + * "unic" (Unicode) + * "symb" (Microsoft Symbol) + * "ADOB" (Adobe Standard) + * "ADBE" (Adobe Expert) + * "ADBC" (Adobe Custom) + * "armn" (Apple Roman) + * "sjis" (Shift JIS) + * "gb " (PRC) + * "big5" + * "wans" (Extended Wansung) + * "joha" (Johab) + * "lat1" (Latin-1) + + This specifies the character set to use. It does not alter the + encoding of any text provided in subsequent operations. + :param layout_engine: Which layout engine to use, if available: + :attr:`.ImageFont.Layout.BASIC` or :attr:`.ImageFont.Layout.RAQM`. + If it is available, Raqm layout will be used by default. + Otherwise, basic layout will be used. + + Raqm layout is recommended for all non-English text. If Raqm layout + is not required, basic layout will have better performance. + + You can check support for Raqm layout using + :py:func:`PIL.features.check_feature` with ``feature="raqm"``. + + .. versionadded:: 4.2.0 + :return: A font object. + :exception OSError: If the file could not be read. + :exception ValueError: If the font size is not greater than zero. + """ + + def freetype(font: StrOrBytesPath | BinaryIO) -> FreeTypeFont: + return FreeTypeFont(font, size, index, encoding, layout_engine) + + try: + return freetype(font) + except OSError: + if not is_path(font): + raise + ttf_filename = os.path.basename(font) + + dirs = [] + if sys.platform == "win32": + # check the windows font repository + # NOTE: must use uppercase WINDIR, to work around bugs in + # 1.5.2's os.environ.get() + windir = os.environ.get("WINDIR") + if windir: + dirs.append(os.path.join(windir, "fonts")) + elif sys.platform in ("linux", "linux2"): + data_home = os.environ.get("XDG_DATA_HOME") + if not data_home: + # The freedesktop spec defines the following default directory for + # when XDG_DATA_HOME is unset or empty. This user-level directory + # takes precedence over system-level directories. + data_home = os.path.expanduser("~/.local/share") + xdg_dirs = [data_home] + + data_dirs = os.environ.get("XDG_DATA_DIRS") + if not data_dirs: + # Similarly, defaults are defined for the system-level directories + data_dirs = "/usr/local/share:/usr/share" + xdg_dirs += data_dirs.split(":") + + dirs += [os.path.join(xdg_dir, "fonts") for xdg_dir in xdg_dirs] + elif sys.platform == "darwin": + dirs += [ + "/Library/Fonts", + "/System/Library/Fonts", + os.path.expanduser("~/Library/Fonts"), + ] + + ext = os.path.splitext(ttf_filename)[1] + first_font_with_a_different_extension = None + for directory in dirs: + for walkroot, walkdir, walkfilenames in os.walk(directory): + for walkfilename in walkfilenames: + if ext and walkfilename == ttf_filename: + return freetype(os.path.join(walkroot, walkfilename)) + elif not ext and os.path.splitext(walkfilename)[0] == ttf_filename: + fontpath = os.path.join(walkroot, walkfilename) + if os.path.splitext(fontpath)[1] == ".ttf": + return freetype(fontpath) + if not ext and first_font_with_a_different_extension is None: + first_font_with_a_different_extension = fontpath + if first_font_with_a_different_extension: + return freetype(first_font_with_a_different_extension) + raise + + +def load_path(filename: str | bytes) -> ImageFont: + """ + Load font file. Same as :py:func:`~PIL.ImageFont.load`, but searches for a + bitmap font along the Python path. + + :param filename: Name of font file. + :return: A font object. + :exception OSError: If the file could not be read. + """ + if not isinstance(filename, str): + filename = filename.decode("utf-8") + for directory in sys.path: + try: + return load(os.path.join(directory, filename)) + except OSError: # noqa: PERF203 + pass + msg = f'cannot find font file "{filename}" in sys.path' + if os.path.exists(filename): + msg += f', did you mean ImageFont.load("{filename}") instead?' + + raise OSError(msg) + + +def load_default_imagefont() -> ImageFont: + f = ImageFont() + f._load_pilfont_data( + # courB08 + BytesIO(base64.b64decode(b""" +UElMZm9udAo7Ozs7OzsxMDsKREFUQQoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAYAAAAA//8AAQAAAAAAAAABAAEA +BgAAAAH/+gADAAAAAQAAAAMABgAGAAAAAf/6AAT//QADAAAABgADAAYAAAAA//kABQABAAYAAAAL +AAgABgAAAAD/+AAFAAEACwAAABAACQAGAAAAAP/5AAUAAAAQAAAAFQAHAAYAAP////oABQAAABUA +AAAbAAYABgAAAAH/+QAE//wAGwAAAB4AAwAGAAAAAf/5AAQAAQAeAAAAIQAIAAYAAAAB//kABAAB +ACEAAAAkAAgABgAAAAD/+QAE//0AJAAAACgABAAGAAAAAP/6AAX//wAoAAAALQAFAAYAAAAB//8A +BAACAC0AAAAwAAMABgAAAAD//AAF//0AMAAAADUAAQAGAAAAAf//AAMAAAA1AAAANwABAAYAAAAB +//kABQABADcAAAA7AAgABgAAAAD/+QAFAAAAOwAAAEAABwAGAAAAAP/5AAYAAABAAAAARgAHAAYA +AAAA//kABQAAAEYAAABLAAcABgAAAAD/+QAFAAAASwAAAFAABwAGAAAAAP/5AAYAAABQAAAAVgAH +AAYAAAAA//kABQAAAFYAAABbAAcABgAAAAD/+QAFAAAAWwAAAGAABwAGAAAAAP/5AAUAAABgAAAA +ZQAHAAYAAAAA//kABQAAAGUAAABqAAcABgAAAAD/+QAFAAAAagAAAG8ABwAGAAAAAf/8AAMAAABv +AAAAcQAEAAYAAAAA//wAAwACAHEAAAB0AAYABgAAAAD/+gAE//8AdAAAAHgABQAGAAAAAP/7AAT/ +/gB4AAAAfAADAAYAAAAB//oABf//AHwAAACAAAUABgAAAAD/+gAFAAAAgAAAAIUABgAGAAAAAP/5 +AAYAAQCFAAAAiwAIAAYAAP////oABgAAAIsAAACSAAYABgAA////+gAFAAAAkgAAAJgABgAGAAAA +AP/6AAUAAACYAAAAnQAGAAYAAP////oABQAAAJ0AAACjAAYABgAA////+gAFAAAAowAAAKkABgAG +AAD////6AAUAAACpAAAArwAGAAYAAAAA//oABQAAAK8AAAC0AAYABgAA////+gAGAAAAtAAAALsA +BgAGAAAAAP/6AAQAAAC7AAAAvwAGAAYAAP////oABQAAAL8AAADFAAYABgAA////+gAGAAAAxQAA +AMwABgAGAAD////6AAUAAADMAAAA0gAGAAYAAP////oABQAAANIAAADYAAYABgAA////+gAGAAAA +2AAAAN8ABgAGAAAAAP/6AAUAAADfAAAA5AAGAAYAAP////oABQAAAOQAAADqAAYABgAAAAD/+gAF +AAEA6gAAAO8ABwAGAAD////6AAYAAADvAAAA9gAGAAYAAAAA//oABQAAAPYAAAD7AAYABgAA//// ++gAFAAAA+wAAAQEABgAGAAD////6AAYAAAEBAAABCAAGAAYAAP////oABgAAAQgAAAEPAAYABgAA +////+gAGAAABDwAAARYABgAGAAAAAP/6AAYAAAEWAAABHAAGAAYAAP////oABgAAARwAAAEjAAYA +BgAAAAD/+gAFAAABIwAAASgABgAGAAAAAf/5AAQAAQEoAAABKwAIAAYAAAAA//kABAABASsAAAEv +AAgABgAAAAH/+QAEAAEBLwAAATIACAAGAAAAAP/5AAX//AEyAAABNwADAAYAAAAAAAEABgACATcA +AAE9AAEABgAAAAH/+QAE//wBPQAAAUAAAwAGAAAAAP/7AAYAAAFAAAABRgAFAAYAAP////kABQAA +AUYAAAFMAAcABgAAAAD/+wAFAAABTAAAAVEABQAGAAAAAP/5AAYAAAFRAAABVwAHAAYAAAAA//sA +BQAAAVcAAAFcAAUABgAAAAD/+QAFAAABXAAAAWEABwAGAAAAAP/7AAYAAgFhAAABZwAHAAYAAP// +//kABQAAAWcAAAFtAAcABgAAAAD/+QAGAAABbQAAAXMABwAGAAAAAP/5AAQAAgFzAAABdwAJAAYA +AP////kABgAAAXcAAAF+AAcABgAAAAD/+QAGAAABfgAAAYQABwAGAAD////7AAUAAAGEAAABigAF +AAYAAP////sABQAAAYoAAAGQAAUABgAAAAD/+wAFAAABkAAAAZUABQAGAAD////7AAUAAgGVAAAB +mwAHAAYAAAAA//sABgACAZsAAAGhAAcABgAAAAD/+wAGAAABoQAAAacABQAGAAAAAP/7AAYAAAGn +AAABrQAFAAYAAAAA//kABgAAAa0AAAGzAAcABgAA////+wAGAAABswAAAboABQAGAAD////7AAUA +AAG6AAABwAAFAAYAAP////sABgAAAcAAAAHHAAUABgAAAAD/+wAGAAABxwAAAc0ABQAGAAD////7 +AAYAAgHNAAAB1AAHAAYAAAAA//sABQAAAdQAAAHZAAUABgAAAAH/+QAFAAEB2QAAAd0ACAAGAAAA +Av/6AAMAAQHdAAAB3gAHAAYAAAAA//kABAABAd4AAAHiAAgABgAAAAD/+wAF//0B4gAAAecAAgAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAYAAAAB +//sAAwACAecAAAHpAAcABgAAAAD/+QAFAAEB6QAAAe4ACAAGAAAAAP/5AAYAAAHuAAAB9AAHAAYA +AAAA//oABf//AfQAAAH5AAUABgAAAAD/+QAGAAAB+QAAAf8ABwAGAAAAAv/5AAMAAgH/AAACAAAJ +AAYAAAAA//kABQABAgAAAAIFAAgABgAAAAH/+gAE//sCBQAAAggAAQAGAAAAAP/5AAYAAAIIAAAC +DgAHAAYAAAAB//kABf/+Ag4AAAISAAUABgAA////+wAGAAACEgAAAhkABQAGAAAAAP/7AAX//gIZ +AAACHgADAAYAAAAA//wABf/9Ah4AAAIjAAEABgAAAAD/+QAHAAACIwAAAioABwAGAAAAAP/6AAT/ ++wIqAAACLgABAAYAAAAA//kABP/8Ai4AAAIyAAMABgAAAAD/+gAFAAACMgAAAjcABgAGAAAAAf/5 +AAT//QI3AAACOgAEAAYAAAAB//kABP/9AjoAAAI9AAQABgAAAAL/+QAE//sCPQAAAj8AAgAGAAD/ +///7AAYAAgI/AAACRgAHAAYAAAAA//kABgABAkYAAAJMAAgABgAAAAH//AAD//0CTAAAAk4AAQAG +AAAAAf//AAQAAgJOAAACUQADAAYAAAAB//kABP/9AlEAAAJUAAQABgAAAAH/+QAF//4CVAAAAlgA +BQAGAAD////7AAYAAAJYAAACXwAFAAYAAP////kABgAAAl8AAAJmAAcABgAA////+QAGAAACZgAA +Am0ABwAGAAD////5AAYAAAJtAAACdAAHAAYAAAAA//sABQACAnQAAAJ5AAcABgAA////9wAGAAAC +eQAAAoAACQAGAAD////3AAYAAAKAAAAChwAJAAYAAP////cABgAAAocAAAKOAAkABgAA////9wAG +AAACjgAAApUACQAGAAD////4AAYAAAKVAAACnAAIAAYAAP////cABgAAApwAAAKjAAkABgAA//// ++gAGAAACowAAAqoABgAGAAAAAP/6AAUAAgKqAAACrwAIAAYAAP////cABQAAAq8AAAK1AAkABgAA +////9wAFAAACtQAAArsACQAGAAD////3AAUAAAK7AAACwQAJAAYAAP////gABQAAAsEAAALHAAgA +BgAAAAD/9wAEAAACxwAAAssACQAGAAAAAP/3AAQAAALLAAACzwAJAAYAAAAA//cABAAAAs8AAALT +AAkABgAAAAD/+AAEAAAC0wAAAtcACAAGAAD////6AAUAAALXAAAC3QAGAAYAAP////cABgAAAt0A +AALkAAkABgAAAAD/9wAFAAAC5AAAAukACQAGAAAAAP/3AAUAAALpAAAC7gAJAAYAAAAA//cABQAA +Au4AAALzAAkABgAAAAD/9wAFAAAC8wAAAvgACQAGAAAAAP/4AAUAAAL4AAAC/QAIAAYAAAAA//oA +Bf//Av0AAAMCAAUABgAA////+gAGAAADAgAAAwkABgAGAAD////3AAYAAAMJAAADEAAJAAYAAP// +//cABgAAAxAAAAMXAAkABgAA////9wAGAAADFwAAAx4ACQAGAAD////4AAYAAAAAAAoABwASAAYA +AP////cABgAAAAcACgAOABMABgAA////+gAFAAAADgAKABQAEAAGAAD////6AAYAAAAUAAoAGwAQ +AAYAAAAA//gABgAAABsACgAhABIABgAAAAD/+AAGAAAAIQAKACcAEgAGAAAAAP/4AAYAAAAnAAoA +LQASAAYAAAAA//gABgAAAC0ACgAzABIABgAAAAD/+QAGAAAAMwAKADkAEQAGAAAAAP/3AAYAAAA5 +AAoAPwATAAYAAP////sABQAAAD8ACgBFAA8ABgAAAAD/+wAFAAIARQAKAEoAEQAGAAAAAP/4AAUA +AABKAAoATwASAAYAAAAA//gABQAAAE8ACgBUABIABgAAAAD/+AAFAAAAVAAKAFkAEgAGAAAAAP/5 +AAUAAABZAAoAXgARAAYAAAAA//gABgAAAF4ACgBkABIABgAAAAD/+AAGAAAAZAAKAGoAEgAGAAAA +AP/4AAYAAABqAAoAcAASAAYAAAAA//kABgAAAHAACgB2ABEABgAAAAD/+AAFAAAAdgAKAHsAEgAG +AAD////4AAYAAAB7AAoAggASAAYAAAAA//gABQAAAIIACgCHABIABgAAAAD/+AAFAAAAhwAKAIwA +EgAGAAAAAP/4AAUAAACMAAoAkQASAAYAAAAA//gABQAAAJEACgCWABIABgAAAAD/+QAFAAAAlgAK +AJsAEQAGAAAAAP/6AAX//wCbAAoAoAAPAAYAAAAA//oABQABAKAACgClABEABgAA////+AAGAAAA +pQAKAKwAEgAGAAD////4AAYAAACsAAoAswASAAYAAP////gABgAAALMACgC6ABIABgAA////+QAG +AAAAugAKAMEAEQAGAAD////4AAYAAgDBAAoAyAAUAAYAAP////kABQACAMgACgDOABMABgAA//// ++QAGAAIAzgAKANUAEw== +""")), + Image.open(BytesIO(base64.b64decode(b""" +iVBORw0KGgoAAAANSUhEUgAAAx4AAAAUAQAAAAArMtZoAAAEwElEQVR4nABlAJr/AHVE4czCI/4u +Mc4b7vuds/xzjz5/3/7u/n9vMe7vnfH/9++vPn/xyf5zhxzjt8GHw8+2d83u8x27199/nxuQ6Od9 +M43/5z2I+9n9ZtmDBwMQECDRQw/eQIQohJXxpBCNVE6QCCAAAAD//wBlAJr/AgALyj1t/wINwq0g +LeNZUworuN1cjTPIzrTX6ofHWeo3v336qPzfEwRmBnHTtf95/fglZK5N0PDgfRTslpGBvz7LFc4F +IUXBWQGjQ5MGCx34EDFPwXiY4YbYxavpnhHFrk14CDAAAAD//wBlAJr/AgKqRooH2gAgPeggvUAA +Bu2WfgPoAwzRAABAAAAAAACQgLz/3Uv4Gv+gX7BJgDeeGP6AAAD1NMDzKHD7ANWr3loYbxsAD791 +NAADfcoIDyP44K/jv4Y63/Z+t98Ovt+ub4T48LAAAAD//wBlAJr/AuplMlADJAAAAGuAphWpqhMx +in0A/fRvAYBABPgBwBUgABBQ/sYAyv9g0bCHgOLoGAAAAAAAREAAwI7nr0ArYpow7aX8//9LaP/9 +SjdavWA8ePHeBIKB//81/83ndznOaXx379wAAAD//wBlAJr/AqDxW+D3AABAAbUh/QMnbQag/gAY +AYDAAACgtgD/gOqAAAB5IA/8AAAk+n9w0AAA8AAAmFRJuPo27ciC0cD5oeW4E7KA/wD3ECMAn2tt +y8PgwH8AfAxFzC0JzeAMtratAsC/ffwAAAD//wBlAJr/BGKAyCAA4AAAAvgeYTAwHd1kmQF5chkG +ABoMIHcL5xVpTfQbUqzlAAAErwAQBgAAEOClA5D9il08AEh/tUzdCBsXkbgACED+woQg8Si9VeqY +lODCn7lmF6NhnAEYgAAA/NMIAAAAAAD//2JgjLZgVGBg5Pv/Tvpc8hwGBjYGJADjHDrAwPzAjv/H +/Wf3PzCwtzcwHmBgYGcwbZz8wHaCAQMDOwMDQ8MCBgYOC3W7mp+f0w+wHOYxO3OG+e376hsMZjk3 +AAAAAP//YmCMY2A4wMAIN5e5gQETPD6AZisDAwMDgzSDAAPjByiHcQMDAwMDg1nOze1lByRu5/47 +c4859311AYNZzg0AAAAA//9iYGDBYihOIIMuwIjGL39/fwffA8b//xv/P2BPtzzHwCBjUQAAAAD/ +/yLFBrIBAAAA//9i1HhcwdhizX7u8NZNzyLbvT97bfrMf/QHI8evOwcSqGUJAAAA//9iYBB81iSw +pEE170Qrg5MIYydHqwdDQRMrAwcVrQAAAAD//2J4x7j9AAMDn8Q/BgYLBoaiAwwMjPdvMDBYM1Tv +oJodAAAAAP//Yqo/83+dxePWlxl3npsel9lvLfPcqlE9725C+acfVLMEAAAA//9i+s9gwCoaaGMR +evta/58PTEWzr21hufPjA8N+qlnBwAAAAAD//2JiWLci5v1+HmFXDqcnULE/MxgYGBj+f6CaJQAA +AAD//2Ji2FrkY3iYpYC5qDeGgeEMAwPDvwQBBoYvcTwOVLMEAAAA//9isDBgkP///0EOg9z35v// +Gc/eeW7BwPj5+QGZhANUswMAAAD//2JgqGBgYGBgqEMXlvhMPUsAAAAA//8iYDd1AAAAAP//AwDR +w7IkEbzhVQAAAABJRU5ErkJggg== +"""))), + ) + return f + + +def load_default(size: float | None = None) -> FreeTypeFont | ImageFont: + """If FreeType support is available, load a version of Aileron Regular, + https://dotcolon.net/fonts/aileron, with a more limited character set. + + Otherwise, load a "better than nothing" font. + + .. versionadded:: 1.1.4 + + :param size: The font size of Aileron Regular. + + .. versionadded:: 10.1.0 + + :return: A font object. + """ + if isinstance(core, ModuleType) or size is not None: + return truetype( + BytesIO(base64.b64decode(b""" +AAEAAAAPAIAAAwBwRkZUTYwDlUAAADFoAAAAHEdERUYAqADnAAAo8AAAACRHUE9ThhmITwAAKfgAA +AduR1NVQnHxefoAACkUAAAA4k9TLzJovoHLAAABeAAAAGBjbWFw5lFQMQAAA6gAAAGqZ2FzcP//AA +MAACjoAAAACGdseWYmRXoPAAAGQAAAHfhoZWFkE18ayQAAAPwAAAA2aGhlYQboArEAAAE0AAAAJGh +tdHjjERZ8AAAB2AAAAdBsb2NhuOexrgAABVQAAADqbWF4cAC7AEYAAAFYAAAAIG5hbWUr+h5lAAAk +OAAAA6Jwb3N0D3oPTQAAJ9wAAAEKAAEAAAABGhxJDqIhXw889QALA+gAAAAA0Bqf2QAAAADhCh2h/ +2r/LgOxAyAAAAAIAAIAAAAAAAAAAQAAA8r/GgAAA7j/av9qA7EAAQAAAAAAAAAAAAAAAAAAAHQAAQ +AAAHQAQwAFAAAAAAACAAAAAQABAAAAQAAAAAAAAAADAfoBkAAFAAgCigJYAAAASwKKAlgAAAFeADI +BPgAAAAAFAAAAAAAAAAAAAAcAAAAAAAAAAAAAAABVS1dOAEAAIPsCAwL/GgDIA8oA5iAAAJMAAAAA +AhICsgAAACAAAwH0AAAAAAAAAU0AAADYAAAA8gA5AVMAVgJEAEYCRAA1AuQAKQKOAEAAsAArATsAZ +AE7AB4CMABVAkQAUADc/+EBEgAgANwAJQEv//sCRAApAkQAggJEADwCRAAtAkQAIQJEADkCRAArAk +QAMgJEACwCRAAxANwAJQDc/+ECRABnAkQAUAJEAEQB8wAjA1QANgJ/AB0CcwBkArsALwLFAGQCSwB +kAjcAZALGAC8C2gBkAQgAZAIgADcCYQBkAj8AZANiAGQCzgBkAuEALwJWAGQC3QAvAmsAZAJJADQC +ZAAiAqoAXgJuACADuAAaAnEAGQJFABMCTwAuATMAYgEv//sBJwAiAkQAUAH0ADIBLAApAhMAJAJjA +EoCEQAeAmcAHgIlAB4BIgAVAmcAHgJRAEoA7gA+AOn/8wIKAEoA9wBGA1cASgJRAEoCSgAeAmMASg +JnAB4BSgBKAcsAGAE5ABQCUABCAgIAAQMRAAEB4v/6AgEAAQHOABQBLwBAAPoAYAEvACECRABNA0Y +AJAItAHgBKgAcAkQAUAEsAHQAygAgAi0AOQD3ADYA9wAWAaEANgGhABYCbAAlAYMAeAGDADkA6/9q +AhsAFAIKABUB/QAVAAAAAwAAAAMAAAAcAAEAAAAAAKQAAwABAAAAHAAEAIgAAAAeABAAAwAOAH4Aq +QCrALEAtAC3ALsgGSAdICYgOiBEISL7Av//AAAAIACpAKsAsAC0ALcAuyAYIBwgJiA5IEQhIvsB// +//4/+5/7j/tP+y/7D/reBR4E/gR+A14CzfTwVxAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAEGAAABAAAAAAAAAAECAAAAAgAAAAAAAAAAAAAAAAAAAAEAAAMEBQYHCAkKCwwNDg8QERIT +FBUWFxgZGhscHR4fICEiIyQlJicoKSorLC0uLzAxMjM0NTY3ODk6Ozw9Pj9AQUJDREVGR0hJSktMT +U5PUFFSU1RVVldYWVpbXF1eX2BhAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGQAAA +AAAAAAYnFmAAAAAABlAAAAAAAAAAAAAAAAAAAAAAAAAAAAY2htAAAAAAAAAABrbGlqAAAAAHAAbm9 +ycwBnAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAmACYAJgAmAD4AUgCCAMoBCgFO +AVwBcgGIAaYBvAHKAdYB6AH2AgwCIAJKAogCpgLWAw4DIgNkA5wDugPUA+gD/AQQBEYEogS8BPoFJ +gVSBWoFgAWwBcoF1gX6BhQGJAZMBmgGiga0BuIHGgdUB2YHkAeiB8AH3AfyCAoIHAgqCDoITghcCG +oIogjSCPoJKglYCXwJwgnqCgIKKApACl4Klgq8CtwLDAs8C1YLjAuyC9oL7gwMDCYMSAxgDKAMrAz +qDQoNTA1mDYQNoA2uDcAN2g3oDfYODA4iDkoOXA5sDnoOnA7EDvwAAAAFAAAAAAH0ArwAAwAGAAkA +DAAPAAAxESERAxMhExcRASELARETAfT6qv6syKr+jgFUqsiqArz9RAGLAP/+1P8B/v3VAP8BLP4CA +P8AAgA5//IAuQKyAAMACwAANyMDMwIyFhQGIiY0oE4MZk84JCQ4JLQB/v3AJDgkJDgAAgBWAeUBPA +LfAAMABwAAEyMnMxcjJzOmRgpagkYKWgHl+vr6AAAAAAIARgAAAf4CsgAbAB8AAAEHMxUjByM3Iwc +jNyM1MzcjNTM3MwczNzMHMxUrAQczAZgdZXEvOi9bLzovWmYdZXEvOi9bLzovWp9bHlsBn4w429vb +2ziMONvb29s4jAAAAAMANf+mAg4DDAAfACYALAAAJRQGBxUjNS4BJzMeARcRLgE0Njc1MxUeARcjJ +icVHgEBFBYXNQ4BExU+ATU0Ag5xWDpgcgRcBz41Xl9oVTpVYwpcC1ttXP6cLTQuM5szOrVRZwlOTQ +ZqVzZECAEAGlukZAlOTQdrUG8O7iNlAQgxNhDlCDj+8/YGOjReAAAAAAUAKf/yArsCvAAHAAsAFQA +dACcAABIyFhQGIiY0EyMBMwQiBhUUFjI2NTQSMhYUBiImNDYiBhUUFjI2NTR5iFBQiFCVVwHAV/5c +OiMjOiPmiFBQiFCxOiMjOiMCvFaSVlaS/ZoCsjIzMC80NC8w/uNWklZWkhozMC80NC8wAAAAAgBA/ +/ICbgLAACIALgAAARUjEQYjIiY1NDY3LgE1NDYzMhcVJiMiBhUUFhcWOwE1MxUFFBYzMjc1IyIHDg +ECbmBcYYOOVkg7R4hsQjY4Q0RNRD4SLDxW/pJUXzksPCkUUk0BgUb+zBVUZ0BkDw5RO1huCkULQzp +COAMBcHDHRz0J/AIHRQAAAAEAKwHlAIUC3wADAAATIycze0YKWgHl+gAAAAABAGT/sAEXAwwACQAA +EzMGEBcjLgE0Nt06dXU6OUBAAwzG/jDGVePs4wAAAAEAHv+wANEDDAAJAAATMx4BFAYHIzYQHjo5Q +EA5OnUDDFXj7ONVxgHQAAAAAQBVAFIB2wHbAA4AAAE3FwcXBycHJzcnNxcnMwEtmxOfcTJjYzJxnx +ObCj4BKD07KYolmZkliik7PbMAAQBQAFUB9AIlAAsAAAEjFSM1IzUzNTMVMwH0tTq1tTq1AR/Kyjj +OzgAAAAAB/+H/iACMAGQABAAANwcjNzOMWlFOXVrS3AAAAQAgAP8A8gE3AAMAABMjNTPy0tIA/zgA +AQAl//IApQByAAcAADYyFhQGIiY0STgkJDgkciQ4JCQ4AAAAAf/7/+IBNALQAAMAABcjEzM5Pvs+H +gLuAAAAAAIAKf/yAhsCwAADAAcAABIgECA2IBAgKQHy/g5gATL+zgLA/TJEAkYAAAAAAQCCAAABlg +KyAAgAAAERIxEHNTc2MwGWVr6SIygCsv1OAldxW1sWAAEAPAAAAg4CwAAZAAA3IRUhNRM+ATU0JiM +iDwEjNz4BMzIWFRQGB7kBUv4x+kI2QTt+EAFWAQp8aGVtSl5GRjEA/0RVLzlLmAoKa3FsUkNxXQAA +AAEALf/yAhYCwAAqAAABHgEVFAYjIi8BMxceATMyNjU0KwE1MzI2NTQmIyIGDwEjNz4BMzIWFRQGA +YxBSZJo2RUBVgEHV0JBUaQREUBUQzc5TQcBVgEKfGhfcEMBbxJbQl1x0AoKRkZHPn9GSD80QUVCCg +pfbGBPOlgAAAACACEAAAIkArIACgAPAAAlIxUjNSE1ATMRMyMRBg8BAiRXVv6qAVZWV60dHLCurq4 +rAdn+QgFLMibzAAABADn/8gIZArIAHQAAATIWFRQGIyIvATMXFjMyNjU0JiMiByMTIRUhBzc2ATNv +d5Fl1RQBVgIad0VSTkVhL1IwAYj+vh8rMAHHgGdtgcUKCoFXTU5bYgGRRvAuHQAAAAACACv/8gITA +sAAFwAjAAABMhYVFAYjIhE0NjMyFh8BIycmIyIDNzYTMjY1NCYjIgYVFBYBLmp7imr0l3RZdAgBXA +IYZ5wKJzU6QVNJSz5SUAHSgWltiQFGxcNlVQoKdv7sPiz+ZF1LTmJbU0lhAAAAAQAyAAACGgKyAAY +AAAEVASMBITUCGv6oXAFL/oECsij9dgJsRgAAAAMALP/xAhgCwAAWACAALAAAAR4BFRQGIyImNTQ2 +Ny4BNTQ2MhYVFAYmIgYVFBYyNjU0AzI2NTQmIyIGFRQWAZQ5S5BmbIpPOjA7ecp5P2F8Q0J8RIVJS +0pLTEtOAW0TXTxpZ2ZqPF0SE1A3VWVlVTdQ/UU0N0RENzT9/ko+Ok1NOj1LAAIAMf/yAhkCwAAXAC +MAAAEyERQGIyImLwEzFxYzMhMHBiMiJjU0NhMyNjU0JiMiBhUUFgEl9Jd0WXQIAVwCGGecCic1SWp +7imo+UlBAQVNJAsD+usXDZVUKCnYBFD4sgWltif5kW1NJYV1LTmIAAAACACX/8gClAiAABwAPAAAS +MhYUBiImNBIyFhQGIiY0STgkJDgkJDgkJDgkAiAkOCQkOP52JDgkJDgAAAAC/+H/iAClAiAABwAMA +AASMhYUBiImNBMHIzczSTgkJDgkaFpSTl4CICQ4JCQ4/mba5gAAAQBnAB4B+AH0AAYAAAENARUlNS +UB+P6qAVb+bwGRAbCmpkbJRMkAAAIAUAC7AfQBuwADAAcAAAEhNSERITUhAfT+XAGk/lwBpAGDOP8 +AOAABAEQAHgHVAfQABgAAARUFNS0BNQHV/m8BVv6qAStEyUSmpkYAAAAAAgAj//IB1ALAABgAIAAA +ATIWFRQHDgEHIz4BNz4BNTQmIyIGByM+ARIyFhQGIiY0AQRibmktIAJWBSEqNig+NTlHBFoDezQ4J +CQ4JALAZ1BjaS03JS1DMD5LLDQ/SUVgcv2yJDgkJDgAAAAAAgA2/5gDFgKYADYAQgAAAQMGFRQzMj +Y1NCYjIg4CFRQWMzI2NxcGIyImNTQ+AjMyFhUUBiMiJwcGIyImNTQ2MzIfATcHNzYmIyIGFRQzMjY +Cej8EJjJJlnBAfGQ+oHtAhjUYg5OPx0h2k06Os3xRWQsVLjY5VHtdPBwJETcJDyUoOkZEJz8B0f74 +EQ8kZl6EkTFZjVOLlyknMVm1pmCiaTq4lX6CSCknTVRmmR8wPdYnQzxuSWVGAAIAHQAAAncCsgAHA +AoAACUjByMTMxMjATMDAcj+UVz4dO5d/sjPZPT0ArL9TgE6ATQAAAADAGQAAAJMArIAEAAbACcAAA +EeARUUBgcGKwERMzIXFhUUJRUzMjc2NTQnJiMTPgE1NCcmKwEVMzIBvkdHZkwiNt7LOSGq/oeFHBt +hahIlSTM+cB8Yj5UWAW8QT0VYYgwFArIEF5Fv1eMED2NfDAL93AU+N24PBP0AAAAAAQAv//ICjwLA +ABsAAAEyFh8BIycmIyIGFRQWMzI/ATMHDgEjIiY1NDYBdX+PCwFWAiKiaHx5ZaIiAlYBCpWBk6a0A +sCAagoKpqN/gaOmCgplhcicn8sAAAIAZAAAAp8CsgAMABkAAAEeARUUBgcGKwERMzITPgE1NCYnJi +sBETMyAY59lJp8IzXN0jUVWmdjWRs5d3I4Aq4QqJWUug8EArL9mQ+PeHGHDgX92gAAAAABAGQAAAI +vArIACwAAJRUhESEVIRUhFSEVAi/+NQHB/pUBTf6zRkYCskbwRvAAAAABAGQAAAIlArIACQAAExUh +FSERIxEhFboBQ/69VgHBAmzwRv7KArJGAAAAAAEAL//yAo8CwAAfAAABMxEjNQcGIyImNTQ2MzIWH +wEjJyYjIgYVFBYzMjY1IwGP90wfPnWTprSSf48LAVYCIqJofHllVG+hAU3+s3hARsicn8uAagoKpq +N/gaN1XAAAAAEAZAAAAowCsgALAAABESMRIREjETMRIRECjFb+hFZWAXwCsv1OAS7+0gKy/sQBPAA +AAAABAGQAAAC6ArIAAwAAMyMRM7pWVgKyAAABADf/8gHoArIAEwAAAREUBw4BIyImLwEzFxYzMjc2 +NREB6AIFcGpgbQIBVgIHfXQKAQKy/lYxIltob2EpKYyEFD0BpwAAAAABAGQAAAJ0ArIACwAACQEjA +wcVIxEzEQEzATsBJ3ntQlZWAVVlAWH+nwEnR+ACsv6RAW8AAQBkAAACLwKyAAUAACUVIREzEQIv/j +VWRkYCsv2UAAABAGQAAAMUArIAFAAAAREjETQ3BgcDIwMmJxYVESMRMxsBAxRWAiMxemx8NxsCVo7 +MywKy/U4BY7ZLco7+nAFmoFxLtP6dArL9lwJpAAAAAAEAZAAAAoACsgANAAAhIwEWFREjETMBJjUR +MwKAhP67A1aEAUUDVAJeeov+pwKy/aJ5jAFZAAAAAgAv//ICuwLAAAkAEwAAEiAWFRQGICY1NBIyN +jU0JiIGFRTbATSsrP7MrNrYenrYegLAxaKhxsahov47nIeIm5uIhwACAGQAAAJHArIADgAYAAABHg +EVFAYHBisBESMRMzITNjQnJisBETMyAZRUX2VOHzuAVtY7GlxcGDWIiDUCrgtnVlVpCgT+5gKy/rU +V1BUF/vgAAAACAC//zAK9AsAAEgAcAAAlFhcHJiMiBwYjIiY1NDYgFhUUJRQWMjY1NCYiBgI9PUMx +UDcfKh8omqysATSs/dR62Hp62HpICTg7NgkHxqGixcWitbWHnJyHiJubAAIAZAAAAlgCsgAXACMAA +CUWFyMmJyYnJisBESMRMzIXHgEVFAYHFiUzMjc+ATU0JyYrAQIqDCJfGQwNWhAhglbiOx9QXEY1Tv +6bhDATMj1lGSyMtYgtOXR0BwH+1wKyBApbU0BSESRAAgVAOGoQBAABADT/8gIoAsAAJQAAATIWFyM +uASMiBhUUFhceARUUBiMiJiczHgEzMjY1NCYnLgE1NDYBOmd2ClwGS0E6SUNRdW+HZnKKC1wPWkQ9 +Uk1cZGuEAsBwXUJHNjQ3OhIbZVZZbm5kREo+NT5DFRdYUFdrAAAAAAEAIgAAAmQCsgAHAAABIxEjE +SM1IQJk9lb2AkICbP2UAmxGAAEAXv/yAmQCsgAXAAABERQHDgEiJicmNREzERQXHgEyNjc2NRECZA +IIgfCBCAJWAgZYmlgGAgKy/k0qFFxzc1wUKgGz/lUrEkRQUEQSKwGrAAAAAAEAIAAAAnoCsgAGAAA +hIwMzGwEzAYJ07l3N1FwCsv2PAnEAAAEAGgAAA7ECsgAMAAABAyMLASMDMxsBMxsBA7HAcZyicrZi +kaB0nJkCsv1OAlP9rQKy/ZsCW/2kAmYAAAEAGQAAAm8CsgALAAAhCwEjEwMzGwEzAxMCCsrEY/bkY +re+Y/D6AST+3AFcAVb+5gEa/q3+oQAAAQATAAACUQKyAAgAAAERIxEDMxsBMwFdVvRjwLphARD+8A +EQAaL+sQFPAAABAC4AAAI5ArIACQAAJRUhNQEhNSEVAQI5/fUBof57Aen+YUZGQgIqRkX92QAAAAA +BAGL/sAEFAwwABwAAARUjETMVIxEBBWlpowMMOP0UOANcAAAB//v/4gE0AtAAAwAABSMDMwE0Pvs+ +HgLuAAAAAQAi/7AAxQMMAAcAABcjNTMRIzUzxaNpaaNQOALsOAABAFAA1wH0AmgABgAAJQsBIxMzE +wGwjY1GsESw1wFZ/qcBkf5vAAAAAQAy/6oBwv/iAAMAAAUhNSEBwv5wAZBWOAAAAAEAKQJEALYCsg +ADAAATIycztjhVUAJEbgAAAAACACT/8gHQAiAAHQAlAAAhJwcGIyImNTQ2OwE1NCcmIyIHIz4BMzI +XFh0BFBcnMjY9ASYVFAF6CR0wVUtgkJoiAgdgaQlaBm1Zrg4DCuQ9R+5MOSFQR1tbDiwUUXBUXowf +J8c9SjRORzYSgVwAAAAAAgBK//ICRQLfABEAHgAAATIWFRQGIyImLwEVIxEzETc2EzI2NTQmIyIGH +QEUFgFUcYCVbiNJEyNWVigySElcU01JXmECIJd4i5QTEDRJAt/+3jkq/hRuZV55ZWsdX14AAQAe// +IB9wIgABgAAAEyFhcjJiMiBhUUFjMyNjczDgEjIiY1NDYBF152DFocbEJXU0A1Rw1aE3pbaoKQAiB +oWH5qZm1tPDlaXYuLgZcAAAACAB7/8gIZAt8AEQAeAAABESM1BwYjIiY1NDYzMhYfAREDMjY9ATQm +IyIGFRQWAhlWKDJacYCVbiNJEyOnSV5hQUlcUwLf/SFVOSqXeIuUExA0ARb9VWVrHV9ebmVeeQACA +B7/8gH9AiAAFQAbAAABFAchHgEzMjY3Mw4BIyImNTQ2MzIWJyIGByEmAf0C/oAGUkA1SwlaD4FXbI +WObmt45UBVBwEqDQEYFhNjWD84W16Oh3+akU9aU60AAAEAFQAAARoC8gAWAAATBh0BMxUjESMRIzU +zNTQ3PgEzMhcVJqcDbW1WOTkDB0k8Hx5oAngVITRC/jQBzEIsJRs5PwVHEwAAAAIAHv8uAhkCIAAi +AC8AAAERFAcOASMiLwEzFx4BMzI2NzY9AQcGIyImNTQ2MzIWHwE1AzI2PQE0JiMiBhUUFgIZAQSEd +NwRAVcBBU5DTlUDASgyWnGAlW4jSRMjp0leYUFJXFMCEv5wSh1zeq8KCTI8VU0ZIQk5Kpd4i5QTED +RJ/iJlax1fXm5lXnkAAQBKAAACCgLkABcAAAEWFREjETQnLgEHDgEdASMRMxE3NjMyFgIIAlYCBDs +6RVRWViE5UVViAYUbQP7WASQxGzI7AQJyf+kC5P7TPSxUAAACAD4AAACsAsAABwALAAASMhYUBiIm +NBMjETNeLiAgLiBiVlYCwCAuICAu/WACEgAC//P/LgCnAsAABwAVAAASMhYUBiImNBcRFAcGIyInN +RY3NjURWS4gIC4gYgMLcRwNSgYCAsAgLiAgLo79wCUbZAJGBzMOHgJEAAAAAQBKAAACCALfAAsAAC +EnBxUjETMREzMHEwGTwTJWVvdu9/rgN6kC3/4oAQv6/ugAAQBG//wA3gLfAA8AABMRFBceATcVBiM +iJicmNRGcAQIcIxkkKi4CAQLf/bkhERoSBD4EJC8SNAJKAAAAAQBKAAADEAIgACQAAAEWFREjETQn +JiMiFREjETQnJiMiFREjETMVNzYzMhYXNzYzMhYDCwVWBAxedFYEDF50VlYiJko7ThAvJkpEVAGfI +jn+vAEcQyRZ1v76ARxDJFnW/voCEk08HzYtRB9HAAAAAAEASgAAAgoCIAAWAAABFhURIxE0JyYjIg +YdASMRMxU3NjMyFgIIAlYCCXBEVVZWITlRVWIBhRtA/tYBJDEbbHR/6QISWz0sVAAAAAACAB7/8gI +sAiAABwARAAASIBYUBiAmNBIyNjU0JiIGFRSlAQCHh/8Ah7ieWlqeWgIgn/Cfn/D+s3ZfYHV1YF8A +AgBK/zwCRQIgABEAHgAAATIWFRQGIyImLwERIxEzFTc2EzI2NTQmIyIGHQEUFgFUcYCVbiNJEyNWV +igySElcU01JXmECIJd4i5QTEDT+8wLWVTkq/hRuZV55ZWsdX14AAgAe/zwCGQIgABEAHgAAAREjEQ +cGIyImNTQ2MzIWHwE1AzI2PQE0JiMiBhUUFgIZVigyWnGAlW4jSRMjp0leYUFJXFMCEv0qARk5Kpd +4i5QTEDRJ/iJlax1fXm5lXnkAAQBKAAABPgIeAA0AAAEyFxUmBhURIxEzFTc2ARoWDkdXVlYwIwIe +B0EFVlf+0gISU0cYAAEAGP/yAa0CIAAjAAATMhYXIyYjIgYVFBYXHgEVFAYjIiYnMxYzMjY1NCYnL +gE1NDbkV2MJWhNdKy04PF1XbVhWbgxaE2ktOjlEUllkAiBaS2MrJCUoEBlPQkhOVFZoKCUmLhIWSE +BIUwAAAAEAFP/4ARQCiQAXAAATERQXHgE3FQYjIiYnJjURIzUzNTMVMxWxAQMmMx8qMjMEAUdHVmM +BzP7PGw4mFgY/BSwxDjQBNUJ7e0IAAAABAEL/8gICAhIAFwAAAREjNQcGIyImJyY1ETMRFBceATMy +Nj0BAgJWITlRT2EKBVYEBkA1RFECEv3uWj4qTToiOQE+/tIlJC43c4DpAAAAAAEAAQAAAfwCEgAGA +AABAyMDMxsBAfzJaclfop8CEv3uAhL+LQHTAAABAAEAAAMLAhIADAAAAQMjCwEjAzMbATMbAQMLqW +Z2dmapY3t0a3Z7AhL97gG+/kICEv5AAcD+QwG9AAAB//oAAAHWAhIACwAAARMjJwcjEwMzFzczARq +8ZIuKY763ZoWFYwEO/vLV1QEMAQbNzQAAAQAB/y4B+wISABEAAAEDDgEjIic1FjMyNj8BAzMbAQH7 +2iFZQB8NDRIpNhQH02GenQIS/cFVUAJGASozEwIt/i4B0gABABQAAAGxAg4ACQAAJRUhNQEhNSEVA +QGx/mMBNP7iAYL+zkREQgGIREX+ewAAAAABAED/sAEOAwwALAAAASMiBhUUFxYVFAYHHgEVFAcGFR +QWOwEVIyImNTQ3NjU0JzU2NTQnJjU0NjsBAQ4MKiMLDS4pKS4NCyMqDAtERAwLUlILDERECwLUGBk +WTlsgKzUFBTcrIFtOFhkYOC87GFVMIkUIOAhFIkxVGDsvAAAAAAEAYP84AJoDIAADAAAXIxEzmjo6 +yAPoAAEAIf+wAO8DDAAsAAATFQYVFBcWFRQGKwE1MzI2NTQnJjU0NjcuATU0NzY1NCYrATUzMhYVF +AcGFRTvUgsMREQLDCojCw0uKSkuDQsjKgwLREQMCwF6OAhFIkxVGDsvOBgZFk5bICs1BQU3KyBbTh +YZGDgvOxhVTCJFAAABAE0A3wH2AWQAEwAAATMUIyImJyYjIhUjNDMyFhcWMzIBvjhuGywtQR0xOG4 +bLC1BHTEBZIURGCNMhREYIwAAAwAk/94DIgLoAAcAEQApAAAAIBYQBiAmECQgBhUUFiA2NTQlMhYX +IyYjIgYUFjMyNjczDgEjIiY1NDYBAQFE3d3+vN0CB/7wubkBELn+xVBnD1wSWDo+QTcqOQZcEmZWX +HN2Aujg/rbg4AFKpr+Mjb6+jYxbWEldV5ZZNShLVn5na34AAgB4AFIB9AGeAAUACwAAAQcXIyc3Mw +cXIyc3AUqJiUmJifOJiUmJiQGepqampqampqYAAAIAHAHSAQ4CwAAHAA8AABIyFhQGIiY0NiIGFBY +yNjRgakREakSTNCEhNCECwEJqQkJqCiM4IyM4AAAAAAIAUAAAAfQCCwALAA8AAAEzFSMVIzUjNTM1 +MxMhNSEBP7W1OrW1OrX+XAGkAVs4tLQ4sP31OAAAAQB0AkQBAQKyAAMAABMjNzOsOD1QAkRuAAAAA +AEAIADsAKoBdgAHAAASMhYUBiImNEg6KCg6KAF2KDooKDoAAAIAOQBSAbUBngAFAAsAACUHIzcnMw +UHIzcnMwELiUmJiUkBM4lJiYlJ+KampqampqYAAAABADYB5QDhAt8ABAAAEzczByM2Xk1OXQHv8Po +AAQAWAeUAwQLfAAQAABMHIzczwV5NTl0C1fD6AAIANgHlAYsC3wAEAAkAABM3MwcjPwEzByM2Xk1O +XapeTU5dAe/w+grw+gAAAgAWAeUBawLfAAQACQAAEwcjNzMXByM3M8FeTU5dql5NTl0C1fD6CvD6A +AADACX/8gI1AHIABwAPABcAADYyFhQGIiY0NjIWFAYiJjQ2MhYUBiImNEk4JCQ4JOw4JCQ4JOw4JC +Q4JHIkOCQkOCQkOCQkOCQkOCQkOAAAAAEAeABSAUoBngAFAAABBxcjJzcBSomJSYmJAZ6mpqamAAA +AAAEAOQBSAQsBngAFAAAlByM3JzMBC4lJiYlJ+KampgAAAf9qAAABgQKyAAMAACsBATM/VwHAVwKy +AAAAAAIAFAHIAdwClAAHABQAABMVIxUjNSM1BRUjNwcjJxcjNTMXN9pKMkoByDICKzQqATJLKysCl +CmjoykBy46KiY3Lm5sAAQAVAAABvALyABgAAAERIxEjESMRIzUzNTQ3NjMyFxUmBgcGHQEBvFbCVj +k5AxHHHx5iVgcDAg798gHM/jQBzEIOJRuWBUcIJDAVIRYAAAABABX//AHkAvIAJQAAJR4BNxUGIyI +mJyY1ESYjIgcGHQEzFSMRIxEjNTM1NDc2MzIXERQBowIcIxkkKi4CAR4nXgwDbW1WLy8DEbNdOmYa +EQQ/BCQvEjQCFQZWFSEWQv40AcxCDiUblhP9uSEAAAAAAAAWAQ4AAQAAAAAAAAATACgAAQAAAAAAA +QAHAEwAAQAAAAAAAgAHAGQAAQAAAAAAAwAaAKIAAQAAAAAABAAHAM0AAQAAAAAABQA8AU8AAQAAAA +AABgAPAawAAQAAAAAACAALAdQAAQAAAAAACQALAfgAAQAAAAAACwAXAjQAAQAAAAAADAAXAnwAAwA +BBAkAAAAmAAAAAwABBAkAAQAOADwAAwABBAkAAgAOAFQAAwABBAkAAwA0AGwAAwABBAkABAAOAL0A +AwABBAkABQB4ANUAAwABBAkABgAeAYwAAwABBAkACAAWAbwAAwABBAkACQAWAeAAAwABBAkACwAuA +gQAAwABBAkADAAuAkwATgBvACAAUgBpAGcAaAB0AHMAIABSAGUAcwBlAHIAdgBlAGQALgAATm8gUm +lnaHRzIFJlc2VydmVkLgAAQQBpAGwAZQByAG8AbgAAQWlsZXJvbgAAUgBlAGcAdQBsAGEAcgAAUmV +ndWxhcgAAMQAuADEAMAAyADsAVQBLAFcATgA7AEEAaQBsAGUAcgBvAG4ALQBSAGUAZwB1AGwAYQBy +AAAxLjEwMjtVS1dOO0FpbGVyb24tUmVndWxhcgAAQQBpAGwAZQByAG8AbgAAQWlsZXJvbgAAVgBlA +HIAcwBpAG8AbgAgADEALgAxADAAMgA7AFAAUwAgADAAMAAxAC4AMQAwADIAOwBoAG8AdABjAG8Abg +B2ACAAMQAuADAALgA3ADAAOwBtAGEAawBlAG8AdABmAC4AbABpAGIAMgAuADUALgA1ADgAMwAyADk +AAFZlcnNpb24gMS4xMDI7UFMgMDAxLjEwMjtob3Rjb252IDEuMC43MDttYWtlb3RmLmxpYjIuNS41 +ODMyOQAAQQBpAGwAZQByAG8AbgAtAFIAZQBnAHUAbABhAHIAAEFpbGVyb24tUmVndWxhcgAAUwBvA +HIAYQAgAFMAYQBnAGEAbgBvAABTb3JhIFNhZ2FubwAAUwBvAHIAYQAgAFMAYQBnAGEAbgBvAABTb3 +JhIFNhZ2FubwAAaAB0AHQAcAA6AC8ALwB3AHcAdwAuAGQAbwB0AGMAbwBsAG8AbgAuAG4AZQB0AAB +odHRwOi8vd3d3LmRvdGNvbG9uLm5ldAAAaAB0AHQAcAA6AC8ALwB3AHcAdwAuAGQAbwB0AGMAbwBs +AG8AbgAuAG4AZQB0AABodHRwOi8vd3d3LmRvdGNvbG9uLm5ldAAAAAACAAAAAAAA/4MAMgAAAAAAA +AAAAAAAAAAAAAAAAAAAAHQAAAABAAIAAwAEAAUABgAHAAgACQAKAAsADAANAA4ADwAQABEAEgATAB +QAFQAWABcAGAAZABoAGwAcAB0AHgAfACAAIQAiACMAJAAlACYAJwAoACkAKgArACwALQAuAC8AMAA +xADIAMwA0ADUANgA3ADgAOQA6ADsAPAA9AD4APwBAAEEAQgBDAEQARQBGAEcASABJAEoASwBMAE0A +TgBPAFAAUQBSAFMAVABVAFYAVwBYAFkAWgBbAFwAXQBeAF8AYABhAIsAqQCDAJMAjQDDAKoAtgC3A +LQAtQCrAL4AvwC8AIwAwADBAAAAAAAB//8AAgABAAAADAAAABwAAAACAAIAAwBxAAEAcgBzAAIABA +AAAAIAAAABAAAACgBMAGYAAkRGTFQADmxhdG4AGgAEAAAAAP//AAEAAAAWAANDQVQgAB5NT0wgABZ +ST00gABYAAP//AAEAAAAA//8AAgAAAAEAAmxpZ2EADmxvY2wAFAAAAAEAAQAAAAEAAAACAAYAEAAG +AAAAAgASADQABAAAAAEATAADAAAAAgAQABYAAQAcAAAAAQABAE8AAQABAGcAAQABAE8AAwAAAAIAE +AAWAAEAHAAAAAEAAQAvAAEAAQBnAAEAAQAvAAEAGgABAAgAAgAGAAwAcwACAE8AcgACAEwAAQABAE +kAAAABAAAACgBGAGAAAkRGTFQADmxhdG4AHAAEAAAAAP//AAIAAAABABYAA0NBVCAAFk1PTCAAFlJ +PTSAAFgAA//8AAgAAAAEAAmNwc3AADmtlcm4AFAAAAAEAAAAAAAEAAQACAAYADgABAAAAAQASAAIA +AAACAB4ANgABAAoABQAFAAoAAgABACQAPQAAAAEAEgAEAAAAAQAMAAEAOP/nAAEAAQAkAAIGigAEA +AAFJAXKABoAGQAA//gAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAD/sv+4/+z/7v/MAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAD/xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/9T/6AAAAAD/8QAA +ABD/vQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/7gAAAAAAAAAAAAAAAAAA//MAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABIAAAAAAAAAAP/5AAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP/gAAD/4AAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA//L/9AAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAA/+gAAAAAAAkAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP/zAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP/mAAAAAAAAAAAAAAAAAAD +/4gAA//AAAAAA//YAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/+AAAAAAAAP/OAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/zv/qAAAAAP/0AAAACAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP/ZAAD/egAA/1kAAAAA/5D/rgAAAAAAAAAAAA +AAAAAAAAAAAAAAAAD/9AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAD/8AAA/7b/8P+wAAD/8P/E/98AAAAA/8P/+P/0//oAAAAAAAAAAAAA//gA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/+AAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/w//C/9MAAP/SAAD/9wAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAD/yAAA/+kAAAAA//QAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/9wAAAAD//QAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAP/2AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAP/cAAAAAAAAAAAAAAAA/7YAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAP/8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/6AAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAkAFAAEAAAAAQACwAAABcA +BgAAAAAAAAAIAA4AAAAAAAsAEgAAAAAAAAATABkAAwANAAAAAQAJAAAAAAAAAAAAAAAAAAAAGAAAA +AAABwAAAAAAAAAAAAAAFQAFAAAAAAAYABgAAAAUAAAACgAAAAwAAgAPABEAFgAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFAAEAEQBdAAYAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAQAAAAcAAAAAAAAABwAAAAAACAAAAAAAAAAAAAcAAAAHAAAAEwAJ +ABUADgAPAAAACwAQAAAAAAAAAAAAAAAAAAUAGAACAAIAAgAAAAIAGAAXAAAAGAAAABYAFgACABYAA +gAWAAAAEQADAAoAFAAMAA0ABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAASAAAAEgAGAAEAHgAkAC +YAJwApACoALQAuAC8AMgAzADcAOAA5ADoAPAA9AEUASABOAE8AUgBTAFUAVwBZAFoAWwBcAF0AcwA +AAAAAAQAAAADa3tfFAAAAANAan9kAAAAA4QodoQ== +""")), + 10 if size is None else size, + layout_engine=Layout.BASIC, + ) + return load_default_imagefont() diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/ImageGrab.py b/presentation/.venv/lib/python3.12/site-packages/PIL/ImageGrab.py new file mode 100644 index 0000000..66ee6dd --- /dev/null +++ b/presentation/.venv/lib/python3.12/site-packages/PIL/ImageGrab.py @@ -0,0 +1,231 @@ +# +# The Python Imaging Library +# $Id$ +# +# screen grabber +# +# History: +# 2001-04-26 fl created +# 2001-09-17 fl use builtin driver, if present +# 2002-11-19 fl added grabclipboard support +# +# Copyright (c) 2001-2002 by Secret Labs AB +# Copyright (c) 2001-2002 by Fredrik Lundh +# +# See the README file for information on usage and redistribution. +# +from __future__ import annotations + +import io +import os +import shutil +import subprocess +import sys +import tempfile + +from . import Image + +TYPE_CHECKING = False +if TYPE_CHECKING: + from . import ImageWin + + +def grab( + bbox: tuple[int, int, int, int] | None = None, + include_layered_windows: bool = False, + all_screens: bool = False, + xdisplay: str | None = None, + window: int | ImageWin.HWND | None = None, +) -> Image.Image: + im: Image.Image + if xdisplay is None: + if sys.platform == "darwin": + fh, filepath = tempfile.mkstemp(".png") + os.close(fh) + args = ["screencapture"] + if window is not None: + args += ["-l", str(window)] + elif bbox: + left, top, right, bottom = bbox + args += ["-R", f"{left},{top},{right-left},{bottom-top}"] + args += ["-x", filepath] + retcode = subprocess.call(args) + if retcode: + raise subprocess.CalledProcessError(retcode, args) + im = Image.open(filepath) + im.load() + os.unlink(filepath) + if bbox: + if window is not None: + # Determine if the window was in Retina mode or not + # by capturing it without the shadow, + # and checking how different the width is + fh, filepath = tempfile.mkstemp(".png") + os.close(fh) + args = ["screencapture", "-l", str(window), "-o", "-x", filepath] + retcode = subprocess.call(args) + if retcode: + raise subprocess.CalledProcessError(retcode, args) + with Image.open(filepath) as im_no_shadow: + retina = im.width - im_no_shadow.width > 100 + os.unlink(filepath) + + # Since screencapture's -R does not work with -l, + # crop the image manually + if retina: + left, top, right, bottom = bbox + im_cropped = im.resize( + (right - left, bottom - top), + box=tuple(coord * 2 for coord in bbox), + ) + else: + im_cropped = im.crop(bbox) + im.close() + return im_cropped + else: + im_resized = im.resize((right - left, bottom - top)) + im.close() + return im_resized + return im + elif sys.platform == "win32": + if window is not None: + all_screens = -1 + offset, size, data = Image.core.grabscreen_win32( + include_layered_windows, + all_screens, + int(window) if window is not None else 0, + ) + im = Image.frombytes( + "RGB", + size, + data, + # RGB, 32-bit line padding, origin lower left corner + "raw", + "BGR", + (size[0] * 3 + 3) & -4, + -1, + ) + if bbox: + x0, y0 = offset + left, top, right, bottom = bbox + im = im.crop((left - x0, top - y0, right - x0, bottom - y0)) + return im + # Cast to Optional[str] needed for Windows and macOS. + display_name: str | None = xdisplay + try: + if not Image.core.HAVE_XCB: + msg = "Pillow was built without XCB support" + raise OSError(msg) + size, data = Image.core.grabscreen_x11(display_name) + except OSError: + if display_name is None and sys.platform not in ("darwin", "win32"): + if shutil.which("gnome-screenshot"): + args = ["gnome-screenshot", "-f"] + elif shutil.which("grim"): + args = ["grim"] + elif shutil.which("spectacle"): + args = ["spectacle", "-n", "-b", "-f", "-o"] + else: + raise + fh, filepath = tempfile.mkstemp(".png") + os.close(fh) + args.append(filepath) + retcode = subprocess.call(args) + if retcode: + raise subprocess.CalledProcessError(retcode, args) + im = Image.open(filepath) + im.load() + os.unlink(filepath) + if bbox: + im_cropped = im.crop(bbox) + im.close() + return im_cropped + return im + else: + raise + else: + im = Image.frombytes("RGB", size, data, "raw", "BGRX", size[0] * 4, 1) + if bbox: + im = im.crop(bbox) + return im + + +def grabclipboard() -> Image.Image | list[str] | None: + if sys.platform == "darwin": + p = subprocess.run( + ["osascript", "-e", "get the clipboard as «class PNGf»"], + capture_output=True, + ) + if p.returncode != 0: + return None + + import binascii + + data = io.BytesIO(binascii.unhexlify(p.stdout[11:-3])) + return Image.open(data) + elif sys.platform == "win32": + fmt, data = Image.core.grabclipboard_win32() + if fmt == "file": # CF_HDROP + import struct + + o = struct.unpack_from("I", data)[0] + if data[16] == 0: + files = data[o:].decode("mbcs").split("\0") + else: + files = data[o:].decode("utf-16le").split("\0") + return files[: files.index("")] + if isinstance(data, bytes): + data = io.BytesIO(data) + if fmt == "png": + from . import PngImagePlugin + + return PngImagePlugin.PngImageFile(data) + elif fmt == "DIB": + from . import BmpImagePlugin + + return BmpImagePlugin.DibImageFile(data) + return None + else: + if os.getenv("WAYLAND_DISPLAY"): + session_type = "wayland" + elif os.getenv("DISPLAY"): + session_type = "x11" + else: # Session type check failed + session_type = None + + if shutil.which("wl-paste") and session_type in ("wayland", None): + args = ["wl-paste", "-t", "image"] + elif shutil.which("xclip") and session_type in ("x11", None): + args = ["xclip", "-selection", "clipboard", "-t", "image/png", "-o"] + else: + msg = "wl-paste or xclip is required for ImageGrab.grabclipboard() on Linux" + raise NotImplementedError(msg) + + p = subprocess.run(args, capture_output=True) + if p.returncode != 0: + err = p.stderr + for silent_error in [ + # wl-paste, when the clipboard is empty + b"Nothing is copied", + # Ubuntu/Debian wl-paste, when the clipboard is empty + b"No selection", + # Ubuntu/Debian wl-paste, when an image isn't available + b"No suitable type of content copied", + # wl-paste or Ubuntu/Debian xclip, when an image isn't available + b" not available", + # xclip, when an image isn't available + b"cannot convert ", + # xclip, when the clipboard isn't initialized + b"xclip: Error: There is no owner for the ", + ]: + if silent_error in err: + return None + msg = f"{args[0]} error" + if err: + msg += f": {err.strip().decode()}" + raise ChildProcessError(msg) + + data = io.BytesIO(p.stdout) + im = Image.open(data) + im.load() + return im diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/ImageMath.py b/presentation/.venv/lib/python3.12/site-packages/PIL/ImageMath.py new file mode 100644 index 0000000..dfdc50c --- /dev/null +++ b/presentation/.venv/lib/python3.12/site-packages/PIL/ImageMath.py @@ -0,0 +1,314 @@ +# +# The Python Imaging Library +# $Id$ +# +# a simple math add-on for the Python Imaging Library +# +# History: +# 1999-02-15 fl Original PIL Plus release +# 2005-05-05 fl Simplified and cleaned up for PIL 1.1.6 +# 2005-09-12 fl Fixed int() and float() for Python 2.4.1 +# +# Copyright (c) 1999-2005 by Secret Labs AB +# Copyright (c) 2005 by Fredrik Lundh +# +# See the README file for information on usage and redistribution. +# +from __future__ import annotations + +import builtins + +from . import Image, _imagingmath + +TYPE_CHECKING = False +if TYPE_CHECKING: + from collections.abc import Callable + from types import CodeType + from typing import Any + + +class _Operand: + """Wraps an image operand, providing standard operators""" + + def __init__(self, im: Image.Image): + self.im = im + + def __fixup(self, im1: _Operand | float) -> Image.Image: + # convert image to suitable mode + if isinstance(im1, _Operand): + # argument was an image. + if im1.im.mode in ("1", "L"): + return im1.im.convert("I") + elif im1.im.mode in ("I", "F"): + return im1.im + else: + msg = f"unsupported mode: {im1.im.mode}" + raise ValueError(msg) + else: + # argument was a constant + if isinstance(im1, (int, float)) and self.im.mode in ("1", "L", "I"): + return Image.new("I", self.im.size, im1) + else: + return Image.new("F", self.im.size, im1) + + def apply( + self, + op: str, + im1: _Operand | float, + im2: _Operand | float | None = None, + mode: str | None = None, + ) -> _Operand: + im_1 = self.__fixup(im1) + if im2 is None: + # unary operation + out = Image.new(mode or im_1.mode, im_1.size, None) + try: + op = getattr(_imagingmath, f"{op}_{im_1.mode}") + except AttributeError as e: + msg = f"bad operand type for '{op}'" + raise TypeError(msg) from e + _imagingmath.unop(op, out.getim(), im_1.getim()) + else: + # binary operation + im_2 = self.__fixup(im2) + if im_1.mode != im_2.mode: + # convert both arguments to floating point + if im_1.mode != "F": + im_1 = im_1.convert("F") + if im_2.mode != "F": + im_2 = im_2.convert("F") + if im_1.size != im_2.size: + # crop both arguments to a common size + size = ( + min(im_1.size[0], im_2.size[0]), + min(im_1.size[1], im_2.size[1]), + ) + if im_1.size != size: + im_1 = im_1.crop((0, 0) + size) + if im_2.size != size: + im_2 = im_2.crop((0, 0) + size) + out = Image.new(mode or im_1.mode, im_1.size, None) + try: + op = getattr(_imagingmath, f"{op}_{im_1.mode}") + except AttributeError as e: + msg = f"bad operand type for '{op}'" + raise TypeError(msg) from e + _imagingmath.binop(op, out.getim(), im_1.getim(), im_2.getim()) + return _Operand(out) + + # unary operators + def __bool__(self) -> bool: + # an image is "true" if it contains at least one non-zero pixel + return self.im.getbbox() is not None + + def __abs__(self) -> _Operand: + return self.apply("abs", self) + + def __pos__(self) -> _Operand: + return self + + def __neg__(self) -> _Operand: + return self.apply("neg", self) + + # binary operators + def __add__(self, other: _Operand | float) -> _Operand: + return self.apply("add", self, other) + + def __radd__(self, other: _Operand | float) -> _Operand: + return self.apply("add", other, self) + + def __sub__(self, other: _Operand | float) -> _Operand: + return self.apply("sub", self, other) + + def __rsub__(self, other: _Operand | float) -> _Operand: + return self.apply("sub", other, self) + + def __mul__(self, other: _Operand | float) -> _Operand: + return self.apply("mul", self, other) + + def __rmul__(self, other: _Operand | float) -> _Operand: + return self.apply("mul", other, self) + + def __truediv__(self, other: _Operand | float) -> _Operand: + return self.apply("div", self, other) + + def __rtruediv__(self, other: _Operand | float) -> _Operand: + return self.apply("div", other, self) + + def __mod__(self, other: _Operand | float) -> _Operand: + return self.apply("mod", self, other) + + def __rmod__(self, other: _Operand | float) -> _Operand: + return self.apply("mod", other, self) + + def __pow__(self, other: _Operand | float) -> _Operand: + return self.apply("pow", self, other) + + def __rpow__(self, other: _Operand | float) -> _Operand: + return self.apply("pow", other, self) + + # bitwise + def __invert__(self) -> _Operand: + return self.apply("invert", self) + + def __and__(self, other: _Operand | float) -> _Operand: + return self.apply("and", self, other) + + def __rand__(self, other: _Operand | float) -> _Operand: + return self.apply("and", other, self) + + def __or__(self, other: _Operand | float) -> _Operand: + return self.apply("or", self, other) + + def __ror__(self, other: _Operand | float) -> _Operand: + return self.apply("or", other, self) + + def __xor__(self, other: _Operand | float) -> _Operand: + return self.apply("xor", self, other) + + def __rxor__(self, other: _Operand | float) -> _Operand: + return self.apply("xor", other, self) + + def __lshift__(self, other: _Operand | float) -> _Operand: + return self.apply("lshift", self, other) + + def __rshift__(self, other: _Operand | float) -> _Operand: + return self.apply("rshift", self, other) + + # logical + def __eq__(self, other: _Operand | float) -> _Operand: # type: ignore[override] + return self.apply("eq", self, other) + + def __ne__(self, other: _Operand | float) -> _Operand: # type: ignore[override] + return self.apply("ne", self, other) + + def __lt__(self, other: _Operand | float) -> _Operand: + return self.apply("lt", self, other) + + def __le__(self, other: _Operand | float) -> _Operand: + return self.apply("le", self, other) + + def __gt__(self, other: _Operand | float) -> _Operand: + return self.apply("gt", self, other) + + def __ge__(self, other: _Operand | float) -> _Operand: + return self.apply("ge", self, other) + + +# conversions +def imagemath_int(self: _Operand) -> _Operand: + return _Operand(self.im.convert("I")) + + +def imagemath_float(self: _Operand) -> _Operand: + return _Operand(self.im.convert("F")) + + +# logical +def imagemath_equal(self: _Operand, other: _Operand | float | None) -> _Operand: + return self.apply("eq", self, other, mode="I") + + +def imagemath_notequal(self: _Operand, other: _Operand | float | None) -> _Operand: + return self.apply("ne", self, other, mode="I") + + +def imagemath_min(self: _Operand, other: _Operand | float | None) -> _Operand: + return self.apply("min", self, other) + + +def imagemath_max(self: _Operand, other: _Operand | float | None) -> _Operand: + return self.apply("max", self, other) + + +def imagemath_convert(self: _Operand, mode: str) -> _Operand: + return _Operand(self.im.convert(mode)) + + +ops = { + "int": imagemath_int, + "float": imagemath_float, + "equal": imagemath_equal, + "notequal": imagemath_notequal, + "min": imagemath_min, + "max": imagemath_max, + "convert": imagemath_convert, +} + + +def lambda_eval(expression: Callable[[dict[str, Any]], Any], **kw: Any) -> Any: + """ + Returns the result of an image function. + + :py:mod:`~PIL.ImageMath` only supports single-layer images. To process multi-band + images, use the :py:meth:`~PIL.Image.Image.split` method or + :py:func:`~PIL.Image.merge` function. + + :param expression: A function that receives a dictionary. + :param **kw: Values to add to the function's dictionary. + :return: The expression result. This is usually an image object, but can + also be an integer, a floating point value, or a pixel tuple, + depending on the expression. + """ + + args: dict[str, Any] = ops.copy() + args.update(kw) + for k, v in args.items(): + if isinstance(v, Image.Image): + args[k] = _Operand(v) + + out = expression(args) + try: + return out.im + except AttributeError: + return out + + +def unsafe_eval(expression: str, **kw: Any) -> Any: + """ + Evaluates an image expression. This uses Python's ``eval()`` function to process + the expression string, and carries the security risks of doing so. It is not + recommended to process expressions without considering this. + :py:meth:`~lambda_eval` is a more secure alternative. + + :py:mod:`~PIL.ImageMath` only supports single-layer images. To process multi-band + images, use the :py:meth:`~PIL.Image.Image.split` method or + :py:func:`~PIL.Image.merge` function. + + :param expression: A string containing a Python-style expression. + :param **kw: Values to add to the evaluation context. + :return: The evaluated expression. This is usually an image object, but can + also be an integer, a floating point value, or a pixel tuple, + depending on the expression. + """ + + # build execution namespace + args: dict[str, Any] = ops.copy() + for k in kw: + if "__" in k or hasattr(builtins, k): + msg = f"'{k}' not allowed" + raise ValueError(msg) + + args.update(kw) + for k, v in args.items(): + if isinstance(v, Image.Image): + args[k] = _Operand(v) + + compiled_code = compile(expression, "<string>", "eval") + + def scan(code: CodeType) -> None: + for const in code.co_consts: + if type(const) is type(compiled_code): + scan(const) + + for name in code.co_names: + if name not in args and name != "abs": + msg = f"'{name}' not allowed" + raise ValueError(msg) + + scan(compiled_code) + out = builtins.eval(expression, {"__builtins": {"abs": abs}}, args) + try: + return out.im + except AttributeError: + return out diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/ImageMode.py b/presentation/.venv/lib/python3.12/site-packages/PIL/ImageMode.py new file mode 100644 index 0000000..b7c6c86 --- /dev/null +++ b/presentation/.venv/lib/python3.12/site-packages/PIL/ImageMode.py @@ -0,0 +1,85 @@ +# +# The Python Imaging Library. +# $Id$ +# +# standard mode descriptors +# +# History: +# 2006-03-20 fl Added +# +# Copyright (c) 2006 by Secret Labs AB. +# Copyright (c) 2006 by Fredrik Lundh. +# +# See the README file for information on usage and redistribution. +# +from __future__ import annotations + +import sys +from functools import lru_cache +from typing import NamedTuple + + +class ModeDescriptor(NamedTuple): + """Wrapper for mode strings.""" + + mode: str + bands: tuple[str, ...] + basemode: str + basetype: str + typestr: str + + def __str__(self) -> str: + return self.mode + + +@lru_cache +def getmode(mode: str) -> ModeDescriptor: + """Gets a mode descriptor for the given mode.""" + endian = "<" if sys.byteorder == "little" else ">" + + modes = { + # core modes + # Bits need to be extended to bytes + "1": ("L", "L", ("1",), "|b1"), + "L": ("L", "L", ("L",), "|u1"), + "I": ("L", "I", ("I",), f"{endian}i4"), + "F": ("L", "F", ("F",), f"{endian}f4"), + "P": ("P", "L", ("P",), "|u1"), + "RGB": ("RGB", "L", ("R", "G", "B"), "|u1"), + "RGBX": ("RGB", "L", ("R", "G", "B", "X"), "|u1"), + "RGBA": ("RGB", "L", ("R", "G", "B", "A"), "|u1"), + "CMYK": ("RGB", "L", ("C", "M", "Y", "K"), "|u1"), + "YCbCr": ("RGB", "L", ("Y", "Cb", "Cr"), "|u1"), + # UNDONE - unsigned |u1i1i1 + "LAB": ("RGB", "L", ("L", "A", "B"), "|u1"), + "HSV": ("RGB", "L", ("H", "S", "V"), "|u1"), + # extra experimental modes + "RGBa": ("RGB", "L", ("R", "G", "B", "a"), "|u1"), + "LA": ("L", "L", ("L", "A"), "|u1"), + "La": ("L", "L", ("L", "a"), "|u1"), + "PA": ("RGB", "L", ("P", "A"), "|u1"), + } + if mode in modes: + base_mode, base_type, bands, type_str = modes[mode] + return ModeDescriptor(mode, bands, base_mode, base_type, type_str) + + mapping_modes = { + # I;16 == I;16L, and I;32 == I;32L + "I;16": "<u2", + "I;16S": "<i2", + "I;16L": "<u2", + "I;16LS": "<i2", + "I;16B": ">u2", + "I;16BS": ">i2", + "I;16N": f"{endian}u2", + "I;16NS": f"{endian}i2", + "I;32": "<u4", + "I;32B": ">u4", + "I;32L": "<u4", + "I;32S": "<i4", + "I;32BS": ">i4", + "I;32LS": "<i4", + } + + type_str = mapping_modes[mode] + return ModeDescriptor(mode, ("I",), "L", "L", type_str) diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/ImageMorph.py b/presentation/.venv/lib/python3.12/site-packages/PIL/ImageMorph.py new file mode 100644 index 0000000..9fcd8d7 --- /dev/null +++ b/presentation/.venv/lib/python3.12/site-packages/PIL/ImageMorph.py @@ -0,0 +1,317 @@ +# A binary morphology add-on for the Python Imaging Library +# +# History: +# 2014-06-04 Initial version. +# +# Copyright (c) 2014 Dov Grobgeld <dov.grobgeld@gmail.com> +from __future__ import annotations + +import re + +from . import Image, _imagingmorph + +LUT_SIZE = 1 << 9 + +# fmt: off +ROTATION_MATRIX = [ + 6, 3, 0, + 7, 4, 1, + 8, 5, 2, +] +MIRROR_MATRIX = [ + 2, 1, 0, + 5, 4, 3, + 8, 7, 6, +] +# fmt: on + + +class LutBuilder: + """A class for building a MorphLut from a descriptive language + + The input patterns is a list of a strings sequences like these:: + + 4:(... + .1. + 111)->1 + + (whitespaces including linebreaks are ignored). The option 4 + describes a series of symmetry operations (in this case a + 4-rotation), the pattern is described by: + + - . or X - Ignore + - 1 - Pixel is on + - 0 - Pixel is off + + The result of the operation is described after "->" string. + + The default is to return the current pixel value, which is + returned if no other match is found. + + Operations: + + - 4 - 4 way rotation + - N - Negate + - 1 - Dummy op for no other operation (an op must always be given) + - M - Mirroring + + Example:: + + lb = LutBuilder(patterns = ["4:(... .1. 111)->1"]) + lut = lb.build_lut() + + """ + + def __init__( + self, patterns: list[str] | None = None, op_name: str | None = None + ) -> None: + """ + :param patterns: A list of input patterns, or None. + :param op_name: The name of a known pattern. One of "corner", "dilation4", + "dilation8", "erosion4", "erosion8" or "edge". + :exception Exception: If the op_name is not recognized. + """ + self.lut: bytearray | None = None + if op_name is not None: + known_patterns = { + "corner": ["1:(... ... ...)->0", "4:(00. 01. ...)->1"], + "dilation4": ["4:(... .0. .1.)->1"], + "dilation8": ["4:(... .0. .1.)->1", "4:(... .0. ..1)->1"], + "erosion4": ["4:(... .1. .0.)->0"], + "erosion8": ["4:(... .1. .0.)->0", "4:(... .1. ..0)->0"], + "edge": [ + "1:(... ... ...)->0", + "4:(.0. .1. ...)->1", + "4:(01. .1. ...)->1", + ], + } + if op_name not in known_patterns: + msg = f"Unknown pattern {op_name}!" + raise Exception(msg) + + self.patterns = known_patterns[op_name] + elif patterns is not None: + self.patterns = patterns + else: + self.patterns = [] + + def add_patterns(self, patterns: list[str]) -> None: + """ + Append to list of patterns. + + :param patterns: Additional patterns. + """ + self.patterns += patterns + + def build_default_lut(self) -> bytearray: + """ + Set the current LUT, and return it. + + This is the default LUT that patterns will be applied against when building. + """ + symbols = [0, 1] + m = 1 << 4 # pos of current pixel + self.lut = bytearray(symbols[(i & m) > 0] for i in range(LUT_SIZE)) + return self.lut + + def get_lut(self) -> bytearray | None: + """ + Returns the current LUT + """ + return self.lut + + def _string_permute(self, pattern: str, permutation: list[int]) -> str: + """Takes a pattern and a permutation and returns the + string permuted according to the permutation list. + """ + assert len(permutation) == 9 + return "".join(pattern[p] for p in permutation) + + def _pattern_permute( + self, basic_pattern: str, options: str, basic_result: int + ) -> list[tuple[str, int]]: + """Takes a basic pattern and its result and clones + the pattern according to the modifications described in the $options + parameter. It returns a list of all cloned patterns.""" + patterns = [(basic_pattern, basic_result)] + + # rotations + if "4" in options: + res = patterns[-1][1] + for i in range(4): + patterns.append( + (self._string_permute(patterns[-1][0], ROTATION_MATRIX), res) + ) + # mirror + if "M" in options: + n = len(patterns) + for pattern, res in patterns[:n]: + patterns.append((self._string_permute(pattern, MIRROR_MATRIX), res)) + + # negate + if "N" in options: + n = len(patterns) + for pattern, res in patterns[:n]: + # Swap 0 and 1 + pattern = pattern.replace("0", "Z").replace("1", "0").replace("Z", "1") + res = 1 - int(res) + patterns.append((pattern, res)) + + return patterns + + def build_lut(self) -> bytearray: + """Compile all patterns into a morphology LUT, and return it. + + This is the data to be passed into MorphOp.""" + self.build_default_lut() + assert self.lut is not None + patterns = [] + + # Parse and create symmetries of the patterns strings + for p in self.patterns: + m = re.search(r"(\w):?\s*\((.+?)\)\s*->\s*(\d)", p.replace("\n", "")) + if not m: + msg = 'Syntax error in pattern "' + p + '"' + raise Exception(msg) + options = m.group(1) + pattern = m.group(2) + result = int(m.group(3)) + + # Get rid of spaces + pattern = pattern.replace(" ", "").replace("\n", "") + + patterns += self._pattern_permute(pattern, options, result) + + # Compile the patterns into regular expressions for speed + compiled_patterns = [] + for pattern in patterns: + p = pattern[0].replace(".", "X").replace("X", "[01]") + compiled_patterns.append((re.compile(p), pattern[1])) + + # Step through table and find patterns that match. + # Note that all the patterns are searched. The last one found takes priority + for i in range(LUT_SIZE): + # Build the bit pattern + bitpattern = bin(i)[2:] + bitpattern = ("0" * (9 - len(bitpattern)) + bitpattern)[::-1] + + for pattern, r in compiled_patterns: + if pattern.match(bitpattern): + self.lut[i] = [0, 1][r] + + return self.lut + + +class MorphOp: + """A class for binary morphological operators""" + + def __init__( + self, + lut: bytearray | None = None, + op_name: str | None = None, + patterns: list[str] | None = None, + ) -> None: + """Create a binary morphological operator. + + If the LUT is not provided, then it is built using LutBuilder from the op_name + or the patterns. + + :param lut: The LUT data. + :param patterns: A list of input patterns, or None. + :param op_name: The name of a known pattern. One of "corner", "dilation4", + "dilation8", "erosion4", "erosion8", "edge". + :exception Exception: If the op_name is not recognized. + """ + if patterns is None and op_name is None: + self.lut = lut + else: + self.lut = LutBuilder(patterns, op_name).build_lut() + + def apply(self, image: Image.Image) -> tuple[int, Image.Image]: + """Run a single morphological operation on an image. + + Returns a tuple of the number of changed pixels and the + morphed image. + + :param image: A 1-mode or L-mode image. + :exception Exception: If the current operator is None. + :exception ValueError: If the image is not 1 or L mode.""" + if self.lut is None: + msg = "No operator loaded" + raise Exception(msg) + + if image.mode not in ("1", "L"): + msg = "Image mode must be 1 or L" + raise ValueError(msg) + outimage = Image.new(image.mode, image.size) + count = _imagingmorph.apply(bytes(self.lut), image.getim(), outimage.getim()) + return count, outimage + + def match(self, image: Image.Image) -> list[tuple[int, int]]: + """Get a list of coordinates matching the morphological operation on + an image. + + Returns a list of tuples of (x,y) coordinates of all matching pixels. See + :ref:`coordinate-system`. + + :param image: A 1-mode or L-mode image. + :exception Exception: If the current operator is None. + :exception ValueError: If the image is not 1 or L mode.""" + if self.lut is None: + msg = "No operator loaded" + raise Exception(msg) + + if image.mode not in ("1", "L"): + msg = "Image mode must be 1 or L" + raise ValueError(msg) + return _imagingmorph.match(bytes(self.lut), image.getim()) + + def get_on_pixels(self, image: Image.Image) -> list[tuple[int, int]]: + """Get a list of all turned on pixels in a 1 or L mode image. + + Returns a list of tuples of (x,y) coordinates of all non-empty pixels. See + :ref:`coordinate-system`. + + :param image: A 1-mode or L-mode image. + :exception ValueError: If the image is not 1 or L mode.""" + + if image.mode not in ("1", "L"): + msg = "Image mode must be 1 or L" + raise ValueError(msg) + return _imagingmorph.get_on_pixels(image.getim()) + + def load_lut(self, filename: str) -> None: + """ + Load an operator from an mrl file + + :param filename: The file to read from. + :exception Exception: If the length of the file data is not 512. + """ + with open(filename, "rb") as f: + self.lut = bytearray(f.read()) + + if len(self.lut) != LUT_SIZE: + self.lut = None + msg = "Wrong size operator file!" + raise Exception(msg) + + def save_lut(self, filename: str) -> None: + """ + Save an operator to an mrl file. + + :param filename: The destination file. + :exception Exception: If the current operator is None. + """ + if self.lut is None: + msg = "No operator loaded" + raise Exception(msg) + with open(filename, "wb") as f: + f.write(self.lut) + + def set_lut(self, lut: bytearray | None) -> None: + """ + Set the LUT from an external source + + :param lut: A new LUT. + """ + self.lut = lut diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/ImageOps.py b/presentation/.venv/lib/python3.12/site-packages/PIL/ImageOps.py new file mode 100644 index 0000000..42b10bd --- /dev/null +++ b/presentation/.venv/lib/python3.12/site-packages/PIL/ImageOps.py @@ -0,0 +1,746 @@ +# +# The Python Imaging Library. +# $Id$ +# +# standard image operations +# +# History: +# 2001-10-20 fl Created +# 2001-10-23 fl Added autocontrast operator +# 2001-12-18 fl Added Kevin's fit operator +# 2004-03-14 fl Fixed potential division by zero in equalize +# 2005-05-05 fl Fixed equalize for low number of values +# +# Copyright (c) 2001-2004 by Secret Labs AB +# Copyright (c) 2001-2004 by Fredrik Lundh +# +# See the README file for information on usage and redistribution. +# +from __future__ import annotations + +import functools +import operator +import re +from collections.abc import Sequence +from typing import Literal, Protocol, cast, overload + +from . import ExifTags, Image, ImagePalette + +# +# helpers + + +def _border(border: int | tuple[int, ...]) -> tuple[int, int, int, int]: + if isinstance(border, tuple): + if len(border) == 2: + left, top = right, bottom = border + elif len(border) == 4: + left, top, right, bottom = border + else: + left = top = right = bottom = border + return left, top, right, bottom + + +def _color(color: str | int | tuple[int, ...], mode: str) -> int | tuple[int, ...]: + if isinstance(color, str): + from . import ImageColor + + color = ImageColor.getcolor(color, mode) + return color + + +def _lut(image: Image.Image, lut: list[int]) -> Image.Image: + if image.mode == "P": + # FIXME: apply to lookup table, not image data + msg = "mode P support coming soon" + raise NotImplementedError(msg) + elif image.mode in ("L", "RGB"): + if image.mode == "RGB" and len(lut) == 256: + lut = lut + lut + lut + return image.point(lut) + else: + msg = f"not supported for mode {image.mode}" + raise OSError(msg) + + +# +# actions + + +def autocontrast( + image: Image.Image, + cutoff: float | tuple[float, float] = 0, + ignore: int | Sequence[int] | None = None, + mask: Image.Image | None = None, + preserve_tone: bool = False, +) -> Image.Image: + """ + Maximize (normalize) image contrast. This function calculates a + histogram of the input image (or mask region), removes ``cutoff`` percent of the + lightest and darkest pixels from the histogram, and remaps the image + so that the darkest pixel becomes black (0), and the lightest + becomes white (255). + + :param image: The image to process. + :param cutoff: The percent to cut off from the histogram on the low and + high ends. Either a tuple of (low, high), or a single + number for both. + :param ignore: The background pixel value (use None for no background). + :param mask: Histogram used in contrast operation is computed using pixels + within the mask. If no mask is given the entire image is used + for histogram computation. + :param preserve_tone: Preserve image tone in Photoshop-like style autocontrast. + + .. versionadded:: 8.2.0 + + :return: An image. + """ + if preserve_tone: + histogram = image.convert("L").histogram(mask) + else: + histogram = image.histogram(mask) + + lut = [] + for layer in range(0, len(histogram), 256): + h = histogram[layer : layer + 256] + if ignore is not None: + # get rid of outliers + if isinstance(ignore, int): + h[ignore] = 0 + else: + for ix in ignore: + h[ix] = 0 + if cutoff: + # cut off pixels from both ends of the histogram + if not isinstance(cutoff, tuple): + cutoff = (cutoff, cutoff) + # get number of pixels + n = 0 + for ix in range(256): + n = n + h[ix] + # remove cutoff% pixels from the low end + cut = int(n * cutoff[0] // 100) + for lo in range(256): + if cut > h[lo]: + cut = cut - h[lo] + h[lo] = 0 + else: + h[lo] -= cut + cut = 0 + if cut <= 0: + break + # remove cutoff% samples from the high end + cut = int(n * cutoff[1] // 100) + for hi in range(255, -1, -1): + if cut > h[hi]: + cut = cut - h[hi] + h[hi] = 0 + else: + h[hi] -= cut + cut = 0 + if cut <= 0: + break + # find lowest/highest samples after preprocessing + for lo in range(256): + if h[lo]: + break + for hi in range(255, -1, -1): + if h[hi]: + break + if hi <= lo: + # don't bother + lut.extend(list(range(256))) + else: + scale = 255.0 / (hi - lo) + offset = -lo * scale + for ix in range(256): + ix = int(ix * scale + offset) + if ix < 0: + ix = 0 + elif ix > 255: + ix = 255 + lut.append(ix) + return _lut(image, lut) + + +def colorize( + image: Image.Image, + black: str | tuple[int, ...], + white: str | tuple[int, ...], + mid: str | int | tuple[int, ...] | None = None, + blackpoint: int = 0, + whitepoint: int = 255, + midpoint: int = 127, +) -> Image.Image: + """ + Colorize grayscale image. + This function calculates a color wedge which maps all black pixels in + the source image to the first color and all white pixels to the + second color. If ``mid`` is specified, it uses three-color mapping. + The ``black`` and ``white`` arguments should be RGB tuples or color names; + optionally you can use three-color mapping by also specifying ``mid``. + Mapping positions for any of the colors can be specified + (e.g. ``blackpoint``), where these parameters are the integer + value corresponding to where the corresponding color should be mapped. + These parameters must have logical order, such that + ``blackpoint <= midpoint <= whitepoint`` (if ``mid`` is specified). + + :param image: The image to colorize. + :param black: The color to use for black input pixels. + :param white: The color to use for white input pixels. + :param mid: The color to use for midtone input pixels. + :param blackpoint: an int value [0, 255] for the black mapping. + :param whitepoint: an int value [0, 255] for the white mapping. + :param midpoint: an int value [0, 255] for the midtone mapping. + :return: An image. + """ + + # Initial asserts + assert image.mode == "L" + if mid is None: + assert 0 <= blackpoint <= whitepoint <= 255 + else: + assert 0 <= blackpoint <= midpoint <= whitepoint <= 255 + + # Define colors from arguments + rgb_black = cast(Sequence[int], _color(black, "RGB")) + rgb_white = cast(Sequence[int], _color(white, "RGB")) + rgb_mid = cast(Sequence[int], _color(mid, "RGB")) if mid is not None else None + + # Empty lists for the mapping + red = [] + green = [] + blue = [] + + # Create the low-end values + for i in range(blackpoint): + red.append(rgb_black[0]) + green.append(rgb_black[1]) + blue.append(rgb_black[2]) + + # Create the mapping (2-color) + if rgb_mid is None: + range_map = range(whitepoint - blackpoint) + + for i in range_map: + red.append( + rgb_black[0] + i * (rgb_white[0] - rgb_black[0]) // len(range_map) + ) + green.append( + rgb_black[1] + i * (rgb_white[1] - rgb_black[1]) // len(range_map) + ) + blue.append( + rgb_black[2] + i * (rgb_white[2] - rgb_black[2]) // len(range_map) + ) + + # Create the mapping (3-color) + else: + range_map1 = range(midpoint - blackpoint) + range_map2 = range(whitepoint - midpoint) + + for i in range_map1: + red.append( + rgb_black[0] + i * (rgb_mid[0] - rgb_black[0]) // len(range_map1) + ) + green.append( + rgb_black[1] + i * (rgb_mid[1] - rgb_black[1]) // len(range_map1) + ) + blue.append( + rgb_black[2] + i * (rgb_mid[2] - rgb_black[2]) // len(range_map1) + ) + for i in range_map2: + red.append(rgb_mid[0] + i * (rgb_white[0] - rgb_mid[0]) // len(range_map2)) + green.append( + rgb_mid[1] + i * (rgb_white[1] - rgb_mid[1]) // len(range_map2) + ) + blue.append(rgb_mid[2] + i * (rgb_white[2] - rgb_mid[2]) // len(range_map2)) + + # Create the high-end values + for i in range(256 - whitepoint): + red.append(rgb_white[0]) + green.append(rgb_white[1]) + blue.append(rgb_white[2]) + + # Return converted image + image = image.convert("RGB") + return _lut(image, red + green + blue) + + +def contain( + image: Image.Image, size: tuple[int, int], method: int = Image.Resampling.BICUBIC +) -> Image.Image: + """ + Returns a resized version of the image, set to the maximum width and height + within the requested size, while maintaining the original aspect ratio. + + :param image: The image to resize. + :param size: The requested output size in pixels, given as a + (width, height) tuple. + :param method: Resampling method to use. Default is + :py:attr:`~PIL.Image.Resampling.BICUBIC`. + See :ref:`concept-filters`. + :return: An image. + """ + + im_ratio = image.width / image.height + dest_ratio = size[0] / size[1] + + if im_ratio != dest_ratio: + if im_ratio > dest_ratio: + new_height = round(image.height / image.width * size[0]) + if new_height != size[1]: + size = (size[0], new_height) + else: + new_width = round(image.width / image.height * size[1]) + if new_width != size[0]: + size = (new_width, size[1]) + return image.resize(size, resample=method) + + +def cover( + image: Image.Image, size: tuple[int, int], method: int = Image.Resampling.BICUBIC +) -> Image.Image: + """ + Returns a resized version of the image, so that the requested size is + covered, while maintaining the original aspect ratio. + + :param image: The image to resize. + :param size: The requested output size in pixels, given as a + (width, height) tuple. + :param method: Resampling method to use. Default is + :py:attr:`~PIL.Image.Resampling.BICUBIC`. + See :ref:`concept-filters`. + :return: An image. + """ + + im_ratio = image.width / image.height + dest_ratio = size[0] / size[1] + + if im_ratio != dest_ratio: + if im_ratio < dest_ratio: + new_height = round(image.height / image.width * size[0]) + if new_height != size[1]: + size = (size[0], new_height) + else: + new_width = round(image.width / image.height * size[1]) + if new_width != size[0]: + size = (new_width, size[1]) + return image.resize(size, resample=method) + + +def pad( + image: Image.Image, + size: tuple[int, int], + method: int = Image.Resampling.BICUBIC, + color: str | int | tuple[int, ...] | None = None, + centering: tuple[float, float] = (0.5, 0.5), +) -> Image.Image: + """ + Returns a resized and padded version of the image, expanded to fill the + requested aspect ratio and size. + + :param image: The image to resize and crop. + :param size: The requested output size in pixels, given as a + (width, height) tuple. + :param method: Resampling method to use. Default is + :py:attr:`~PIL.Image.Resampling.BICUBIC`. + See :ref:`concept-filters`. + :param color: The background color of the padded image. + :param centering: Control the position of the original image within the + padded version. + + (0.5, 0.5) will keep the image centered + (0, 0) will keep the image aligned to the top left + (1, 1) will keep the image aligned to the bottom + right + :return: An image. + """ + + resized = contain(image, size, method) + if resized.size == size: + out = resized + else: + out = Image.new(image.mode, size, color) + if resized.palette: + palette = resized.getpalette() + if palette is not None: + out.putpalette(palette) + if resized.width != size[0]: + x = round((size[0] - resized.width) * max(0, min(centering[0], 1))) + out.paste(resized, (x, 0)) + else: + y = round((size[1] - resized.height) * max(0, min(centering[1], 1))) + out.paste(resized, (0, y)) + return out + + +def crop(image: Image.Image, border: int = 0) -> Image.Image: + """ + Remove border from image. The same amount of pixels are removed + from all four sides. This function works on all image modes. + + .. seealso:: :py:meth:`~PIL.Image.Image.crop` + + :param image: The image to crop. + :param border: The number of pixels to remove. + :return: An image. + """ + left, top, right, bottom = _border(border) + return image.crop((left, top, image.size[0] - right, image.size[1] - bottom)) + + +def scale( + image: Image.Image, factor: float, resample: int = Image.Resampling.BICUBIC +) -> Image.Image: + """ + Returns a rescaled image by a specific factor given in parameter. + A factor greater than 1 expands the image, between 0 and 1 contracts the + image. + + :param image: The image to rescale. + :param factor: The expansion factor, as a float. + :param resample: Resampling method to use. Default is + :py:attr:`~PIL.Image.Resampling.BICUBIC`. + See :ref:`concept-filters`. + :returns: An :py:class:`~PIL.Image.Image` object. + """ + if factor == 1: + return image.copy() + elif factor <= 0: + msg = "the factor must be greater than 0" + raise ValueError(msg) + else: + size = (round(factor * image.width), round(factor * image.height)) + return image.resize(size, resample) + + +class SupportsGetMesh(Protocol): + """ + An object that supports the ``getmesh`` method, taking an image as an + argument, and returning a list of tuples. Each tuple contains two tuples, + the source box as a tuple of 4 integers, and a tuple of 8 integers for the + final quadrilateral, in order of top left, bottom left, bottom right, top + right. + """ + + def getmesh( + self, image: Image.Image + ) -> list[ + tuple[tuple[int, int, int, int], tuple[int, int, int, int, int, int, int, int]] + ]: ... + + +def deform( + image: Image.Image, + deformer: SupportsGetMesh, + resample: int = Image.Resampling.BILINEAR, +) -> Image.Image: + """ + Deform the image. + + :param image: The image to deform. + :param deformer: A deformer object. Any object that implements a + ``getmesh`` method can be used. + :param resample: An optional resampling filter. Same values possible as + in the PIL.Image.transform function. + :return: An image. + """ + return image.transform( + image.size, Image.Transform.MESH, deformer.getmesh(image), resample + ) + + +def equalize(image: Image.Image, mask: Image.Image | None = None) -> Image.Image: + """ + Equalize the image histogram. This function applies a non-linear + mapping to the input image, in order to create a uniform + distribution of grayscale values in the output image. + + :param image: The image to equalize. + :param mask: An optional mask. If given, only the pixels selected by + the mask are included in the analysis. + :return: An image. + """ + if image.mode == "P": + image = image.convert("RGB") + h = image.histogram(mask) + lut = [] + for b in range(0, len(h), 256): + histo = [_f for _f in h[b : b + 256] if _f] + if len(histo) <= 1: + lut.extend(list(range(256))) + else: + step = (functools.reduce(operator.add, histo) - histo[-1]) // 255 + if not step: + lut.extend(list(range(256))) + else: + n = step // 2 + for i in range(256): + lut.append(n // step) + n = n + h[i + b] + return _lut(image, lut) + + +def expand( + image: Image.Image, + border: int | tuple[int, ...] = 0, + fill: str | int | tuple[int, ...] = 0, +) -> Image.Image: + """ + Add border to the image + + :param image: The image to expand. + :param border: Border width, in pixels. + :param fill: Pixel fill value (a color value). Default is 0 (black). + :return: An image. + """ + left, top, right, bottom = _border(border) + width = left + image.size[0] + right + height = top + image.size[1] + bottom + color = _color(fill, image.mode) + if image.palette: + mode = image.palette.mode + palette = ImagePalette.ImagePalette(mode, image.getpalette(mode)) + if isinstance(color, tuple) and (len(color) == 3 or len(color) == 4): + color = palette.getcolor(color) + else: + palette = None + out = Image.new(image.mode, (width, height), color) + if palette: + out.putpalette(palette.palette, mode) + out.paste(image, (left, top)) + return out + + +def fit( + image: Image.Image, + size: tuple[int, int], + method: int = Image.Resampling.BICUBIC, + bleed: float = 0.0, + centering: tuple[float, float] = (0.5, 0.5), +) -> Image.Image: + """ + Returns a resized and cropped version of the image, cropped to the + requested aspect ratio and size. + + This function was contributed by Kevin Cazabon. + + :param image: The image to resize and crop. + :param size: The requested output size in pixels, given as a + (width, height) tuple. + :param method: Resampling method to use. Default is + :py:attr:`~PIL.Image.Resampling.BICUBIC`. + See :ref:`concept-filters`. + :param bleed: Remove a border around the outside of the image from all + four edges. The value is a decimal percentage (use 0.01 for + one percent). The default value is 0 (no border). + Cannot be greater than or equal to 0.5. + :param centering: Control the cropping position. Use (0.5, 0.5) for + center cropping (e.g. if cropping the width, take 50% off + of the left side, and therefore 50% off the right side). + (0.0, 0.0) will crop from the top left corner (i.e. if + cropping the width, take all of the crop off of the right + side, and if cropping the height, take all of it off the + bottom). (1.0, 0.0) will crop from the bottom left + corner, etc. (i.e. if cropping the width, take all of the + crop off the left side, and if cropping the height take + none from the top, and therefore all off the bottom). + :return: An image. + """ + + # by Kevin Cazabon, Feb 17/2000 + # kevin@cazabon.com + # https://www.cazabon.com + + centering_x, centering_y = centering + + if not 0.0 <= centering_x <= 1.0: + centering_x = 0.5 + if not 0.0 <= centering_y <= 1.0: + centering_y = 0.5 + + if not 0.0 <= bleed < 0.5: + bleed = 0.0 + + # calculate the area to use for resizing and cropping, subtracting + # the 'bleed' around the edges + + # number of pixels to trim off on Top and Bottom, Left and Right + bleed_pixels = (bleed * image.size[0], bleed * image.size[1]) + + live_size = ( + image.size[0] - bleed_pixels[0] * 2, + image.size[1] - bleed_pixels[1] * 2, + ) + + # calculate the aspect ratio of the live_size + live_size_ratio = live_size[0] / live_size[1] + + # calculate the aspect ratio of the output image + output_ratio = size[0] / size[1] + + # figure out if the sides or top/bottom will be cropped off + if live_size_ratio == output_ratio: + # live_size is already the needed ratio + crop_width = live_size[0] + crop_height = live_size[1] + elif live_size_ratio >= output_ratio: + # live_size is wider than what's needed, crop the sides + crop_width = output_ratio * live_size[1] + crop_height = live_size[1] + else: + # live_size is taller than what's needed, crop the top and bottom + crop_width = live_size[0] + crop_height = live_size[0] / output_ratio + + # make the crop + crop_left = bleed_pixels[0] + (live_size[0] - crop_width) * centering_x + crop_top = bleed_pixels[1] + (live_size[1] - crop_height) * centering_y + + crop = (crop_left, crop_top, crop_left + crop_width, crop_top + crop_height) + + # resize the image and return it + return image.resize(size, method, box=crop) + + +def flip(image: Image.Image) -> Image.Image: + """ + Flip the image vertically (top to bottom). + + :param image: The image to flip. + :return: An image. + """ + return image.transpose(Image.Transpose.FLIP_TOP_BOTTOM) + + +def grayscale(image: Image.Image) -> Image.Image: + """ + Convert the image to grayscale. + + :param image: The image to convert. + :return: An image. + """ + return image.convert("L") + + +def invert(image: Image.Image) -> Image.Image: + """ + Invert (negate) the image. + + :param image: The image to invert. + :return: An image. + """ + lut = list(range(255, -1, -1)) + return image.point(lut) if image.mode == "1" else _lut(image, lut) + + +def mirror(image: Image.Image) -> Image.Image: + """ + Flip image horizontally (left to right). + + :param image: The image to mirror. + :return: An image. + """ + return image.transpose(Image.Transpose.FLIP_LEFT_RIGHT) + + +def posterize(image: Image.Image, bits: int) -> Image.Image: + """ + Reduce the number of bits for each color channel. + + :param image: The image to posterize. + :param bits: The number of bits to keep for each channel (1-8). + :return: An image. + """ + mask = ~(2 ** (8 - bits) - 1) + lut = [i & mask for i in range(256)] + return _lut(image, lut) + + +def solarize(image: Image.Image, threshold: int = 128) -> Image.Image: + """ + Invert all pixel values above a threshold. + + :param image: The image to solarize. + :param threshold: All pixels above this grayscale level are inverted. + :return: An image. + """ + lut = [] + for i in range(256): + if i < threshold: + lut.append(i) + else: + lut.append(255 - i) + return _lut(image, lut) + + +@overload +def exif_transpose(image: Image.Image, *, in_place: Literal[True]) -> None: ... + + +@overload +def exif_transpose( + image: Image.Image, *, in_place: Literal[False] = False +) -> Image.Image: ... + + +def exif_transpose(image: Image.Image, *, in_place: bool = False) -> Image.Image | None: + """ + If an image has an EXIF Orientation tag, other than 1, transpose the image + accordingly, and remove the orientation data. + + :param image: The image to transpose. + :param in_place: Boolean. Keyword-only argument. + If ``True``, the original image is modified in-place, and ``None`` is returned. + If ``False`` (default), a new :py:class:`~PIL.Image.Image` object is returned + with the transposition applied. If there is no transposition, a copy of the + image will be returned. + """ + image.load() + image_exif = image.getexif() + orientation = image_exif.get(ExifTags.Base.Orientation, 1) + method = { + 2: Image.Transpose.FLIP_LEFT_RIGHT, + 3: Image.Transpose.ROTATE_180, + 4: Image.Transpose.FLIP_TOP_BOTTOM, + 5: Image.Transpose.TRANSPOSE, + 6: Image.Transpose.ROTATE_270, + 7: Image.Transpose.TRANSVERSE, + 8: Image.Transpose.ROTATE_90, + }.get(orientation) + if method is not None: + if in_place: + image.im = image.im.transpose(method) + image._size = image.im.size + else: + transposed_image = image.transpose(method) + exif_image = image if in_place else transposed_image + + exif = exif_image.getexif() + if ExifTags.Base.Orientation in exif: + del exif[ExifTags.Base.Orientation] + if "exif" in exif_image.info: + exif_image.info["exif"] = exif.tobytes() + elif "Raw profile type exif" in exif_image.info: + exif_image.info["Raw profile type exif"] = exif.tobytes().hex() + for key in ("XML:com.adobe.xmp", "xmp"): + if key in exif_image.info: + for pattern in ( + r'tiff:Orientation="([0-9])"', + r"<tiff:Orientation>([0-9])</tiff:Orientation>", + ): + value = exif_image.info[key] + if isinstance(value, str): + value = re.sub(pattern, "", value) + elif isinstance(value, tuple): + value = tuple( + re.sub(pattern.encode(), b"", v) for v in value + ) + else: + value = re.sub(pattern.encode(), b"", value) + exif_image.info[key] = value + if not in_place: + return transposed_image + elif not in_place: + return image.copy() + return None diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/ImagePalette.py b/presentation/.venv/lib/python3.12/site-packages/PIL/ImagePalette.py new file mode 100644 index 0000000..2abbd46 --- /dev/null +++ b/presentation/.venv/lib/python3.12/site-packages/PIL/ImagePalette.py @@ -0,0 +1,290 @@ +# +# The Python Imaging Library. +# $Id$ +# +# image palette object +# +# History: +# 1996-03-11 fl Rewritten. +# 1997-01-03 fl Up and running. +# 1997-08-23 fl Added load hack +# 2001-04-16 fl Fixed randint shadow bug in random() +# +# Copyright (c) 1997-2001 by Secret Labs AB +# Copyright (c) 1996-1997 by Fredrik Lundh +# +# See the README file for information on usage and redistribution. +# +from __future__ import annotations + +import array +from collections.abc import Sequence +from typing import IO + +from . import GimpGradientFile, GimpPaletteFile, ImageColor, PaletteFile + +TYPE_CHECKING = False +if TYPE_CHECKING: + from . import Image + + +class ImagePalette: + """ + Color palette for palette mapped images + + :param mode: The mode to use for the palette. See: + :ref:`concept-modes`. Defaults to "RGB" + :param palette: An optional palette. If given, it must be a bytearray, + an array or a list of ints between 0-255. The list must consist of + all channels for one color followed by the next color (e.g. RGBRGBRGB). + Defaults to an empty palette. + """ + + def __init__( + self, + mode: str = "RGB", + palette: Sequence[int] | bytes | bytearray | None = None, + ) -> None: + self.mode = mode + self.rawmode: str | None = None # if set, palette contains raw data + self.palette = palette or bytearray() + self.dirty: int | None = None + + @property + def palette(self) -> Sequence[int] | bytes | bytearray: + return self._palette + + @palette.setter + def palette(self, palette: Sequence[int] | bytes | bytearray) -> None: + self._colors: dict[tuple[int, ...], int] | None = None + self._palette = palette + + @property + def colors(self) -> dict[tuple[int, ...], int]: + if self._colors is None: + mode_len = len(self.mode) + self._colors = {} + for i in range(0, len(self.palette), mode_len): + color = tuple(self.palette[i : i + mode_len]) + if color in self._colors: + continue + self._colors[color] = i // mode_len + return self._colors + + @colors.setter + def colors(self, colors: dict[tuple[int, ...], int]) -> None: + self._colors = colors + + def copy(self) -> ImagePalette: + new = ImagePalette() + + new.mode = self.mode + new.rawmode = self.rawmode + if self.palette is not None: + new.palette = self.palette[:] + new.dirty = self.dirty + + return new + + def getdata(self) -> tuple[str, Sequence[int] | bytes | bytearray]: + """ + Get palette contents in format suitable for the low-level + ``im.putpalette`` primitive. + + .. warning:: This method is experimental. + """ + if self.rawmode: + return self.rawmode, self.palette + return self.mode, self.tobytes() + + def tobytes(self) -> bytes: + """Convert palette to bytes. + + .. warning:: This method is experimental. + """ + if self.rawmode: + msg = "palette contains raw palette data" + raise ValueError(msg) + if isinstance(self.palette, bytes): + return self.palette + arr = array.array("B", self.palette) + return arr.tobytes() + + # Declare tostring as an alias for tobytes + tostring = tobytes + + def _new_color_index( + self, image: Image.Image | None = None, e: Exception | None = None + ) -> int: + if not isinstance(self.palette, bytearray): + self._palette = bytearray(self.palette) + index = len(self.palette) // len(self.mode) + special_colors: tuple[int | tuple[int, ...] | None, ...] = () + if image: + special_colors = ( + image.info.get("background"), + image.info.get("transparency"), + ) + while index in special_colors: + index += 1 + if index >= 256: + if image: + # Search for an unused index + for i, count in reversed(list(enumerate(image.histogram()))): + if count == 0 and i not in special_colors: + index = i + break + if index >= 256: + msg = "cannot allocate more than 256 colors" + raise ValueError(msg) from e + return index + + def getcolor( + self, + color: tuple[int, ...], + image: Image.Image | None = None, + ) -> int: + """Given an rgb tuple, allocate palette entry. + + .. warning:: This method is experimental. + """ + if self.rawmode: + msg = "palette contains raw palette data" + raise ValueError(msg) + if isinstance(color, tuple): + if self.mode == "RGB": + if len(color) == 4: + if color[3] != 255: + msg = "cannot add non-opaque RGBA color to RGB palette" + raise ValueError(msg) + color = color[:3] + elif self.mode == "RGBA": + if len(color) == 3: + color += (255,) + try: + return self.colors[color] + except KeyError as e: + # allocate new color slot + index = self._new_color_index(image, e) + assert isinstance(self._palette, bytearray) + self.colors[color] = index + mode_len = len(self.mode) + if index * mode_len < len(self.palette): + self._palette = ( + self._palette[: index * mode_len] + + bytes(color) + + self._palette[index * mode_len + mode_len :] + ) + else: + self._palette += bytes(color) + self.dirty = 1 + return index + else: + msg = f"unknown color specifier: {repr(color)}" # type: ignore[unreachable] + raise ValueError(msg) + + def save(self, fp: str | IO[str]) -> None: + """Save palette to text file. + + .. warning:: This method is experimental. + """ + if self.rawmode: + msg = "palette contains raw palette data" + raise ValueError(msg) + open_fp = False + if isinstance(fp, str): + fp = open(fp, "w") + open_fp = True + try: + fp.write("# Palette\n") + fp.write(f"# Mode: {self.mode}\n") + palette_len = len(self.palette) + for i in range(256): + fp.write(f"{i}") + for j in range(i * len(self.mode), (i + 1) * len(self.mode)): + fp.write(f" {self.palette[j] if j < palette_len else 0}") + fp.write("\n") + finally: + if open_fp: + fp.close() + + +# -------------------------------------------------------------------- +# Internal + + +def raw(rawmode: str, data: Sequence[int] | bytes | bytearray) -> ImagePalette: + palette = ImagePalette() + palette.rawmode = rawmode + palette.palette = data + palette.dirty = 1 + return palette + + +# -------------------------------------------------------------------- +# Factories + + +def make_linear_lut(black: int, white: float) -> list[int]: + if black == 0: + return [int(white * i // 255) for i in range(256)] + + msg = "unavailable when black is non-zero" + raise NotImplementedError(msg) # FIXME + + +def make_gamma_lut(exp: float) -> list[int]: + return [int(((i / 255.0) ** exp) * 255.0 + 0.5) for i in range(256)] + + +def negative(mode: str = "RGB") -> ImagePalette: + palette = list(range(256 * len(mode))) + palette.reverse() + return ImagePalette(mode, [i // len(mode) for i in palette]) + + +def random(mode: str = "RGB") -> ImagePalette: + from random import randint + + palette = [randint(0, 255) for _ in range(256 * len(mode))] + return ImagePalette(mode, palette) + + +def sepia(white: str = "#fff0c0") -> ImagePalette: + bands = [make_linear_lut(0, band) for band in ImageColor.getrgb(white)] + return ImagePalette("RGB", [bands[i % 3][i // 3] for i in range(256 * 3)]) + + +def wedge(mode: str = "RGB") -> ImagePalette: + palette = list(range(256 * len(mode))) + return ImagePalette(mode, [i // len(mode) for i in palette]) + + +def load(filename: str) -> tuple[bytes, str]: + # FIXME: supports GIMP gradients only + + with open(filename, "rb") as fp: + paletteHandlers: list[ + type[ + GimpPaletteFile.GimpPaletteFile + | GimpGradientFile.GimpGradientFile + | PaletteFile.PaletteFile + ] + ] = [ + GimpPaletteFile.GimpPaletteFile, + GimpGradientFile.GimpGradientFile, + PaletteFile.PaletteFile, + ] + for paletteHandler in paletteHandlers: + try: + fp.seek(0) + lut = paletteHandler(fp).getpalette() + if lut: + break + except (SyntaxError, ValueError): + pass + else: + msg = "cannot load palette" + raise OSError(msg) + + return lut # data, rawmode diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/ImagePath.py b/presentation/.venv/lib/python3.12/site-packages/PIL/ImagePath.py new file mode 100644 index 0000000..77e8a60 --- /dev/null +++ b/presentation/.venv/lib/python3.12/site-packages/PIL/ImagePath.py @@ -0,0 +1,20 @@ +# +# The Python Imaging Library +# $Id$ +# +# path interface +# +# History: +# 1996-11-04 fl Created +# 2002-04-14 fl Added documentation stub class +# +# Copyright (c) Secret Labs AB 1997. +# Copyright (c) Fredrik Lundh 1996. +# +# See the README file for information on usage and redistribution. +# +from __future__ import annotations + +from . import Image + +Path = Image.core.path diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/ImageQt.py b/presentation/.venv/lib/python3.12/site-packages/PIL/ImageQt.py new file mode 100644 index 0000000..af4d074 --- /dev/null +++ b/presentation/.venv/lib/python3.12/site-packages/PIL/ImageQt.py @@ -0,0 +1,219 @@ +# +# The Python Imaging Library. +# $Id$ +# +# a simple Qt image interface. +# +# history: +# 2006-06-03 fl: created +# 2006-06-04 fl: inherit from QImage instead of wrapping it +# 2006-06-05 fl: removed toimage helper; move string support to ImageQt +# 2013-11-13 fl: add support for Qt5 (aurelien.ballier@cyclonit.com) +# +# Copyright (c) 2006 by Secret Labs AB +# Copyright (c) 2006 by Fredrik Lundh +# +# See the README file for information on usage and redistribution. +# +from __future__ import annotations + +import sys +from io import BytesIO + +from . import Image +from ._util import is_path + +TYPE_CHECKING = False +if TYPE_CHECKING: + from collections.abc import Callable + from typing import Any + + from . import ImageFile + + QBuffer: type + +qt_version: str | None +qt_versions = [ + ["6", "PyQt6"], + ["side6", "PySide6"], +] + +# If a version has already been imported, attempt it first +qt_versions.sort(key=lambda version: version[1] in sys.modules, reverse=True) +for version, qt_module in qt_versions: + try: + qRgba: Callable[[int, int, int, int], int] + if qt_module == "PyQt6": + from PyQt6.QtCore import QBuffer, QByteArray, QIODevice + from PyQt6.QtGui import QImage, QPixmap, qRgba + elif qt_module == "PySide6": + from PySide6.QtCore import ( # type: ignore[assignment] + QBuffer, + QByteArray, + QIODevice, + ) + from PySide6.QtGui import QImage, QPixmap, qRgba # type: ignore[assignment] + except (ImportError, RuntimeError): + continue + qt_is_installed = True + qt_version = version + break +else: + qt_is_installed = False + qt_version = None + + +def rgb(r: int, g: int, b: int, a: int = 255) -> int: + """(Internal) Turns an RGB color into a Qt compatible color integer.""" + # use qRgb to pack the colors, and then turn the resulting long + # into a negative integer with the same bitpattern. + return qRgba(r, g, b, a) & 0xFFFFFFFF + + +def fromqimage(im: QImage | QPixmap) -> ImageFile.ImageFile: + """ + :param im: QImage or PIL ImageQt object + """ + buffer = QBuffer() + qt_openmode: object + if qt_version == "6": + try: + qt_openmode = getattr(QIODevice, "OpenModeFlag") + except AttributeError: + qt_openmode = getattr(QIODevice, "OpenMode") + else: + qt_openmode = QIODevice + buffer.open(getattr(qt_openmode, "ReadWrite")) + # preserve alpha channel with png + # otherwise ppm is more friendly with Image.open + if im.hasAlphaChannel(): + im.save(buffer, "png") + else: + im.save(buffer, "ppm") + + b = BytesIO() + b.write(buffer.data()) + buffer.close() + b.seek(0) + + return Image.open(b) + + +def fromqpixmap(im: QPixmap) -> ImageFile.ImageFile: + return fromqimage(im) + + +def align8to32(bytes: bytes, width: int, mode: str) -> bytes: + """ + converts each scanline of data from 8 bit to 32 bit aligned + """ + + bits_per_pixel = {"1": 1, "L": 8, "P": 8, "I;16": 16}[mode] + + # calculate bytes per line and the extra padding if needed + bits_per_line = bits_per_pixel * width + full_bytes_per_line, remaining_bits_per_line = divmod(bits_per_line, 8) + bytes_per_line = full_bytes_per_line + (1 if remaining_bits_per_line else 0) + + extra_padding = -bytes_per_line % 4 + + # already 32 bit aligned by luck + if not extra_padding: + return bytes + + new_data = [ + bytes[i * bytes_per_line : (i + 1) * bytes_per_line] + b"\x00" * extra_padding + for i in range(len(bytes) // bytes_per_line) + ] + + return b"".join(new_data) + + +def _toqclass_helper(im: Image.Image | str | QByteArray) -> dict[str, Any]: + data = None + colortable = None + exclusive_fp = False + + # handle filename, if given instead of image name + if hasattr(im, "toUtf8"): + # FIXME - is this really the best way to do this? + im = str(im.toUtf8(), "utf-8") + if is_path(im): + im = Image.open(im) + exclusive_fp = True + assert isinstance(im, Image.Image) + + qt_format = getattr(QImage, "Format") if qt_version == "6" else QImage + if im.mode == "1": + format = getattr(qt_format, "Format_Mono") + elif im.mode == "L": + format = getattr(qt_format, "Format_Indexed8") + colortable = [rgb(i, i, i) for i in range(256)] + elif im.mode == "P": + format = getattr(qt_format, "Format_Indexed8") + palette = im.getpalette() + assert palette is not None + colortable = [rgb(*palette[i : i + 3]) for i in range(0, len(palette), 3)] + elif im.mode == "RGB": + # Populate the 4th channel with 255 + im = im.convert("RGBA") + + data = im.tobytes("raw", "BGRA") + format = getattr(qt_format, "Format_RGB32") + elif im.mode == "RGBA": + data = im.tobytes("raw", "BGRA") + format = getattr(qt_format, "Format_ARGB32") + elif im.mode == "I;16": + im = im.point(lambda i: i * 256) + + format = getattr(qt_format, "Format_Grayscale16") + else: + if exclusive_fp: + im.close() + msg = f"unsupported image mode {repr(im.mode)}" + raise ValueError(msg) + + size = im.size + __data = data or align8to32(im.tobytes(), size[0], im.mode) + if exclusive_fp: + im.close() + return {"data": __data, "size": size, "format": format, "colortable": colortable} + + +if qt_is_installed: + + class ImageQt(QImage): + def __init__(self, im: Image.Image | str | QByteArray) -> None: + """ + An PIL image wrapper for Qt. This is a subclass of PyQt's QImage + class. + + :param im: A PIL Image object, or a file name (given either as + Python string or a PyQt string object). + """ + im_data = _toqclass_helper(im) + # must keep a reference, or Qt will crash! + # All QImage constructors that take data operate on an existing + # buffer, so this buffer has to hang on for the life of the image. + # Fixes https://github.com/python-pillow/Pillow/issues/1370 + self.__data = im_data["data"] + super().__init__( + self.__data, + im_data["size"][0], + im_data["size"][1], + im_data["format"], + ) + if im_data["colortable"]: + self.setColorTable(im_data["colortable"]) + + +def toqimage(im: Image.Image | str | QByteArray) -> ImageQt: + return ImageQt(im) + + +def toqpixmap(im: Image.Image | str | QByteArray) -> QPixmap: + qimage = toqimage(im) + pixmap = getattr(QPixmap, "fromImage")(qimage) + if qt_version == "6": + pixmap.detach() + return pixmap diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/ImageSequence.py b/presentation/.venv/lib/python3.12/site-packages/PIL/ImageSequence.py new file mode 100644 index 0000000..361be48 --- /dev/null +++ b/presentation/.venv/lib/python3.12/site-packages/PIL/ImageSequence.py @@ -0,0 +1,88 @@ +# +# The Python Imaging Library. +# $Id$ +# +# sequence support classes +# +# history: +# 1997-02-20 fl Created +# +# Copyright (c) 1997 by Secret Labs AB. +# Copyright (c) 1997 by Fredrik Lundh. +# +# See the README file for information on usage and redistribution. +# + +## +from __future__ import annotations + +from . import Image + +TYPE_CHECKING = False +if TYPE_CHECKING: + from collections.abc import Callable + + +class Iterator: + """ + This class implements an iterator object that can be used to loop + over an image sequence. + + You can use the ``[]`` operator to access elements by index. This operator + will raise an :py:exc:`IndexError` if you try to access a nonexistent + frame. + + :param im: An image object. + """ + + def __init__(self, im: Image.Image) -> None: + if not hasattr(im, "seek"): + msg = "im must have seek method" + raise AttributeError(msg) + self.im = im + self.position = getattr(self.im, "_min_frame", 0) + + def __getitem__(self, ix: int) -> Image.Image: + try: + self.im.seek(ix) + return self.im + except EOFError as e: + msg = "end of sequence" + raise IndexError(msg) from e + + def __iter__(self) -> Iterator: + return self + + def __next__(self) -> Image.Image: + try: + self.im.seek(self.position) + self.position += 1 + return self.im + except EOFError as e: + msg = "end of sequence" + raise StopIteration(msg) from e + + +def all_frames( + im: Image.Image | list[Image.Image], + func: Callable[[Image.Image], Image.Image] | None = None, +) -> list[Image.Image]: + """ + Applies a given function to all frames in an image or a list of images. + The frames are returned as a list of separate images. + + :param im: An image, or a list of images. + :param func: The function to apply to all of the image frames. + :returns: A list of images. + """ + if not isinstance(im, list): + im = [im] + + ims = [] + for imSequence in im: + current = imSequence.tell() + + ims += [im_frame.copy() for im_frame in Iterator(imSequence)] + + imSequence.seek(current) + return [func(im) for im in ims] if func else ims diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/ImageShow.py b/presentation/.venv/lib/python3.12/site-packages/PIL/ImageShow.py new file mode 100644 index 0000000..7705608 --- /dev/null +++ b/presentation/.venv/lib/python3.12/site-packages/PIL/ImageShow.py @@ -0,0 +1,362 @@ +# +# The Python Imaging Library. +# $Id$ +# +# im.show() drivers +# +# History: +# 2008-04-06 fl Created +# +# Copyright (c) Secret Labs AB 2008. +# +# See the README file for information on usage and redistribution. +# +from __future__ import annotations + +import abc +import os +import shutil +import subprocess +import sys +from shlex import quote +from typing import Any + +from . import Image + +_viewers = [] + + +def register(viewer: type[Viewer] | Viewer, order: int = 1) -> None: + """ + The :py:func:`register` function is used to register additional viewers:: + + from PIL import ImageShow + ImageShow.register(MyViewer()) # MyViewer will be used as a last resort + ImageShow.register(MySecondViewer(), 0) # MySecondViewer will be prioritised + ImageShow.register(ImageShow.XVViewer(), 0) # XVViewer will be prioritised + + :param viewer: The viewer to be registered. + :param order: + Zero or a negative integer to prepend this viewer to the list, + a positive integer to append it. + """ + if isinstance(viewer, type) and issubclass(viewer, Viewer): + viewer = viewer() + if order > 0: + _viewers.append(viewer) + else: + _viewers.insert(0, viewer) + + +def show(image: Image.Image, title: str | None = None, **options: Any) -> bool: + r""" + Display a given image. + + :param image: An image object. + :param title: Optional title. Not all viewers can display the title. + :param \**options: Additional viewer options. + :returns: ``True`` if a suitable viewer was found, ``False`` otherwise. + """ + for viewer in _viewers: + if viewer.show(image, title=title, **options): + return True + return False + + +class Viewer: + """Base class for viewers.""" + + # main api + + def show(self, image: Image.Image, **options: Any) -> int: + """ + The main function for displaying an image. + Converts the given image to the target format and displays it. + """ + + if not ( + image.mode in ("1", "RGBA") + or (self.format == "PNG" and image.mode in ("I;16", "LA")) + ): + base = Image.getmodebase(image.mode) + if image.mode != base: + image = image.convert(base) + + return self.show_image(image, **options) + + # hook methods + + format: str | None = None + """The format to convert the image into.""" + options: dict[str, Any] = {} + """Additional options used to convert the image.""" + + def get_format(self, image: Image.Image) -> str | None: + """Return format name, or ``None`` to save as PGM/PPM.""" + return self.format + + def get_command(self, file: str, **options: Any) -> str: + """ + Returns the command used to display the file. + Not implemented in the base class. + """ + msg = "unavailable in base viewer" + raise NotImplementedError(msg) + + def save_image(self, image: Image.Image) -> str: + """Save to temporary file and return filename.""" + return image._dump(format=self.get_format(image), **self.options) + + def show_image(self, image: Image.Image, **options: Any) -> int: + """Display the given image.""" + return self.show_file(self.save_image(image), **options) + + def show_file(self, path: str, **options: Any) -> int: + """ + Display given file. + """ + if not os.path.exists(path): + raise FileNotFoundError + os.system(self.get_command(path, **options)) # nosec + return 1 + + +# -------------------------------------------------------------------- + + +class WindowsViewer(Viewer): + """The default viewer on Windows is the default system application for PNG files.""" + + format = "PNG" + options = {"compress_level": 1, "save_all": True} + + def get_command(self, file: str, **options: Any) -> str: + return ( + f'start "Pillow" /WAIT "{file}" ' + "&& ping -n 4 127.0.0.1 >NUL " + f'&& del /f "{file}"' + ) + + def show_file(self, path: str, **options: Any) -> int: + """ + Display given file. + """ + if not os.path.exists(path): + raise FileNotFoundError + subprocess.Popen( + self.get_command(path, **options), + shell=True, + creationflags=getattr(subprocess, "CREATE_NO_WINDOW"), + ) # nosec + return 1 + + +if sys.platform == "win32": + register(WindowsViewer) + + +class MacViewer(Viewer): + """The default viewer on macOS using ``Preview.app``.""" + + format = "PNG" + options = {"compress_level": 1, "save_all": True} + + def get_command(self, file: str, **options: Any) -> str: + # on darwin open returns immediately resulting in the temp + # file removal while app is opening + command = "open -a Preview.app" + command = f"({command} {quote(file)}; sleep 20; rm -f {quote(file)})&" + return command + + def show_file(self, path: str, **options: Any) -> int: + """ + Display given file. + """ + if not os.path.exists(path): + raise FileNotFoundError + subprocess.call(["open", "-a", "Preview.app", path]) + + pyinstaller = getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS") + executable = (not pyinstaller and sys.executable) or shutil.which("python3") + if executable: + subprocess.Popen( + [ + executable, + "-c", + "import os, sys, time; time.sleep(20); os.remove(sys.argv[1])", + path, + ] + ) + return 1 + + +if sys.platform == "darwin": + register(MacViewer) + + +class UnixViewer(abc.ABC, Viewer): + format = "PNG" + options = {"compress_level": 1, "save_all": True} + + @abc.abstractmethod + def get_command_ex(self, file: str, **options: Any) -> tuple[str, str]: + pass + + def get_command(self, file: str, **options: Any) -> str: + command = self.get_command_ex(file, **options)[0] + return f"{command} {quote(file)}" + + +class XDGViewer(UnixViewer): + """ + The freedesktop.org ``xdg-open`` command. + """ + + def get_command_ex(self, file: str, **options: Any) -> tuple[str, str]: + command = executable = "xdg-open" + return command, executable + + def show_file(self, path: str, **options: Any) -> int: + """ + Display given file. + """ + if not os.path.exists(path): + raise FileNotFoundError + subprocess.Popen(["xdg-open", path]) + return 1 + + +class DisplayViewer(UnixViewer): + """ + The ImageMagick ``display`` command. + This viewer supports the ``title`` parameter. + """ + + def get_command_ex( + self, file: str, title: str | None = None, **options: Any + ) -> tuple[str, str]: + command = executable = "display" + if title: + command += f" -title {quote(title)}" + return command, executable + + def show_file(self, path: str, **options: Any) -> int: + """ + Display given file. + """ + if not os.path.exists(path): + raise FileNotFoundError + args = ["display"] + title = options.get("title") + if title: + args += ["-title", title] + args.append(path) + + subprocess.Popen(args) + return 1 + + +class GmDisplayViewer(UnixViewer): + """The GraphicsMagick ``gm display`` command.""" + + def get_command_ex(self, file: str, **options: Any) -> tuple[str, str]: + executable = "gm" + command = "gm display" + return command, executable + + def show_file(self, path: str, **options: Any) -> int: + """ + Display given file. + """ + if not os.path.exists(path): + raise FileNotFoundError + subprocess.Popen(["gm", "display", path]) + return 1 + + +class EogViewer(UnixViewer): + """The GNOME Image Viewer ``eog`` command.""" + + def get_command_ex(self, file: str, **options: Any) -> tuple[str, str]: + executable = "eog" + command = "eog -n" + return command, executable + + def show_file(self, path: str, **options: Any) -> int: + """ + Display given file. + """ + if not os.path.exists(path): + raise FileNotFoundError + subprocess.Popen(["eog", "-n", path]) + return 1 + + +class XVViewer(UnixViewer): + """ + The X Viewer ``xv`` command. + This viewer supports the ``title`` parameter. + """ + + def get_command_ex( + self, file: str, title: str | None = None, **options: Any + ) -> tuple[str, str]: + # note: xv is pretty outdated. most modern systems have + # imagemagick's display command instead. + command = executable = "xv" + if title: + command += f" -name {quote(title)}" + return command, executable + + def show_file(self, path: str, **options: Any) -> int: + """ + Display given file. + """ + if not os.path.exists(path): + raise FileNotFoundError + args = ["xv"] + title = options.get("title") + if title: + args += ["-name", title] + args.append(path) + + subprocess.Popen(args) + return 1 + + +if sys.platform not in ("win32", "darwin"): # unixoids + if shutil.which("xdg-open"): + register(XDGViewer) + if shutil.which("display"): + register(DisplayViewer) + if shutil.which("gm"): + register(GmDisplayViewer) + if shutil.which("eog"): + register(EogViewer) + if shutil.which("xv"): + register(XVViewer) + + +class IPythonViewer(Viewer): + """The viewer for IPython frontends.""" + + def show_image(self, image: Image.Image, **options: Any) -> int: + ipython_display(image) + return 1 + + +try: + from IPython.display import display as ipython_display +except ImportError: + pass +else: + register(IPythonViewer) + + +if __name__ == "__main__": + if len(sys.argv) < 2: + print("Syntax: python3 ImageShow.py imagefile [title]") + sys.exit() + + with Image.open(sys.argv[1]) as im: + print(show(im, *sys.argv[2:])) diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/ImageStat.py b/presentation/.venv/lib/python3.12/site-packages/PIL/ImageStat.py new file mode 100644 index 0000000..3a1044b --- /dev/null +++ b/presentation/.venv/lib/python3.12/site-packages/PIL/ImageStat.py @@ -0,0 +1,167 @@ +# +# The Python Imaging Library. +# $Id$ +# +# global image statistics +# +# History: +# 1996-04-05 fl Created +# 1997-05-21 fl Added mask; added rms, var, stddev attributes +# 1997-08-05 fl Added median +# 1998-07-05 hk Fixed integer overflow error +# +# Notes: +# This class shows how to implement delayed evaluation of attributes. +# To get a certain value, simply access the corresponding attribute. +# The __getattr__ dispatcher takes care of the rest. +# +# Copyright (c) Secret Labs AB 1997. +# Copyright (c) Fredrik Lundh 1996-97. +# +# See the README file for information on usage and redistribution. +# +from __future__ import annotations + +import math +from functools import cached_property + +from . import Image + + +class Stat: + def __init__( + self, image_or_list: Image.Image | list[int], mask: Image.Image | None = None + ) -> None: + """ + Calculate statistics for the given image. If a mask is included, + only the regions covered by that mask are included in the + statistics. You can also pass in a previously calculated histogram. + + :param image: A PIL image, or a precalculated histogram. + + .. note:: + + For a PIL image, calculations rely on the + :py:meth:`~PIL.Image.Image.histogram` method. The pixel counts are + grouped into 256 bins, even if the image has more than 8 bits per + channel. So ``I`` and ``F`` mode images have a maximum ``mean``, + ``median`` and ``rms`` of 255, and cannot have an ``extrema`` maximum + of more than 255. + + :param mask: An optional mask. + """ + if isinstance(image_or_list, Image.Image): + self.h = image_or_list.histogram(mask) + elif isinstance(image_or_list, list): + self.h = image_or_list + else: + msg = "first argument must be image or list" # type: ignore[unreachable] + raise TypeError(msg) + self.bands = list(range(len(self.h) // 256)) + + @cached_property + def extrema(self) -> list[tuple[int, int]]: + """ + Min/max values for each band in the image. + + .. note:: + This relies on the :py:meth:`~PIL.Image.Image.histogram` method, and + simply returns the low and high bins used. This is correct for + images with 8 bits per channel, but fails for other modes such as + ``I`` or ``F``. Instead, use :py:meth:`~PIL.Image.Image.getextrema` to + return per-band extrema for the image. This is more correct and + efficient because, for non-8-bit modes, the histogram method uses + :py:meth:`~PIL.Image.Image.getextrema` to determine the bins used. + """ + + def minmax(histogram: list[int]) -> tuple[int, int]: + res_min, res_max = 255, 0 + for i in range(256): + if histogram[i]: + res_min = i + break + for i in range(255, -1, -1): + if histogram[i]: + res_max = i + break + return res_min, res_max + + return [minmax(self.h[i:]) for i in range(0, len(self.h), 256)] + + @cached_property + def count(self) -> list[int]: + """Total number of pixels for each band in the image.""" + return [sum(self.h[i : i + 256]) for i in range(0, len(self.h), 256)] + + @cached_property + def sum(self) -> list[float]: + """Sum of all pixels for each band in the image.""" + + v = [] + for i in range(0, len(self.h), 256): + layer_sum = 0.0 + for j in range(256): + layer_sum += j * self.h[i + j] + v.append(layer_sum) + return v + + @cached_property + def sum2(self) -> list[float]: + """Squared sum of all pixels for each band in the image.""" + + v = [] + for i in range(0, len(self.h), 256): + sum2 = 0.0 + for j in range(256): + sum2 += (j**2) * float(self.h[i + j]) + v.append(sum2) + return v + + @cached_property + def mean(self) -> list[float]: + """Average (arithmetic mean) pixel level for each band in the image.""" + return [self.sum[i] / self.count[i] if self.count[i] else 0 for i in self.bands] + + @cached_property + def median(self) -> list[int]: + """Median pixel level for each band in the image.""" + + v = [] + for i in self.bands: + s = 0 + half = self.count[i] // 2 + b = i * 256 + for j in range(256): + s = s + self.h[b + j] + if s > half: + break + v.append(j) + return v + + @cached_property + def rms(self) -> list[float]: + """RMS (root-mean-square) for each band in the image.""" + return [ + math.sqrt(self.sum2[i] / self.count[i]) if self.count[i] else 0 + for i in self.bands + ] + + @cached_property + def var(self) -> list[float]: + """Variance for each band in the image.""" + return [ + ( + (self.sum2[i] - (self.sum[i] ** 2.0) / self.count[i]) / self.count[i] + if self.count[i] + else 0 + ) + for i in self.bands + ] + + @cached_property + def stddev(self) -> list[float]: + """Standard deviation for each band in the image.""" + return [math.sqrt(self.var[i]) for i in self.bands] + + +Global = Stat # compatibility diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/ImageText.py b/presentation/.venv/lib/python3.12/site-packages/PIL/ImageText.py new file mode 100644 index 0000000..008d20d --- /dev/null +++ b/presentation/.venv/lib/python3.12/site-packages/PIL/ImageText.py @@ -0,0 +1,508 @@ +from __future__ import annotations + +import math +import re +from typing import AnyStr, Generic, NamedTuple + +from . import ImageFont +from ._typing import _Ink + +Font = ImageFont.ImageFont | ImageFont.FreeTypeFont | ImageFont.TransposedFont + + +class _Line(NamedTuple): + x: float + y: float + anchor: str + text: str | bytes + + +class _Wrap(Generic[AnyStr]): + lines: list[AnyStr] = [] + position = 0 + offset = 0 + + def __init__( + self, + text: Text[AnyStr], + width: int, + height: int | None = None, + font: Font | None = None, + ) -> None: + self.text: Text[AnyStr] = text + self.width = width + self.height = height + self.font = font + + input_text = self.text.text + emptystring = "" if isinstance(input_text, str) else b"" + line = emptystring + + for word in re.findall( + r"\s*\S+" if isinstance(input_text, str) else rb"\s*\S+", input_text + ): + newlines = re.findall( + r"[^\S\n]*\n" if isinstance(input_text, str) else rb"[^\S\n]*\n", word + ) + if newlines: + if not self.add_line(line): + break + for i, line in enumerate(newlines): + if i != 0 and not self.add_line(emptystring): + break + self.position += len(line) + word = word[len(line) :] + line = emptystring + + new_line = line + word + if self.text._get_bbox(new_line, self.font)[2] <= width: + # This word fits on the line + line = new_line + continue + + # This word does not fit on the line + if line and not self.add_line(line): + break + + original_length = len(word) + word = word.lstrip() + self.offset = original_length - len(word) + + if self.text._get_bbox(word, self.font)[2] > width: + if font is None: + msg = "Word does not fit within line" + raise ValueError(msg) + break + line = word + else: + if line: + self.add_line(line) + self.remaining_text: AnyStr = input_text[self.position :] + + def add_line(self, line: AnyStr) -> bool: + lines = self.lines + [line] + if self.height is not None: + last_line_y = self.text._split(lines=lines)[-1].y + last_line_height = self.text._get_bbox(line, self.font)[3] + if last_line_y + last_line_height > self.height: + return False + + self.lines = lines + self.position += len(line) + self.offset + self.offset = 0 + return True + + +class Text(Generic[AnyStr]): + def __init__( + self, + text: AnyStr, + font: Font | None = None, + mode: str = "RGB", + spacing: float = 4, + direction: str | None = None, + features: list[str] | None = None, + language: str | None = None, + ) -> None: + """ + :param text: String to be drawn. + :param font: Either an :py:class:`~PIL.ImageFont.ImageFont` instance, + :py:class:`~PIL.ImageFont.FreeTypeFont` instance, + :py:class:`~PIL.ImageFont.TransposedFont` instance or ``None``. If + ``None``, the default font from :py:meth:`.ImageFont.load_default` + will be used. + :param mode: The image mode this will be used with. + :param spacing: The number of pixels between lines. + :param direction: Direction of the text. It can be ``"rtl"`` (right to left), + ``"ltr"`` (left to right) or ``"ttb"`` (top to bottom). + Requires libraqm. + :param features: A list of OpenType font features to be used during text + layout. This is usually used to turn on optional font features + that are not enabled by default, for example ``"dlig"`` or + ``"ss01"``, but can be also used to turn off default font + features, for example ``"-liga"`` to disable ligatures or + ``"-kern"`` to disable kerning. To get all supported + features, see `OpenType docs`_. + Requires libraqm. + :param language: Language of the text. Different languages may use + different glyph shapes or ligatures. This parameter tells + the font which language the text is in, and to apply the + correct substitutions as appropriate, if available. + It should be a `BCP 47 language code`_. + Requires libraqm. + """ + self.text: AnyStr = text + self.font = font or ImageFont.load_default() + + self.mode = mode + self.spacing = spacing + self.direction = direction + self.features = features + self.language = language + + self.embedded_color = False + + self.stroke_width: float = 0 + self.stroke_fill: _Ink | None = None + + def embed_color(self) -> None: + """ + Use embedded color glyphs (COLR, CBDT, SBIX). + """ + if self.mode not in ("RGB", "RGBA"): + msg = "Embedded color supported only in RGB and RGBA modes" + raise ValueError(msg) + self.embedded_color = True + + def stroke(self, width: float = 0, fill: _Ink | None = None) -> None: + """ + :param width: The width of the text stroke. + :param fill: Color to use for the text stroke when drawing. If not given, will + default to the ``fill`` parameter from + :py:meth:`.ImageDraw.ImageDraw.text`. + """ + self.stroke_width = width + self.stroke_fill = fill + + def _get_fontmode(self) -> str: + if self.mode in ("1", "P", "I", "F"): + return "1" + elif self.embedded_color: + return "RGBA" + else: + return "L" + + def wrap( + self, + width: int, + height: int | None = None, + scaling: str | tuple[str, int] | None = None, + ) -> Text[AnyStr] | None: + """ + Wrap text to fit within a given width. + + :param width: The width to fit within. + :param height: An optional height limit. Any text that does not fit within this + will be returned as a new :py:class:`.Text` object. + :param scaling: An optional directive to scale the text, either "grow" as much + as possible within the given dimensions, or "shrink" until it + fits. It can also be a tuple of (direction, limit), with an + integer limit to stop scaling at. + + :returns: An :py:class:`.Text` object, or None. + """ + if isinstance(self.font, ImageFont.TransposedFont): + msg = "TransposedFont not supported" + raise ValueError(msg) + if self.direction not in (None, "ltr"): + msg = "Only ltr direction supported" + raise ValueError(msg) + + if scaling is None: + wrap = _Wrap(self, width, height) + else: + if not isinstance(self.font, ImageFont.FreeTypeFont): + msg = "'scaling' only supports FreeTypeFont" + raise ValueError(msg) + if height is None: + msg = "'scaling' requires 'height'" + raise ValueError(msg) + + if isinstance(scaling, str): + limit = 1 + else: + scaling, limit = scaling + + font = self.font + wrap = _Wrap(self, width, height, font) + if scaling == "shrink": + if not wrap.remaining_text: + return None + + size = math.ceil(font.size) + while wrap.remaining_text: + if size == max(limit, 1): + msg = "Text could not be scaled" + raise ValueError(msg) + size -= 1 + font = self.font.font_variant(size=size) + wrap = _Wrap(self, width, height, font) + self.font = font + else: + if wrap.remaining_text: + msg = "Text could not be scaled" + raise ValueError(msg) + + size = math.floor(font.size) + while not wrap.remaining_text: + if size == limit: + msg = "Text could not be scaled" + raise ValueError(msg) + size += 1 + font = self.font.font_variant(size=size) + last_wrap = wrap + wrap = _Wrap(self, width, height, font) + size -= 1 + if size != self.font.size: + self.font = self.font.font_variant(size=size) + wrap = last_wrap + + if wrap.remaining_text: + text = Text( + text=wrap.remaining_text, + font=self.font, + mode=self.mode, + spacing=self.spacing, + direction=self.direction, + features=self.features, + language=self.language, + ) + text.embedded_color = self.embedded_color + text.stroke_width = self.stroke_width + text.stroke_fill = self.stroke_fill + else: + text = None + + newline = "\n" if isinstance(self.text, str) else b"\n" + self.text = newline.join(wrap.lines) + return text + + def get_length(self) -> float: + """ + Returns length (in pixels with 1/64 precision) of text. + + This is the amount by which following text should be offset. + Text bounding box may extend past the length in some fonts, + e.g. when using italics or accents. + + The result is returned as a float; it is a whole number if using basic layout. + + Note that the sum of two lengths may not equal the length of a concatenated + string due to kerning. If you need to adjust for kerning, include the following + character and subtract its length. + + For example, instead of:: + + hello = ImageText.Text("Hello", font).get_length() + world = ImageText.Text("World", font).get_length() + helloworld = ImageText.Text("HelloWorld", font).get_length() + assert hello + world == helloworld + + use:: + + hello = ( + ImageText.Text("HelloW", font).get_length() - + ImageText.Text("W", font).get_length() + ) # adjusted for kerning + world = ImageText.Text("World", font).get_length() + helloworld = ImageText.Text("HelloWorld", font).get_length() + assert hello + world == helloworld + + or disable kerning with (requires libraqm):: + + hello = ImageText.Text("Hello", font, features=["-kern"]).get_length() + world = ImageText.Text("World", font, features=["-kern"]).get_length() + helloworld = ImageText.Text( + "HelloWorld", font, features=["-kern"] + ).get_length() + assert hello + world == helloworld + + :return: Either width for horizontal text, or height for vertical text. + """ + if isinstance(self.text, str): + multiline = "\n" in self.text + else: + multiline = b"\n" in self.text + if multiline: + msg = "can't measure length of multiline text" + raise ValueError(msg) + return self.font.getlength( + self.text, + self._get_fontmode(), + self.direction, + self.features, + self.language, + ) + + def _split( + self, + xy: tuple[float, float] = (0, 0), + anchor: str | None = None, + align: str = "left", + lines: list[str] | list[bytes] | None = None, + ) -> list[_Line]: + if anchor is None: + anchor = "lt" if self.direction == "ttb" else "la" + elif len(anchor) != 2: + msg = "anchor must be a 2 character string" + raise ValueError(msg) + + if lines is None: + lines = ( + self.text.split("\n") + if isinstance(self.text, str) + else self.text.split(b"\n") + ) + if len(lines) == 1: + return [_Line(xy[0], xy[1], anchor, lines[0])] + + if anchor[1] in "tb" and self.direction != "ttb": + msg = "anchor not supported for multiline text" + raise ValueError(msg) + + fontmode = self._get_fontmode() + line_spacing = ( + self.font.getbbox( + "A", + fontmode, + None, + self.features, + self.language, + self.stroke_width, + )[3] + + self.stroke_width + + self.spacing + ) + + top = xy[1] + parts = [] + if self.direction == "ttb": + left = xy[0] + for line in lines: + parts.append(_Line(left, top, anchor, line)) + left += line_spacing + else: + widths = [] + max_width: float = 0 + for line in lines: + line_width = self.font.getlength( + line, fontmode, self.direction, self.features, self.language + ) + widths.append(line_width) + max_width = max(max_width, line_width) + + if anchor[1] == "m": + top -= (len(lines) - 1) * line_spacing / 2.0 + elif anchor[1] == "d": + top -= (len(lines) - 1) * line_spacing + + idx = -1 + for line in lines: + left = xy[0] + idx += 1 + width_difference = max_width - widths[idx] + + # align by align parameter + if align in ("left", "justify"): + pass + elif align == "center": + left += width_difference / 2.0 + elif align == "right": + left += width_difference + else: + msg = 'align must be "left", "center", "right" or "justify"' + raise ValueError(msg) + + if ( + align == "justify" + and width_difference != 0 + and idx != len(lines) - 1 + ): + words = ( + line.split(" ") if isinstance(line, str) else line.split(b" ") + ) + if len(words) > 1: + # align left by anchor + if anchor[0] == "m": + left -= max_width / 2.0 + elif anchor[0] == "r": + left -= max_width + + word_widths = [ + self.font.getlength( + word, + fontmode, + self.direction, + self.features, + self.language, + ) + for word in words + ] + word_anchor = "l" + anchor[1] + width_difference = max_width - sum(word_widths) + i = 0 + for word in words: + parts.append(_Line(left, top, word_anchor, word)) + left += word_widths[i] + width_difference / (len(words) - 1) + i += 1 + top += line_spacing + continue + + # align left by anchor + if anchor[0] == "m": + left -= width_difference / 2.0 + elif anchor[0] == "r": + left -= width_difference + parts.append(_Line(left, top, anchor, line)) + top += line_spacing + + return parts + + def _get_bbox( + self, text: str | bytes, font: Font | None = None, anchor: str | None = None + ) -> tuple[float, float, float, float]: + return (font or self.font).getbbox( + text, + self._get_fontmode(), + self.direction, + self.features, + self.language, + self.stroke_width, + anchor, + ) + + def get_bbox( + self, + xy: tuple[float, float] = (0, 0), + anchor: str | None = None, + align: str = "left", + ) -> tuple[float, float, float, float]: + """ + Returns bounding box (in pixels) of text. + + Use :py:meth:`get_length` to get the offset of following text with 1/64 pixel + precision. The bounding box includes extra margins for some fonts, e.g. italics + or accents. + + :param xy: The anchor coordinates of the text. + :param anchor: The text anchor alignment. Determines the relative location of + the anchor to the text. The default alignment is top left, + specifically ``la`` for horizontal text and ``lt`` for + vertical text. See :ref:`text-anchors` for details. + :param align: For multiline text, ``"left"``, ``"center"``, ``"right"`` or + ``"justify"`` determines the relative alignment of lines. Use the + ``anchor`` parameter to specify the alignment to ``xy``. + + :return: ``(left, top, right, bottom)`` bounding box + """ + bbox: tuple[float, float, float, float] | None = None + for x, y, anchor, text in self._split(xy, anchor, align): + bbox_line = self._get_bbox(text, anchor=anchor) + bbox_line = ( + bbox_line[0] + x, + bbox_line[1] + y, + bbox_line[2] + x, + bbox_line[3] + y, + ) + if bbox is None: + bbox = bbox_line + else: + bbox = ( + min(bbox[0], bbox_line[0]), + min(bbox[1], bbox_line[1]), + max(bbox[2], bbox_line[2]), + max(bbox[3], bbox_line[3]), + ) + + assert bbox is not None + return bbox diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/ImageTk.py b/presentation/.venv/lib/python3.12/site-packages/PIL/ImageTk.py new file mode 100644 index 0000000..3a4cb81 --- /dev/null +++ b/presentation/.venv/lib/python3.12/site-packages/PIL/ImageTk.py @@ -0,0 +1,266 @@ +# +# The Python Imaging Library. +# $Id$ +# +# a Tk display interface +# +# History: +# 96-04-08 fl Created +# 96-09-06 fl Added getimage method +# 96-11-01 fl Rewritten, removed image attribute and crop method +# 97-05-09 fl Use PyImagingPaste method instead of image type +# 97-05-12 fl Minor tweaks to match the IFUNC95 interface +# 97-05-17 fl Support the "pilbitmap" booster patch +# 97-06-05 fl Added file= and data= argument to image constructors +# 98-03-09 fl Added width and height methods to Image classes +# 98-07-02 fl Use default mode for "P" images without palette attribute +# 98-07-02 fl Explicitly destroy Tkinter image objects +# 99-07-24 fl Support multiple Tk interpreters (from Greg Couch) +# 99-07-26 fl Automatically hook into Tkinter (if possible) +# 99-08-15 fl Hook uses _imagingtk instead of _imaging +# +# Copyright (c) 1997-1999 by Secret Labs AB +# Copyright (c) 1996-1997 by Fredrik Lundh +# +# See the README file for information on usage and redistribution. +# +from __future__ import annotations + +import tkinter +from io import BytesIO +from typing import Any + +from . import Image, ImageFile + +TYPE_CHECKING = False +if TYPE_CHECKING: + from ._typing import CapsuleType + +# -------------------------------------------------------------------- +# Check for Tkinter interface hooks + + +def _get_image_from_kw(kw: dict[str, Any]) -> ImageFile.ImageFile | None: + source = None + if "file" in kw: + source = kw.pop("file") + elif "data" in kw: + source = BytesIO(kw.pop("data")) + if not source: + return None + return Image.open(source) + + +def _pyimagingtkcall( + command: str, photo: PhotoImage | tkinter.PhotoImage, ptr: CapsuleType +) -> None: + tk = photo.tk + try: + tk.call(command, photo, repr(ptr)) + except tkinter.TclError: + # activate Tkinter hook + # may raise an error if it cannot attach to Tkinter + from . import _imagingtk + + _imagingtk.tkinit(tk.interpaddr()) + tk.call(command, photo, repr(ptr)) + + +# -------------------------------------------------------------------- +# PhotoImage + + +class PhotoImage: + """ + A Tkinter-compatible photo image. This can be used + everywhere Tkinter expects an image object. If the image is an RGBA + image, pixels having alpha 0 are treated as transparent. + + The constructor takes either a PIL image, or a mode and a size. + Alternatively, you can use the ``file`` or ``data`` options to initialize + the photo image object. + + :param image: Either a PIL image, or a mode string. If a mode string is + used, a size must also be given. + :param size: If the first argument is a mode string, this defines the size + of the image. + :keyword file: A filename to load the image from (using + ``Image.open(file)``). + :keyword data: An 8-bit string containing image data (as loaded from an + image file). + """ + + def __init__( + self, + image: Image.Image | str | None = None, + size: tuple[int, int] | None = None, + **kw: Any, + ) -> None: + # Tk compatibility: file or data + if image is None: + image = _get_image_from_kw(kw) + + if image is None: + msg = "Image is required" + raise ValueError(msg) + elif isinstance(image, str): + mode = image + image = None + + if size is None: + msg = "If first argument is mode, size is required" + raise ValueError(msg) + else: + # got an image instead of a mode + mode = image.mode + if mode == "P": + # palette mapped data + image.apply_transparency() + image.load() + mode = image.palette.mode if image.palette else "RGB" + size = image.size + kw["width"], kw["height"] = size + + if mode not in ["1", "L", "RGB", "RGBA"]: + mode = Image.getmodebase(mode) + + self.__mode = mode + self.__size = size + self.__photo = tkinter.PhotoImage(**kw) + self.tk = self.__photo.tk + if image: + self.paste(image) + + def __del__(self) -> None: + try: + name = self.__photo.name + except AttributeError: + return + self.__photo.name = None + try: + self.__photo.tk.call("image", "delete", name) + except Exception: + pass # ignore internal errors + + def __str__(self) -> str: + """ + Get the Tkinter photo image identifier. This method is automatically + called by Tkinter whenever a PhotoImage object is passed to a Tkinter + method. + + :return: A Tkinter photo image identifier (a string). + """ + return str(self.__photo) + + def width(self) -> int: + """ + Get the width of the image. + + :return: The width, in pixels. + """ + return self.__size[0] + + def height(self) -> int: + """ + Get the height of the image. + + :return: The height, in pixels. + """ + return self.__size[1] + + def paste(self, im: Image.Image) -> None: + """ + Paste a PIL image into the photo image. Note that this can + be very slow if the photo image is displayed. + + :param im: A PIL image. The size must match the target region. If the + mode does not match, the image is converted to the mode of + the bitmap image. + """ + # convert to blittable + ptr = im.getim() + image = im.im + if not image.isblock() or im.mode != self.__mode: + block = Image.core.new_block(self.__mode, im.size) + image.convert2(block, image) # convert directly between buffers + ptr = block.ptr + + _pyimagingtkcall("PyImagingPhoto", self.__photo, ptr) + + +# -------------------------------------------------------------------- +# BitmapImage + + +class BitmapImage: + """ + A Tkinter-compatible bitmap image. This can be used everywhere Tkinter + expects an image object. + + The given image must have mode "1". Pixels having value 0 are treated as + transparent. Options, if any, are passed on to Tkinter. The most commonly + used option is ``foreground``, which is used to specify the color for the + non-transparent parts. See the Tkinter documentation for information on + how to specify colours. + + :param image: A PIL image. + """ + + def __init__(self, image: Image.Image | None = None, **kw: Any) -> None: + # Tk compatibility: file or data + if image is None: + image = _get_image_from_kw(kw) + + if image is None: + msg = "Image is required" + raise ValueError(msg) + self.__mode = image.mode + self.__size = image.size + + self.__photo = tkinter.BitmapImage(data=image.tobitmap(), **kw) + + def __del__(self) -> None: + try: + name = self.__photo.name + except AttributeError: + return + self.__photo.name = None + try: + self.__photo.tk.call("image", "delete", name) + except Exception: + pass # ignore internal errors + + def width(self) -> int: + """ + Get the width of the image. + + :return: The width, in pixels. + """ + return self.__size[0] + + def height(self) -> int: + """ + Get the height of the image. + + :return: The height, in pixels. + """ + return self.__size[1] + + def __str__(self) -> str: + """ + Get the Tkinter bitmap image identifier. This method is automatically + called by Tkinter whenever a BitmapImage object is passed to a Tkinter + method. + + :return: A Tkinter bitmap image identifier (a string). + """ + return str(self.__photo) + + +def getimage(photo: PhotoImage) -> Image.Image: + """Copies the contents of a PhotoImage to a PIL image memory.""" + im = Image.new("RGBA", (photo.width(), photo.height())) + + _pyimagingtkcall("PyImagingPhotoGet", photo, im.getim()) + + return im diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/ImageTransform.py b/presentation/.venv/lib/python3.12/site-packages/PIL/ImageTransform.py new file mode 100644 index 0000000..fb144ff --- /dev/null +++ b/presentation/.venv/lib/python3.12/site-packages/PIL/ImageTransform.py @@ -0,0 +1,136 @@ +# +# The Python Imaging Library. +# $Id$ +# +# transform wrappers +# +# History: +# 2002-04-08 fl Created +# +# Copyright (c) 2002 by Secret Labs AB +# Copyright (c) 2002 by Fredrik Lundh +# +# See the README file for information on usage and redistribution. +# +from __future__ import annotations + +from collections.abc import Sequence +from typing import Any + +from . import Image + + +class Transform(Image.ImageTransformHandler): + """Base class for other transforms defined in :py:mod:`~PIL.ImageTransform`.""" + + method: Image.Transform + + def __init__(self, data: Sequence[Any]) -> None: + self.data = data + + def getdata(self) -> tuple[Image.Transform, Sequence[int]]: + return self.method, self.data + + def transform( + self, + size: tuple[int, int], + image: Image.Image, + **options: Any, + ) -> Image.Image: + """Perform the transform. Called from :py:meth:`.Image.transform`.""" + # can be overridden + method, data = self.getdata() + return image.transform(size, method, data, **options) + + +class AffineTransform(Transform): + """ + Define an affine image transform. + + This function takes a 6-tuple (a, b, c, d, e, f) which contain the first + two rows from the inverse of an affine transform matrix. For each pixel + (x, y) in the output image, the new value is taken from a position (a x + + b y + c, d x + e y + f) in the input image, rounded to nearest pixel. + + This function can be used to scale, translate, rotate, and shear the + original image. + + See :py:meth:`.Image.transform` + + :param matrix: A 6-tuple (a, b, c, d, e, f) containing the first two rows + from the inverse of an affine transform matrix. + """ + + method = Image.Transform.AFFINE + + +class PerspectiveTransform(Transform): + """ + Define a perspective image transform. + + This function takes an 8-tuple (a, b, c, d, e, f, g, h). For each pixel + (x, y) in the output image, the new value is taken from a position + ((a x + b y + c) / (g x + h y + 1), (d x + e y + f) / (g x + h y + 1)) in + the input image, rounded to nearest pixel. + + This function can be used to scale, translate, rotate, and shear the + original image. + + See :py:meth:`.Image.transform` + + :param matrix: An 8-tuple (a, b, c, d, e, f, g, h). + """ + + method = Image.Transform.PERSPECTIVE + + +class ExtentTransform(Transform): + """ + Define a transform to extract a subregion from an image. + + Maps a rectangle (defined by two corners) from the image to a rectangle of + the given size. The resulting image will contain data sampled from between + the corners, such that (x0, y0) in the input image will end up at (0,0) in + the output image, and (x1, y1) at size. + + This method can be used to crop, stretch, shrink, or mirror an arbitrary + rectangle in the current image. It is slightly slower than crop, but about + as fast as a corresponding resize operation. + + See :py:meth:`.Image.transform` + + :param bbox: A 4-tuple (x0, y0, x1, y1) which specifies two points in the + input image's coordinate system. See :ref:`coordinate-system`. + """ + + method = Image.Transform.EXTENT + + +class QuadTransform(Transform): + """ + Define a quad image transform. + + Maps a quadrilateral (a region defined by four corners) from the image to a + rectangle of the given size. + + See :py:meth:`.Image.transform` + + :param xy: An 8-tuple (x0, y0, x1, y1, x2, y2, x3, y3) which contain the + upper left, lower left, lower right, and upper right corner of the + source quadrilateral. + """ + + method = Image.Transform.QUAD + + +class MeshTransform(Transform): + """ + Define a mesh image transform. A mesh transform consists of one or more + individual quad transforms. + + See :py:meth:`.Image.transform` + + :param data: A list of (bbox, quad) tuples. + """ + + method = Image.Transform.MESH diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/ImageWin.py b/presentation/.venv/lib/python3.12/site-packages/PIL/ImageWin.py new file mode 100644 index 0000000..98c28f2 --- /dev/null +++ b/presentation/.venv/lib/python3.12/site-packages/PIL/ImageWin.py @@ -0,0 +1,247 @@ +# +# The Python Imaging Library. +# $Id$ +# +# a Windows DIB display interface +# +# History: +# 1996-05-20 fl Created +# 1996-09-20 fl Fixed subregion exposure +# 1997-09-21 fl Added draw primitive (for tzPrint) +# 2003-05-21 fl Added experimental Window/ImageWindow classes +# 2003-09-05 fl Added fromstring/tostring methods +# +# Copyright (c) Secret Labs AB 1997-2003. +# Copyright (c) Fredrik Lundh 1996-2003. +# +# See the README file for information on usage and redistribution. +# +from __future__ import annotations + +from . import Image + + +class HDC: + """ + Wraps an HDC integer. The resulting object can be passed to the + :py:meth:`~PIL.ImageWin.Dib.draw` and :py:meth:`~PIL.ImageWin.Dib.expose` + methods. + """ + + def __init__(self, dc: int) -> None: + self.dc = dc + + def __int__(self) -> int: + return self.dc + + +class HWND: + """ + Wraps an HWND integer. The resulting object can be passed to the + :py:meth:`~PIL.ImageWin.Dib.draw` and :py:meth:`~PIL.ImageWin.Dib.expose` + methods, instead of a DC. + """ + + def __init__(self, wnd: int) -> None: + self.wnd = wnd + + def __int__(self) -> int: + return self.wnd + + +class Dib: + """ + A Windows bitmap with the given mode and size. The mode can be one of "1", + "L", "P", or "RGB". + + If the display requires a palette, this constructor creates a suitable + palette and associates it with the image. For an "L" image, 128 graylevels + are allocated. For an "RGB" image, a 6x6x6 colour cube is used, together + with 20 graylevels. + + To make sure that palettes work properly under Windows, you must call the + ``palette`` method upon certain events from Windows. + + :param image: Either a PIL image, or a mode string. If a mode string is + used, a size must also be given. The mode can be one of "1", + "L", "P", or "RGB". + :param size: If the first argument is a mode string, this + defines the size of the image. + """ + + def __init__( + self, image: Image.Image | str, size: tuple[int, int] | None = None + ) -> None: + if isinstance(image, str): + mode = image + image = "" + if size is None: + msg = "If first argument is mode, size is required" + raise ValueError(msg) + else: + mode = image.mode + size = image.size + if mode not in ["1", "L", "P", "RGB"]: + mode = Image.getmodebase(mode) + self.image = Image.core.display(mode, size) + self.mode = mode + self.size = size + if image: + assert not isinstance(image, str) + self.paste(image) + + def expose(self, handle: int | HDC | HWND) -> None: + """ + Copy the bitmap contents to a device context. + + :param handle: Device context (HDC), cast to a Python integer, or an + HDC or HWND instance. In PythonWin, you can use + ``CDC.GetHandleAttrib()`` to get a suitable handle. + """ + handle_int = int(handle) + if isinstance(handle, HWND): + dc = self.image.getdc(handle_int) + try: + self.image.expose(dc) + finally: + self.image.releasedc(handle_int, dc) + else: + self.image.expose(handle_int) + + def draw( + self, + handle: int | HDC | HWND, + dst: tuple[int, int, int, int], + src: tuple[int, int, int, int] | None = None, + ) -> None: + """ + Same as expose, but allows you to specify where to draw the image, and + what part of it to draw. + + The destination and source areas are given as 4-tuple rectangles. If + the source is omitted, the entire image is copied. If the source and + the destination have different sizes, the image is resized as + necessary. + """ + if src is None: + src = (0, 0) + self.size + handle_int = int(handle) + if isinstance(handle, HWND): + dc = self.image.getdc(handle_int) + try: + self.image.draw(dc, dst, src) + finally: + self.image.releasedc(handle_int, dc) + else: + self.image.draw(handle_int, dst, src) + + def query_palette(self, handle: int | HDC | HWND) -> int: + """ + Installs the palette associated with the image in the given device + context. + + This method should be called upon **QUERYNEWPALETTE** and + **PALETTECHANGED** events from Windows. If this method returns a + non-zero value, one or more display palette entries were changed, and + the image should be redrawn. + + :param handle: Device context (HDC), cast to a Python integer, or an + HDC or HWND instance. + :return: The number of entries that were changed (if one or more entries, + this indicates that the image should be redrawn). + """ + handle_int = int(handle) + if isinstance(handle, HWND): + handle = self.image.getdc(handle_int) + try: + result = self.image.query_palette(handle) + finally: + self.image.releasedc(handle, handle) + else: + result = self.image.query_palette(handle_int) + return result + + def paste( + self, im: Image.Image, box: tuple[int, int, int, int] | None = None + ) -> None: + """ + Paste a PIL image into the bitmap image. + + :param im: A PIL image. The size must match the target region. + If the mode does not match, the image is converted to the + mode of the bitmap image. + :param box: A 4-tuple defining the left, upper, right, and + lower pixel coordinate. See :ref:`coordinate-system`. If + None is given instead of a tuple, all of the image is + assumed. + """ + im.load() + if self.mode != im.mode: + im = im.convert(self.mode) + if box: + self.image.paste(im.im, box) + else: + self.image.paste(im.im) + + def frombytes(self, buffer: bytes) -> None: + """ + Load display memory contents from byte data. + + :param buffer: A buffer containing display data (usually + data returned from :py:func:`~PIL.ImageWin.Dib.tobytes`) + """ + self.image.frombytes(buffer) + + def tobytes(self) -> bytes: + """ + Copy display memory contents to bytes object. + + :return: A bytes object containing display data. + """ + return self.image.tobytes() + + +class Window: + """Create a Window with the given title size.""" + + def __init__( + self, title: str = "PIL", width: int | None = None, height: int | None = None + ) -> None: + self.hwnd = Image.core.createwindow( + title, self.__dispatcher, width or 0, height or 0 + ) + + def __dispatcher(self, action: str, *args: int) -> None: + getattr(self, f"ui_handle_{action}")(*args) + + def ui_handle_clear(self, dc: int, x0: int, y0: int, x1: int, y1: int) -> None: + pass + + def ui_handle_damage(self, x0: int, y0: int, x1: int, y1: int) -> None: + pass + + def ui_handle_destroy(self) -> None: + pass + + def ui_handle_repair(self, dc: int, x0: int, y0: int, x1: int, y1: int) -> None: + pass + + def ui_handle_resize(self, width: int, height: int) -> None: + pass + + def mainloop(self) -> None: + Image.core.eventloop() + + +class ImageWindow(Window): + """Create an image window which displays the given image.""" + + def __init__(self, image: Image.Image | Dib, title: str = "PIL") -> None: + if not isinstance(image, Dib): + image = Dib(image) + self.image = image + width, height = image.size + super().__init__(title, width=width, height=height) + + def ui_handle_repair(self, dc: int, x0: int, y0: int, x1: int, y1: int) -> None: + self.image.draw(dc, (x0, y0, x1, y1)) diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/ImtImagePlugin.py b/presentation/.venv/lib/python3.12/site-packages/PIL/ImtImagePlugin.py new file mode 100644 index 0000000..c4eccee --- /dev/null +++ b/presentation/.venv/lib/python3.12/site-packages/PIL/ImtImagePlugin.py @@ -0,0 +1,103 @@ +# +# The Python Imaging Library. +# $Id$ +# +# IM Tools support for PIL +# +# history: +# 1996-05-27 fl Created (read 8-bit images only) +# 2001-02-17 fl Use 're' instead of 'regex' (Python 2.1) (0.2) +# +# Copyright (c) Secret Labs AB 1997-2001. +# Copyright (c) Fredrik Lundh 1996-2001. +# +# See the README file for information on usage and redistribution. +# +from __future__ import annotations + +import re + +from . import Image, ImageFile + +# +# -------------------------------------------------------------------- + +field = re.compile(rb"([a-z]*) ([^ \r\n]*)") + + +## +# Image plugin for IM Tools images. + + +class ImtImageFile(ImageFile.ImageFile): + format = "IMT" + format_description = "IM Tools" + + def _open(self) -> None: + # Quick rejection: if there's not a LF among the first + # 100 bytes, this is (probably) not a text header. + + assert self.fp is not None + + buffer = self.fp.read(100) + if b"\n" not in buffer: + msg = "not an IM file" + raise SyntaxError(msg) + + xsize = ysize = 0 + + while True: + if buffer: + s = buffer[:1] + buffer = buffer[1:] + else: + s = self.fp.read(1) + if not s: + break + + if s == b"\x0c": + # image data begins + self.tile = [ + ImageFile._Tile( + "raw", + (0, 0) + self.size, + self.fp.tell() - len(buffer), + self.mode, + ) + ] + + break + + else: + # read key/value pair + if b"\n" not in buffer: + buffer += self.fp.read(100) + lines = buffer.split(b"\n") + s += lines.pop(0) + buffer = b"\n".join(lines) + if len(s) == 1 or len(s) > 100: + break + if s[0] == ord(b"*"): + continue # comment + + m = field.match(s) + if not m: + break + k, v = m.group(1, 2) + if k == b"width": + xsize = int(v) + self._size = xsize, ysize + elif k == b"height": + ysize = int(v) + self._size = xsize, ysize + elif k == b"pixel" and v == b"n8": + self._mode = "L" + + +# +# -------------------------------------------------------------------- + +Image.register_open(ImtImageFile.format, ImtImageFile) + +# +# no extension registered (".im" is simply too common) diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/IptcImagePlugin.py b/presentation/.venv/lib/python3.12/site-packages/PIL/IptcImagePlugin.py new file mode 100644 index 0000000..9c8be8b --- /dev/null +++ b/presentation/.venv/lib/python3.12/site-packages/PIL/IptcImagePlugin.py @@ -0,0 +1,226 @@ +# +# The Python Imaging Library. +# $Id$ +# +# IPTC/NAA file handling +# +# history: +# 1995-10-01 fl Created +# 1998-03-09 fl Cleaned up and added to PIL +# 2002-06-18 fl Added getiptcinfo helper +# +# Copyright (c) Secret Labs AB 1997-2002. +# Copyright (c) Fredrik Lundh 1995. +# +# See the README file for information on usage and redistribution. +# +from __future__ import annotations + +from io import BytesIO +from typing import cast + +from . import Image, ImageFile +from ._binary import i16be as i16 +from ._binary import i32be as i32 + +COMPRESSION = {1: "raw", 5: "jpeg"} + + +# +# Helpers + + +def _i(c: bytes) -> int: + return i32((b"\0\0\0\0" + c)[-4:]) + + +## +# Image plugin for IPTC/NAA datastreams. To read IPTC/NAA fields +# from TIFF and JPEG files, use the <b>getiptcinfo</b> function. + + +class IptcImageFile(ImageFile.ImageFile): + format = "IPTC" + format_description = "IPTC/NAA" + + def getint(self, key: tuple[int, int]) -> int: + return _i(self.info[key]) + + def field(self) -> tuple[tuple[int, int] | None, int]: + # + # get a IPTC field header + assert self.fp is not None + s = self.fp.read(5) + if not s.strip(b"\x00"): + return None, 0 + + tag = s[1], s[2] + + # syntax + if s[0] != 0x1C or tag[0] not in [1, 2, 3, 4, 5, 6, 7, 8, 9, 240]: + msg = "invalid IPTC/NAA file" + raise SyntaxError(msg) + + # field size + size = s[3] + if size > 132: + msg = "illegal field length in IPTC/NAA file" + raise OSError(msg) + elif size == 128: + size = 0 + elif size > 128: + size = _i(self.fp.read(size - 128)) + else: + size = i16(s, 3) + + return tag, size + + def _open(self) -> None: + # load descriptive fields + assert self.fp is not None + while True: + offset = self.fp.tell() + tag, size = self.field() + if not tag or tag == (8, 10): + break + if size: + tagdata = self.fp.read(size) + else: + tagdata = None + if tag in self.info: + if isinstance(self.info[tag], list): + self.info[tag].append(tagdata) + else: + self.info[tag] = [self.info[tag], tagdata] + else: + self.info[tag] = tagdata + + # mode + layers = self.info[(3, 60)][0] + component = self.info[(3, 60)][1] + if layers == 1 and not component: + self._mode = "L" + band = None + else: + if layers == 3 and component: + self._mode = "RGB" + elif layers == 4 and component: + self._mode = "CMYK" + if (3, 65) in self.info: + band = self.info[(3, 65)][0] - 1 + else: + band = 0 + + # size + self._size = self.getint((3, 20)), self.getint((3, 30)) + + # compression + try: + compression = COMPRESSION[self.getint((3, 120))] + except KeyError as e: + msg = "Unknown IPTC image compression" + raise OSError(msg) from e + + # tile + if tag == (8, 10): + self.tile = [ + ImageFile._Tile("iptc", (0, 0) + self.size, offset, (compression, band)) + ] + + def load(self) -> Image.core.PixelAccess | None: + if self.tile: + args = self.tile[0].args + assert isinstance(args, tuple) + compression, band = args + + assert self.fp is not None + self.fp.seek(self.tile[0].offset) + + # Copy image data to temporary file + o = BytesIO() + if compression == "raw": + # To simplify access to the extracted file, + # prepend a PPM header + o.write(b"P5\n%d %d\n255\n" % self.size) + while True: + type, size = self.field() + if type != (8, 10): + break + while size > 0: + s = self.fp.read(min(size, 8192)) + if not s: + break + o.write(s) + size -= len(s) + + with Image.open(o) as _im: + if band is not None: + bands = [Image.new("L", _im.size)] * Image.getmodebands(self.mode) + bands[band] = _im + im = Image.merge(self.mode, bands) + else: + im = _im + im.load() + self.im = im.im + self.tile = [] + return ImageFile.ImageFile.load(self) + + +Image.register_open(IptcImageFile.format, IptcImageFile) + +Image.register_extension(IptcImageFile.format, ".iim") + + +def getiptcinfo( + im: ImageFile.ImageFile, +) -> dict[tuple[int, int], bytes | list[bytes]] | None: + """ + Get IPTC information from TIFF, JPEG, or IPTC file. + + :param im: An image containing IPTC data. + :returns: A dictionary containing IPTC information, or None if + no IPTC information block was found. + """ + from . import JpegImagePlugin, TiffImagePlugin + + data = None + + if isinstance(im, IptcImageFile): + # return info dictionary right away + return {k: v for k, v in im.info.items() if isinstance(k, tuple)} + + elif isinstance(im, JpegImagePlugin.JpegImageFile): + # extract the IPTC/NAA resource + photoshop = im.info.get("photoshop") + if photoshop: + data = photoshop.get(0x0404) + + elif isinstance(im, TiffImagePlugin.TiffImageFile): + # get raw data from the IPTC/NAA tag (PhotoShop tags the data + # as 4-byte integers, so we cannot use the get method...) + try: + data = im.tag_v2._tagdata[TiffImagePlugin.IPTC_NAA_CHUNK] + except KeyError: + pass + + if data is None: + return None # no properties + + # create an IptcImagePlugin object without initializing it + class FakeImage: + pass + + fake_im = FakeImage() + fake_im.__class__ = IptcImageFile # type: ignore[assignment] + iptc_im = cast(IptcImageFile, fake_im) + + # parse the IPTC information chunk + iptc_im.info = {} + iptc_im.fp = BytesIO(data) + + try: + iptc_im._open() + except (IndexError, KeyError): + pass # expected failure + + return {k: v for k, v in iptc_im.info.items() if isinstance(k, tuple)} diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/Jpeg2KImagePlugin.py b/presentation/.venv/lib/python3.12/site-packages/PIL/Jpeg2KImagePlugin.py new file mode 100644 index 0000000..cb37735 --- /dev/null +++ b/presentation/.venv/lib/python3.12/site-packages/PIL/Jpeg2KImagePlugin.py @@ -0,0 +1,460 @@ +# +# The Python Imaging Library +# $Id$ +# +# JPEG2000 file handling +# +# History: +# 2014-03-12 ajh Created +# 2021-06-30 rogermb Extract dpi information from the 'resc' header box +# +# Copyright (c) 2014 Coriolis Systems Limited +# Copyright (c) 2014 Alastair Houghton +# +# See the README file for information on usage and redistribution. +# +from __future__ import annotations + +import io +import os +import struct +from typing import cast + +from . import Image, ImageFile, ImagePalette, _binary + +TYPE_CHECKING = False +if TYPE_CHECKING: + from collections.abc import Callable + from typing import IO + + +class BoxReader: + """ + A small helper class to read fields stored in JPEG2000 header boxes + and to easily step into and read sub-boxes. + """ + + def __init__(self, fp: IO[bytes], length: int = -1) -> None: + self.fp = fp + self.has_length = length >= 0 + self.length = length + self.remaining_in_box = -1 + + def _can_read(self, num_bytes: int) -> bool: + if self.has_length and self.fp.tell() + num_bytes > self.length: + # Outside box: ensure we don't read past the known file length + return False + if self.remaining_in_box >= 0: + # Inside box contents: ensure read does not go past box boundaries + return num_bytes <= self.remaining_in_box + else: + return True # No length known, just read + + def _read_bytes(self, num_bytes: int) -> bytes: + if not self._can_read(num_bytes): + msg = "Not enough data in header" + raise SyntaxError(msg) + + data = self.fp.read(num_bytes) + if len(data) < num_bytes: + msg = f"Expected to read {num_bytes} bytes but only got {len(data)}." + raise OSError(msg) + + if self.remaining_in_box > 0: + self.remaining_in_box -= num_bytes + return data + + def read_fields(self, field_format: str) -> tuple[int | bytes, ...]: + size = struct.calcsize(field_format) + data = self._read_bytes(size) + return struct.unpack(field_format, data) + + def read_boxes(self) -> BoxReader: + size = self.remaining_in_box + data = self._read_bytes(size) + return BoxReader(io.BytesIO(data), size) + + def has_next_box(self) -> bool: + if self.has_length: + return self.fp.tell() + self.remaining_in_box < self.length + else: + return True + + def next_box_type(self) -> bytes: + # Skip the rest of the box if it has not been read + if self.remaining_in_box > 0: + self.fp.seek(self.remaining_in_box, os.SEEK_CUR) + self.remaining_in_box = -1 + + # Read the length and type of the next box + lbox, tbox = cast(tuple[int, bytes], self.read_fields(">I4s")) + if lbox == 1: + lbox = cast(int, self.read_fields(">Q")[0]) + hlen = 16 + else: + hlen = 8 + + if lbox < hlen or not self._can_read(lbox - hlen): + msg = "Invalid header length" + raise SyntaxError(msg) + + self.remaining_in_box = lbox - hlen + return tbox + + +def _parse_codestream(fp: IO[bytes]) -> tuple[tuple[int, int], str]: + """Parse the JPEG 2000 codestream to extract the size and component + count from the SIZ marker segment, returning a PIL (size, mode) tuple.""" + + hdr = fp.read(2) + lsiz = _binary.i16be(hdr) + siz = hdr + fp.read(lsiz - 2) + lsiz, rsiz, xsiz, ysiz, xosiz, yosiz, _, _, _, _, csiz = struct.unpack_from( + ">HHIIIIIIIIH", siz + ) + + size = (xsiz - xosiz, ysiz - yosiz) + if csiz == 1: + ssiz = struct.unpack_from(">B", siz, 38) + if (ssiz[0] & 0x7F) + 1 > 8: + mode = "I;16" + else: + mode = "L" + elif csiz == 2: + mode = "LA" + elif csiz == 3: + mode = "RGB" + elif csiz == 4: + mode = "RGBA" + else: + msg = "unable to determine J2K image mode" + raise SyntaxError(msg) + + return size, mode + + +def _res_to_dpi(num: int, denom: int, exp: int) -> float | None: + """Convert JPEG2000's (numerator, denominator, exponent-base-10) resolution, + calculated as (num / denom) * 10^exp and stored in dots per meter, + to floating-point dots per inch.""" + if denom == 0: + return None + return (254 * num * (10**exp)) / (10000 * denom) + + +def _parse_jp2_header( + fp: IO[bytes], +) -> tuple[ + tuple[int, int], + str, + str | None, + tuple[float, float] | None, + ImagePalette.ImagePalette | None, +]: + """Parse the JP2 header box to extract size, component count, + color space information, and optionally DPI information, + returning a (size, mode, mimetype, dpi) tuple.""" + + # Find the JP2 header box + reader = BoxReader(fp) + header = None + mimetype = None + while reader.has_next_box(): + tbox = reader.next_box_type() + + if tbox == b"jp2h": + header = reader.read_boxes() + break + elif tbox == b"ftyp": + if reader.read_fields(">4s")[0] == b"jpx ": + mimetype = "image/jpx" + assert header is not None + + size = None + mode = None + bpc = None + nc = None + dpi = None # 2-tuple of DPI info, or None + palette = None + colr = None + + while header.has_next_box(): + tbox = header.next_box_type() + + if tbox == b"ihdr": + height, width, nc, bpc = header.read_fields(">IIHB") + assert isinstance(height, int) + assert isinstance(width, int) + assert isinstance(bpc, int) + size = (width, height) + if nc == 1 and (bpc & 0x7F) > 8: + mode = "I;16" + elif nc == 1: + mode = "L" + elif nc == 2: + mode = "LA" + elif nc == 3: + mode = "RGB" + elif nc == 4: + mode = "RGBA" + elif tbox == b"colr": + meth, _, _, enumcs = header.read_fields(">BBBI") + if meth == 1: + if enumcs in (0, 15): + colr = "1" + elif enumcs == 12: + colr = "CMYK" + if nc == 4: + mode = "CMYK" + elif enumcs == 17: + colr = "L" + elif tbox == b"pclr" and mode in ("L", "LA") and colr not in ("1", "L"): + ne, npc = header.read_fields(">HB") + assert isinstance(ne, int) + assert isinstance(npc, int) + max_bitdepth = 0 + for bitdepth in header.read_fields(">" + ("B" * npc)): + assert isinstance(bitdepth, int) + if bitdepth > max_bitdepth: + max_bitdepth = bitdepth + if max_bitdepth <= 8: + if npc == 4: + palette_mode = "CMYK" if colr == "CMYK" else "RGBA" + else: + palette_mode = "RGB" + palette = ImagePalette.ImagePalette(palette_mode) + for i in range(ne): + color: list[int] = [] + for value in header.read_fields(">" + ("B" * npc)): + assert isinstance(value, int) + color.append(value) + palette.getcolor(tuple(color)) + mode = "P" if mode == "L" else "PA" + elif tbox == b"res ": + res = header.read_boxes() + while res.has_next_box(): + tres = res.next_box_type() + if tres == b"resc": + vrcn, vrcd, hrcn, hrcd, vrce, hrce = res.read_fields(">HHHHBB") + assert isinstance(vrcn, int) + assert isinstance(vrcd, int) + assert isinstance(hrcn, int) + assert isinstance(hrcd, int) + assert isinstance(vrce, int) + assert isinstance(hrce, int) + hres = _res_to_dpi(hrcn, hrcd, hrce) + vres = _res_to_dpi(vrcn, vrcd, vrce) + if hres is not None and vres is not None: + dpi = (hres, vres) + break + + if size is None or mode is None: + msg = "Malformed JP2 header" + raise SyntaxError(msg) + + return size, mode, mimetype, dpi, palette + + +## +# Image plugin for JPEG2000 images. + + +class Jpeg2KImageFile(ImageFile.ImageFile): + format = "JPEG2000" + format_description = "JPEG 2000 (ISO 15444)" + + def _open(self) -> None: + assert self.fp is not None + sig = self.fp.read(4) + if sig == b"\xff\x4f\xff\x51": + self.codec = "j2k" + self._size, self._mode = _parse_codestream(self.fp) + self._parse_comment() + else: + sig = sig + self.fp.read(8) + + if sig == b"\x00\x00\x00\x0cjP \x0d\x0a\x87\x0a": + self.codec = "jp2" + header = _parse_jp2_header(self.fp) + self._size, self._mode, self.custom_mimetype, dpi, self.palette = header + if dpi is not None: + self.info["dpi"] = dpi + if self.fp.read(12).endswith(b"jp2c\xff\x4f\xff\x51"): + hdr = self.fp.read(2) + length = _binary.i16be(hdr) + self.fp.seek(length - 2, os.SEEK_CUR) + self._parse_comment() + else: + msg = "not a JPEG 2000 file" + raise SyntaxError(msg) + + self._reduce = 0 + self.layers = 0 + + fd = -1 + length = -1 + + try: + fd = self.fp.fileno() + length = os.fstat(fd).st_size + except Exception: + fd = -1 + try: + pos = self.fp.tell() + self.fp.seek(0, io.SEEK_END) + length = self.fp.tell() + self.fp.seek(pos) + except Exception: + length = -1 + + self.tile = [ + ImageFile._Tile( + "jpeg2k", + (0, 0) + self.size, + 0, + (self.codec, self._reduce, self.layers, fd, length), + ) + ] + + def _parse_comment(self) -> None: + assert self.fp is not None + while True: + marker = self.fp.read(2) + if not marker: + break + typ = marker[1] + if typ in (0x90, 0xD9): + # Start of tile or end of codestream + break + hdr = self.fp.read(2) + length = _binary.i16be(hdr) + if typ == 0x64: + # Comment + self.info["comment"] = self.fp.read(length - 2)[2:] + break + else: + self.fp.seek(length - 2, os.SEEK_CUR) + + @property # type: ignore[override] + def reduce( + self, + ) -> ( + Callable[[int | tuple[int, int], tuple[int, int, int, int] | None], Image.Image] + | int + ): + # https://github.com/python-pillow/Pillow/issues/4343 found that the + # new Image 'reduce' method was shadowed by this plugin's 'reduce' + # property. This attempts to allow for both scenarios + return self._reduce or super().reduce + + @reduce.setter + def reduce(self, value: int) -> None: + self._reduce = value + + def load(self) -> Image.core.PixelAccess | None: + if self.tile and self._reduce: + power = 1 << self._reduce + adjust = power >> 1 + self._size = ( + int((self.size[0] + adjust) / power), + int((self.size[1] + adjust) / power), + ) + + # Update the reduce and layers settings + t = self.tile[0] + assert isinstance(t[3], tuple) + t3 = (t[3][0], self._reduce, self.layers, t[3][3], t[3][4]) + self.tile = [ImageFile._Tile(t[0], (0, 0) + self.size, t[2], t3)] + + return ImageFile.ImageFile.load(self) + + +def _accept(prefix: bytes) -> bool: + return prefix.startswith( + (b"\xff\x4f\xff\x51", b"\x00\x00\x00\x0cjP \x0d\x0a\x87\x0a") + ) + + +# ------------------------------------------------------------ +# Save support + + +def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: + # Get the keyword arguments + info = im.encoderinfo + + if isinstance(filename, str): + filename = filename.encode() + if filename.endswith(b".j2k") or info.get("no_jp2", False): + kind = "j2k" + else: + kind = "jp2" + + offset = info.get("offset", None) + tile_offset = info.get("tile_offset", None) + tile_size = info.get("tile_size", None) + quality_mode = info.get("quality_mode", "rates") + quality_layers = info.get("quality_layers", None) + if quality_layers is not None and not ( + isinstance(quality_layers, (list, tuple)) + and all( + isinstance(quality_layer, (int, float)) for quality_layer in quality_layers + ) + ): + msg = "quality_layers must be a sequence of numbers" + raise ValueError(msg) + + num_resolutions = info.get("num_resolutions", 0) + cblk_size = info.get("codeblock_size", None) + precinct_size = info.get("precinct_size", None) + irreversible = info.get("irreversible", False) + progression = info.get("progression", "LRCP") + cinema_mode = info.get("cinema_mode", "no") + mct = info.get("mct", 0) + signed = info.get("signed", False) + comment = info.get("comment") + if isinstance(comment, str): + comment = comment.encode() + plt = info.get("plt", False) + + fd = -1 + if hasattr(fp, "fileno"): + try: + fd = fp.fileno() + except Exception: + fd = -1 + + im.encoderconfig = ( + offset, + tile_offset, + tile_size, + quality_mode, + quality_layers, + num_resolutions, + cblk_size, + precinct_size, + irreversible, + progression, + cinema_mode, + mct, + signed, + fd, + comment, + plt, + ) + + ImageFile._save(im, fp, [ImageFile._Tile("jpeg2k", (0, 0) + im.size, 0, kind)]) + + +# ------------------------------------------------------------ +# Registry stuff + + +Image.register_open(Jpeg2KImageFile.format, Jpeg2KImageFile, _accept) +Image.register_save(Jpeg2KImageFile.format, _save) + +Image.register_extensions( + Jpeg2KImageFile.format, [".jp2", ".j2k", ".jpc", ".jpf", ".jpx", ".j2c"] +) + +Image.register_mime(Jpeg2KImageFile.format, "image/jp2") diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/JpegImagePlugin.py b/presentation/.venv/lib/python3.12/site-packages/PIL/JpegImagePlugin.py new file mode 100644 index 0000000..46320eb --- /dev/null +++ b/presentation/.venv/lib/python3.12/site-packages/PIL/JpegImagePlugin.py @@ -0,0 +1,889 @@ +# +# The Python Imaging Library. +# $Id$ +# +# JPEG (JFIF) file handling +# +# See "Digital Compression and Coding of Continuous-Tone Still Images, +# Part 1, Requirements and Guidelines" (CCITT T.81 / ISO 10918-1) +# +# History: +# 1995-09-09 fl Created +# 1995-09-13 fl Added full parser +# 1996-03-25 fl Added hack to use the IJG command line utilities +# 1996-05-05 fl Workaround Photoshop 2.5 CMYK polarity bug +# 1996-05-28 fl Added draft support, JFIF version (0.1) +# 1996-12-30 fl Added encoder options, added progression property (0.2) +# 1997-08-27 fl Save mode 1 images as BW (0.3) +# 1998-07-12 fl Added YCbCr to draft and save methods (0.4) +# 1998-10-19 fl Don't hang on files using 16-bit DQT's (0.4.1) +# 2001-04-16 fl Extract DPI settings from JFIF files (0.4.2) +# 2002-07-01 fl Skip pad bytes before markers; identify Exif files (0.4.3) +# 2003-04-25 fl Added experimental EXIF decoder (0.5) +# 2003-06-06 fl Added experimental EXIF GPSinfo decoder +# 2003-09-13 fl Extract COM markers +# 2009-09-06 fl Added icc_profile support (from Florian Hoech) +# 2009-03-06 fl Changed CMYK handling; always use Adobe polarity (0.6) +# 2009-03-08 fl Added subsampling support (from Justin Huff). +# +# Copyright (c) 1997-2003 by Secret Labs AB. +# Copyright (c) 1995-1996 by Fredrik Lundh. +# +# See the README file for information on usage and redistribution. +# +from __future__ import annotations + +import array +import io +import math +import os +import struct +import subprocess +import sys +import tempfile +import warnings + +from . import Image, ImageFile +from ._binary import i16be as i16 +from ._binary import i32be as i32 +from ._binary import o8 +from ._binary import o16be as o16 +from .JpegPresets import presets + +TYPE_CHECKING = False +if TYPE_CHECKING: + from typing import IO, Any + + from .MpoImagePlugin import MpoImageFile + +# +# Parser + + +def Skip(self: JpegImageFile, marker: int) -> None: + assert self.fp is not None + n = i16(self.fp.read(2)) - 2 + ImageFile._safe_read(self.fp, n) + + +def APP(self: JpegImageFile, marker: int) -> None: + # + # Application marker. Store these in the APP dictionary. + # Also look for well-known application markers. + + assert self.fp is not None + n = i16(self.fp.read(2)) - 2 + s = ImageFile._safe_read(self.fp, n) + + app = f"APP{marker & 15}" + + self.app[app] = s # compatibility + self.applist.append((app, s)) + + if marker == 0xFFE0 and s.startswith(b"JFIF"): + # extract JFIF information + self.info["jfif"] = version = i16(s, 5) # version + self.info["jfif_version"] = divmod(version, 256) + # extract JFIF properties + try: + jfif_unit = s[7] + jfif_density = i16(s, 8), i16(s, 10) + except Exception: + pass + else: + if jfif_unit == 1: + self.info["dpi"] = jfif_density + elif jfif_unit == 2: # cm + # 1 dpcm = 2.54 dpi + self.info["dpi"] = tuple(d * 2.54 for d in jfif_density) + self.info["jfif_unit"] = jfif_unit + self.info["jfif_density"] = jfif_density + elif marker == 0xFFE1 and s.startswith(b"Exif\0\0"): + # extract EXIF information + if "exif" in self.info: + self.info["exif"] += s[6:] + else: + self.info["exif"] = s + self._exif_offset = self.fp.tell() - n + 6 + elif marker == 0xFFE1 and s.startswith(b"http://ns.adobe.com/xap/1.0/\x00"): + self.info["xmp"] = s.split(b"\x00", 1)[1] + elif marker == 0xFFE2 and s.startswith(b"FPXR\0"): + # extract FlashPix information (incomplete) + self.info["flashpix"] = s # FIXME: value will change + elif marker == 0xFFE2 and s.startswith(b"ICC_PROFILE\0"): + # Since an ICC profile can be larger than the maximum size of + # a JPEG marker (64K), we need provisions to split it into + # multiple markers. The format defined by the ICC specifies + # one or more APP2 markers containing the following data: + # Identifying string ASCII "ICC_PROFILE\0" (12 bytes) + # Marker sequence number 1, 2, etc (1 byte) + # Number of markers Total of APP2's used (1 byte) + # Profile data (remainder of APP2 data) + # Decoders should use the marker sequence numbers to + # reassemble the profile, rather than assuming that the APP2 + # markers appear in the correct sequence. + self.icclist.append(s) + elif marker == 0xFFED and s.startswith(b"Photoshop 3.0\x00"): + # parse the image resource block + offset = 14 + photoshop = self.info.setdefault("photoshop", {}) + try: + while s[offset : offset + 4] == b"8BIM": + offset += 4 + # resource code + code = i16(s, offset) + offset += 2 + # resource name (usually empty) + name_len = s[offset] + # name = s[offset+1:offset+1+name_len] + offset += 1 + name_len + offset += offset & 1 # align + # resource data block + size = i32(s, offset) + offset += 4 + data = s[offset : offset + size] + if code == 0x03ED: # ResolutionInfo + photoshop[code] = { + "XResolution": i32(data, 0) / 65536, + "DisplayedUnitsX": i16(data, 4), + "YResolution": i32(data, 8) / 65536, + "DisplayedUnitsY": i16(data, 12), + } + else: + photoshop[code] = data + offset += size + offset += offset & 1 # align + except struct.error: + pass # insufficient data + + elif marker == 0xFFEE and s.startswith(b"Adobe"): + self.info["adobe"] = i16(s, 5) + # extract Adobe custom properties + try: + adobe_transform = s[11] + except IndexError: + pass + else: + self.info["adobe_transform"] = adobe_transform + elif marker == 0xFFE2 and s.startswith(b"MPF\0"): + # extract MPO information + self.info["mp"] = s[4:] + # offset is current location minus buffer size + # plus constant header size + self.info["mpoffset"] = self.fp.tell() - n + 4 + + +def COM(self: JpegImageFile, marker: int) -> None: + # + # Comment marker. Store these in the APP dictionary. + assert self.fp is not None + n = i16(self.fp.read(2)) - 2 + s = ImageFile._safe_read(self.fp, n) + + self.info["comment"] = s + self.app["COM"] = s # compatibility + self.applist.append(("COM", s)) + + +def SOF(self: JpegImageFile, marker: int) -> None: + # + # Start of frame marker. Defines the size and mode of the + # image. JPEG is colour blind, so we use some simple + # heuristics to map the number of layers to an appropriate + # mode. Note that this could be made a bit brighter, by + # looking for JFIF and Adobe APP markers. + + assert self.fp is not None + n = i16(self.fp.read(2)) - 2 + s = ImageFile._safe_read(self.fp, n) + self._size = i16(s, 3), i16(s, 1) + if self._im is not None and self.size != self.im.size: + self._im = None + + self.bits = s[0] + if self.bits != 8: + msg = f"cannot handle {self.bits}-bit layers" + raise SyntaxError(msg) + + self.layers = s[5] + if self.layers == 1: + self._mode = "L" + elif self.layers == 3: + self._mode = "RGB" + elif self.layers == 4: + self._mode = "CMYK" + else: + msg = f"cannot handle {self.layers}-layer images" + raise SyntaxError(msg) + + if marker in [0xFFC2, 0xFFC6, 0xFFCA, 0xFFCE]: + self.info["progressive"] = self.info["progression"] = 1 + + if self.icclist: + # fixup icc profile + self.icclist.sort() # sort by sequence number + if self.icclist[0][13] == len(self.icclist): + profile = [p[14:] for p in self.icclist] + icc_profile = b"".join(profile) + else: + icc_profile = None # wrong number of fragments + self.info["icc_profile"] = icc_profile + self.icclist = [] + + for i in range(6, len(s), 3): + t = s[i : i + 3] + # 4-tuples: id, vsamp, hsamp, qtable + self.layer.append((t[0], t[1] // 16, t[1] & 15, t[2])) + + +def DQT(self: JpegImageFile, marker: int) -> None: + # + # Define quantization table. Note that there might be more + # than one table in each marker. + + # FIXME: The quantization tables can be used to estimate the + # compression quality. + + assert self.fp is not None + n = i16(self.fp.read(2)) - 2 + s = ImageFile._safe_read(self.fp, n) + while len(s): + v = s[0] + precision = 1 if (v // 16 == 0) else 2 # in bytes + qt_length = 1 + precision * 64 + if len(s) < qt_length: + msg = "bad quantization table marker" + raise SyntaxError(msg) + data = array.array("B" if precision == 1 else "H", s[1:qt_length]) + if sys.byteorder == "little" and precision > 1: + data.byteswap() # the values are always big-endian + self.quantization[v & 15] = [data[i] for i in zigzag_index] + s = s[qt_length:] + + +# +# JPEG marker table + +MARKER = { + 0xFFC0: ("SOF0", "Baseline DCT", SOF), + 0xFFC1: ("SOF1", "Extended Sequential DCT", SOF), + 0xFFC2: ("SOF2", "Progressive DCT", SOF), + 0xFFC3: ("SOF3", "Spatial lossless", SOF), + 0xFFC4: ("DHT", "Define Huffman table", Skip), + 0xFFC5: ("SOF5", "Differential sequential DCT", SOF), + 0xFFC6: ("SOF6", "Differential progressive DCT", SOF), + 0xFFC7: ("SOF7", "Differential spatial", SOF), + 0xFFC8: ("JPG", "Extension", None), + 0xFFC9: ("SOF9", "Extended sequential DCT (AC)", SOF), + 0xFFCA: ("SOF10", "Progressive DCT (AC)", SOF), + 0xFFCB: ("SOF11", "Spatial lossless DCT (AC)", SOF), + 0xFFCC: ("DAC", "Define arithmetic coding conditioning", Skip), + 0xFFCD: ("SOF13", "Differential sequential DCT (AC)", SOF), + 0xFFCE: ("SOF14", "Differential progressive DCT (AC)", SOF), + 0xFFCF: ("SOF15", "Differential spatial (AC)", SOF), + 0xFFD0: ("RST0", "Restart 0", None), + 0xFFD1: ("RST1", "Restart 1", None), + 0xFFD2: ("RST2", "Restart 2", None), + 0xFFD3: ("RST3", "Restart 3", None), + 0xFFD4: ("RST4", "Restart 4", None), + 0xFFD5: ("RST5", "Restart 5", None), + 0xFFD6: ("RST6", "Restart 6", None), + 0xFFD7: ("RST7", "Restart 7", None), + 0xFFD8: ("SOI", "Start of image", None), + 0xFFD9: ("EOI", "End of image", None), + 0xFFDA: ("SOS", "Start of scan", Skip), + 0xFFDB: ("DQT", "Define quantization table", DQT), + 0xFFDC: ("DNL", "Define number of lines", Skip), + 0xFFDD: ("DRI", "Define restart interval", Skip), + 0xFFDE: ("DHP", "Define hierarchical progression", SOF), + 0xFFDF: ("EXP", "Expand reference component", Skip), + 0xFFE0: ("APP0", "Application segment 0", APP), + 0xFFE1: ("APP1", "Application segment 1", APP), + 0xFFE2: ("APP2", "Application segment 2", APP), + 0xFFE3: ("APP3", "Application segment 3", APP), + 0xFFE4: ("APP4", "Application segment 4", APP), + 0xFFE5: ("APP5", "Application segment 5", APP), + 0xFFE6: ("APP6", "Application segment 6", APP), + 0xFFE7: ("APP7", "Application segment 7", APP), + 0xFFE8: ("APP8", "Application segment 8", APP), + 0xFFE9: ("APP9", "Application segment 9", APP), + 0xFFEA: ("APP10", "Application segment 10", APP), + 0xFFEB: ("APP11", "Application segment 11", APP), + 0xFFEC: ("APP12", "Application segment 12", APP), + 0xFFED: ("APP13", "Application segment 13", APP), + 0xFFEE: ("APP14", "Application segment 14", APP), + 0xFFEF: ("APP15", "Application segment 15", APP), + 0xFFF0: ("JPG0", "Extension 0", None), + 0xFFF1: ("JPG1", "Extension 1", None), + 0xFFF2: ("JPG2", "Extension 2", None), + 0xFFF3: ("JPG3", "Extension 3", None), + 0xFFF4: ("JPG4", "Extension 4", None), + 0xFFF5: ("JPG5", "Extension 5", None), + 0xFFF6: ("JPG6", "Extension 6", None), + 0xFFF7: ("JPG7", "Extension 7", None), + 0xFFF8: ("JPG8", "Extension 8", None), + 0xFFF9: ("JPG9", "Extension 9", None), + 0xFFFA: ("JPG10", "Extension 10", None), + 0xFFFB: ("JPG11", "Extension 11", None), + 0xFFFC: ("JPG12", "Extension 12", None), + 0xFFFD: ("JPG13", "Extension 13", None), + 0xFFFE: ("COM", "Comment", COM), +} + + +def _accept(prefix: bytes) -> bool: + # Magic number was taken from https://en.wikipedia.org/wiki/JPEG + return prefix.startswith(b"\xff\xd8\xff") + + +## +# Image plugin for JPEG and JFIF images. + + +class JpegImageFile(ImageFile.ImageFile): + format = "JPEG" + format_description = "JPEG (ISO 10918)" + + def _open(self) -> None: + assert self.fp is not None + s = self.fp.read(3) + + if not _accept(s): + msg = "not a JPEG file" + raise SyntaxError(msg) + s = b"\xff" + + # Create attributes + self.bits = self.layers = 0 + self._exif_offset = 0 + + # JPEG specifics (internal) + self.layer: list[tuple[int, int, int, int]] = [] + self._huffman_dc: dict[Any, Any] = {} + self._huffman_ac: dict[Any, Any] = {} + self.quantization: dict[int, list[int]] = {} + self.app: dict[str, bytes] = {} # compatibility + self.applist: list[tuple[str, bytes]] = [] + self.icclist: list[bytes] = [] + + while True: + i = s[0] + if i == 0xFF: + s = s + self.fp.read(1) + i = i16(s) + else: + # Skip non-0xFF junk + s = self.fp.read(1) + continue + + if i in MARKER: + name, description, handler = MARKER[i] + if handler is not None: + handler(self, i) + if i == 0xFFDA: # start of scan + rawmode = self.mode + if self.mode == "CMYK": + rawmode = "CMYK;I" # assume adobe conventions + self.tile = [ + ImageFile._Tile("jpeg", (0, 0) + self.size, 0, (rawmode, "")) + ] + # self.__offset = self.fp.tell() + break + s = self.fp.read(1) + elif i in {0, 0xFFFF}: + # padded marker or junk; move on + s = b"\xff" + elif i == 0xFF00: # Skip extraneous data (escaped 0xFF) + s = self.fp.read(1) + else: + msg = "no marker found" + raise SyntaxError(msg) + + self._read_dpi_from_exif() + + def __getstate__(self) -> list[Any]: + return super().__getstate__() + [self.layers, self.layer] + + def __setstate__(self, state: list[Any]) -> None: + self.layers, self.layer = state[6:] + super().__setstate__(state) + + def load_read(self, read_bytes: int) -> bytes: + """ + internal: read more image data + For premature EOF and LOAD_TRUNCATED_IMAGES adds EOI marker + so libjpeg can finish decoding + """ + assert self.fp is not None + s = self.fp.read(read_bytes) + + if not s and ImageFile.LOAD_TRUNCATED_IMAGES and not hasattr(self, "_ended"): + # Premature EOF. + # Pretend file is finished adding EOI marker + self._ended = True + return b"\xff\xd9" + + return s + + def draft( + self, mode: str | None, size: tuple[int, int] | None + ) -> tuple[str, tuple[int, int, float, float]] | None: + if len(self.tile) != 1: + return None + + # Protect from second call + if self.decoderconfig: + return None + + d, e, o, a = self.tile[0] + scale = 1 + original_size = self.size + + assert isinstance(a, tuple) + if a[0] == "RGB" and mode in ["L", "YCbCr"]: + self._mode = mode + a = mode, "" + + if size: + scale = min(self.size[0] // size[0], self.size[1] // size[1]) + for s in [8, 4, 2, 1]: + if scale >= s: + break + assert e is not None + e = ( + e[0], + e[1], + (e[2] - e[0] + s - 1) // s + e[0], + (e[3] - e[1] + s - 1) // s + e[1], + ) + self._size = ((self.size[0] + s - 1) // s, (self.size[1] + s - 1) // s) + scale = s + + self.tile = [ImageFile._Tile(d, e, o, a)] + self.decoderconfig = (scale, 0) + + box = (0, 0, original_size[0] / scale, original_size[1] / scale) + return self.mode, box + + def load_djpeg(self) -> None: + # ALTERNATIVE: handle JPEGs via the IJG command line utilities + + f, path = tempfile.mkstemp() + os.close(f) + if os.path.exists(self.filename): + subprocess.check_call(["djpeg", "-outfile", path, self.filename]) + else: + try: + os.unlink(path) + except OSError: + pass + + msg = "Invalid Filename" + raise ValueError(msg) + + try: + with Image.open(path) as _im: + _im.load() + self.im = _im.im + finally: + try: + os.unlink(path) + except OSError: + pass + + self._mode = self.im.mode + self._size = self.im.size + + self.tile = [] + + def _getexif(self) -> dict[int, Any] | None: + return _getexif(self) + + def _read_dpi_from_exif(self) -> None: + # If DPI isn't in JPEG header, fetch from EXIF + if "dpi" in self.info or "exif" not in self.info: + return + try: + exif = self.getexif() + resolution_unit = exif[0x0128] + x_resolution = exif[0x011A] + try: + dpi = float(x_resolution[0]) / x_resolution[1] + except TypeError: + dpi = x_resolution + if math.isnan(dpi): + msg = "DPI is not a number" + raise ValueError(msg) + if resolution_unit == 3: # cm + # 1 dpcm = 2.54 dpi + dpi *= 2.54 + self.info["dpi"] = dpi, dpi + except ( + struct.error, # truncated EXIF + KeyError, # dpi not included + SyntaxError, # invalid/unreadable EXIF + TypeError, # dpi is an invalid float + ValueError, # dpi is an invalid float + ZeroDivisionError, # invalid dpi rational value + ): + self.info["dpi"] = 72, 72 + + def _getmp(self) -> dict[int, Any] | None: + return _getmp(self) + + +def _getexif(self: JpegImageFile) -> dict[int, Any] | None: + if "exif" not in self.info: + return None + return self.getexif()._get_merged_dict() + + +def _getmp(self: JpegImageFile) -> dict[int, Any] | None: + # Extract MP information. This method was inspired by the "highly + # experimental" _getexif version that's been in use for years now, + # itself based on the ImageFileDirectory class in the TIFF plugin. + + # The MP record essentially consists of a TIFF file embedded in a JPEG + # application marker. + try: + data = self.info["mp"] + except KeyError: + return None + file_contents = io.BytesIO(data) + head = file_contents.read(8) + endianness = ">" if head.startswith(b"\x4d\x4d\x00\x2a") else "<" + # process dictionary + from . import TiffImagePlugin + + try: + info = TiffImagePlugin.ImageFileDirectory_v2(head) + file_contents.seek(info.next) + info.load(file_contents) + mp = dict(info) + except Exception as e: + msg = "malformed MP Index (unreadable directory)" + raise SyntaxError(msg) from e + # it's an error not to have a number of images + try: + quant = mp[0xB001] + except KeyError as e: + msg = "malformed MP Index (no number of images)" + raise SyntaxError(msg) from e + # get MP entries + mpentries = [] + try: + rawmpentries = mp[0xB002] + for entrynum in range(quant): + unpackedentry = struct.unpack_from( + f"{endianness}LLLHH", rawmpentries, entrynum * 16 + ) + labels = ("Attribute", "Size", "DataOffset", "EntryNo1", "EntryNo2") + mpentry = dict(zip(labels, unpackedentry)) + mpentryattr = { + "DependentParentImageFlag": bool(mpentry["Attribute"] & (1 << 31)), + "DependentChildImageFlag": bool(mpentry["Attribute"] & (1 << 30)), + "RepresentativeImageFlag": bool(mpentry["Attribute"] & (1 << 29)), + "Reserved": (mpentry["Attribute"] & (3 << 27)) >> 27, + "ImageDataFormat": (mpentry["Attribute"] & (7 << 24)) >> 24, + "MPType": mpentry["Attribute"] & 0x00FFFFFF, + } + if mpentryattr["ImageDataFormat"] == 0: + mpentryattr["ImageDataFormat"] = "JPEG" + else: + msg = "unsupported picture format in MPO" + raise SyntaxError(msg) + mptypemap = { + 0x000000: "Undefined", + 0x010001: "Large Thumbnail (VGA Equivalent)", + 0x010002: "Large Thumbnail (Full HD Equivalent)", + 0x020001: "Multi-Frame Image (Panorama)", + 0x020002: "Multi-Frame Image: (Disparity)", + 0x020003: "Multi-Frame Image: (Multi-Angle)", + 0x030000: "Baseline MP Primary Image", + } + mpentryattr["MPType"] = mptypemap.get(mpentryattr["MPType"], "Unknown") + mpentry["Attribute"] = mpentryattr + mpentries.append(mpentry) + mp[0xB002] = mpentries + except KeyError as e: + msg = "malformed MP Index (bad MP Entry)" + raise SyntaxError(msg) from e + # Next we should try and parse the individual image unique ID list; + # we don't because I've never seen this actually used in a real MPO + # file and so can't test it. + return mp + + +# -------------------------------------------------------------------- +# stuff to save JPEG files + +RAWMODE = { + "1": "L", + "L": "L", + "RGB": "RGB", + "RGBX": "RGB", + "CMYK": "CMYK;I", # assume adobe conventions + "YCbCr": "YCbCr", +} + +# fmt: off +zigzag_index = ( + 0, 1, 5, 6, 14, 15, 27, 28, + 2, 4, 7, 13, 16, 26, 29, 42, + 3, 8, 12, 17, 25, 30, 41, 43, + 9, 11, 18, 24, 31, 40, 44, 53, + 10, 19, 23, 32, 39, 45, 52, 54, + 20, 22, 33, 38, 46, 51, 55, 60, + 21, 34, 37, 47, 50, 56, 59, 61, + 35, 36, 48, 49, 57, 58, 62, 63, +) + +samplings = { + (1, 1, 1, 1, 1, 1): 0, + (2, 1, 1, 1, 1, 1): 1, + (2, 2, 1, 1, 1, 1): 2, +} +# fmt: on + + +def get_sampling(im: Image.Image) -> int: + # There's no subsampling when images have only 1 layer + # (grayscale images) or when they are CMYK (4 layers), + # so set subsampling to the default value. + # + # NOTE: currently Pillow can't encode JPEG to YCCK format. + # If YCCK support is added in the future, subsampling code will have + # to be updated (here and in JpegEncode.c) to deal with 4 layers. + if not isinstance(im, JpegImageFile) or im.layers in (1, 4): + return -1 + sampling = im.layer[0][1:3] + im.layer[1][1:3] + im.layer[2][1:3] + return samplings.get(sampling, -1) + + +def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: + try: + rawmode = RAWMODE[im.mode] + except KeyError as e: + msg = f"cannot write mode {im.mode} as JPEG" + raise OSError(msg) from e + + info = im.encoderinfo + + dpi = [round(x) for x in info.get("dpi", (0, 0))] + + quality = info.get("quality", -1) + subsampling = info.get("subsampling", -1) + qtables = info.get("qtables") + + if quality == "keep": + quality = -1 + subsampling = "keep" + qtables = "keep" + elif quality in presets: + preset = presets[quality] + quality = -1 + subsampling = preset.get("subsampling", -1) + qtables = preset.get("quantization") + elif not isinstance(quality, int): + msg = "Invalid quality setting" + raise ValueError(msg) + else: + if subsampling in presets: + subsampling = presets[subsampling].get("subsampling", -1) + if isinstance(qtables, str) and qtables in presets: + qtables = presets[qtables].get("quantization") + + if subsampling == "4:4:4": + subsampling = 0 + elif subsampling == "4:2:2": + subsampling = 1 + elif subsampling == "4:2:0": + subsampling = 2 + elif subsampling == "4:1:1": + # For compatibility. Before Pillow 4.3, 4:1:1 actually meant 4:2:0. + # Set 4:2:0 if someone is still using that value. + subsampling = 2 + elif subsampling == "keep": + if im.format != "JPEG": + msg = "Cannot use 'keep' when original image is not a JPEG" + raise ValueError(msg) + subsampling = get_sampling(im) + + def validate_qtables( + qtables: ( + str | tuple[list[int], ...] | list[list[int]] | dict[int, list[int]] | None + ), + ) -> list[list[int]] | None: + if qtables is None: + return qtables + if isinstance(qtables, str): + try: + lines = [ + int(num) + for line in qtables.splitlines() + for num in line.split("#", 1)[0].split() + ] + except ValueError as e: + msg = "Invalid quantization table" + raise ValueError(msg) from e + else: + qtables = [lines[s : s + 64] for s in range(0, len(lines), 64)] + if isinstance(qtables, (tuple, list, dict)): + if isinstance(qtables, dict): + qtables = [ + qtables[key] for key in range(len(qtables)) if key in qtables + ] + elif isinstance(qtables, tuple): + qtables = list(qtables) + if not (0 < len(qtables) < 5): + msg = "None or too many quantization tables" + raise ValueError(msg) + try: + for idx, table in enumerate(qtables): + if len(table) != 64: + msg = "Invalid quantization table" + raise TypeError(msg) + qtables[idx] = list(array.array("H", table)) + except TypeError as e: + msg = "Invalid quantization table" + raise ValueError(msg) from e + return qtables + + if qtables == "keep": + if im.format != "JPEG": + msg = "Cannot use 'keep' when original image is not a JPEG" + raise ValueError(msg) + qtables = getattr(im, "quantization", None) + qtables = validate_qtables(qtables) + + extra = info.get("extra", b"") + + MAX_BYTES_IN_MARKER = 65533 + if xmp := info.get("xmp"): + overhead_len = 29 # b"http://ns.adobe.com/xap/1.0/\x00" + max_data_bytes_in_marker = MAX_BYTES_IN_MARKER - overhead_len + if len(xmp) > max_data_bytes_in_marker: + msg = "XMP data is too long" + raise ValueError(msg) + size = o16(2 + overhead_len + len(xmp)) + extra += b"\xff\xe1" + size + b"http://ns.adobe.com/xap/1.0/\x00" + xmp + + if icc_profile := info.get("icc_profile"): + overhead_len = 14 # b"ICC_PROFILE\0" + o8(i) + o8(len(markers)) + max_data_bytes_in_marker = MAX_BYTES_IN_MARKER - overhead_len + markers = [] + while icc_profile: + markers.append(icc_profile[:max_data_bytes_in_marker]) + icc_profile = icc_profile[max_data_bytes_in_marker:] + i = 1 + for marker in markers: + size = o16(2 + overhead_len + len(marker)) + extra += ( + b"\xff\xe2" + + size + + b"ICC_PROFILE\0" + + o8(i) + + o8(len(markers)) + + marker + ) + i += 1 + + comment = info.get("comment", im.info.get("comment")) + + # "progressive" is the official name, but older documentation + # says "progression" + # FIXME: issue a warning if the wrong form is used (post-1.1.7) + progressive = info.get("progressive", False) or info.get("progression", False) + + optimize = info.get("optimize", False) + + exif = info.get("exif", b"") + if isinstance(exif, Image.Exif): + exif = exif.tobytes() + if len(exif) > MAX_BYTES_IN_MARKER: + msg = "EXIF data is too long" + raise ValueError(msg) + + # get keyword arguments + im.encoderconfig = ( + quality, + progressive, + info.get("smooth", 0), + optimize, + info.get("keep_rgb", False), + info.get("streamtype", 0), + dpi, + subsampling, + info.get("restart_marker_blocks", 0), + info.get("restart_marker_rows", 0), + qtables, + comment, + extra, + exif, + ) + + # if we optimize, libjpeg needs a buffer big enough to hold the whole image + # in a shot. Guessing on the size, at im.size bytes. (raw pixel size is + # channels*size, this is a value that's been used in a django patch. + # https://github.com/matthewwithanm/django-imagekit/issues/50 + if optimize or progressive: + # CMYK can be bigger + if im.mode == "CMYK": + bufsize = 4 * im.size[0] * im.size[1] + # keep sets quality to -1, but the actual value may be high. + elif quality >= 95 or quality == -1: + bufsize = 2 * im.size[0] * im.size[1] + else: + bufsize = im.size[0] * im.size[1] + if exif: + bufsize += len(exif) + 5 + if extra: + bufsize += len(extra) + 1 + else: + # The EXIF info needs to be written as one block, + APP1, + one spare byte. + # Ensure that our buffer is big enough. Same with the icc_profile block. + bufsize = max(len(exif) + 5, len(extra) + 1) + + ImageFile._save( + im, fp, [ImageFile._Tile("jpeg", (0, 0) + im.size, 0, rawmode)], bufsize + ) + + +## +# Factory for making JPEG and MPO instances +def jpeg_factory( + fp: IO[bytes], filename: str | bytes | None = None +) -> JpegImageFile | MpoImageFile: + im = JpegImageFile(fp, filename) + try: + mpheader = im._getmp() + if mpheader is not None and mpheader[45057] > 1: + for segment, content in im.applist: + if segment == "APP1" and b' hdrgm:Version="' in content: + # Ultra HDR images are not yet supported + return im + # It's actually an MPO + from .MpoImagePlugin import MpoImageFile + + # Don't reload everything, just convert it. + im = MpoImageFile.adopt(im, mpheader) + except (TypeError, IndexError): + # It is really a JPEG + pass + except SyntaxError: + warnings.warn( + "Image appears to be a malformed MPO file, it will be " + "interpreted as a base JPEG file" + ) + return im + + +# --------------------------------------------------------------------- +# Registry stuff + +Image.register_open(JpegImageFile.format, jpeg_factory, _accept) +Image.register_save(JpegImageFile.format, _save) + +Image.register_extensions(JpegImageFile.format, [".jfif", ".jpe", ".jpg", ".jpeg"]) + +Image.register_mime(JpegImageFile.format, "image/jpeg") diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/JpegPresets.py b/presentation/.venv/lib/python3.12/site-packages/PIL/JpegPresets.py new file mode 100644 index 0000000..d0e64a3 --- /dev/null +++ b/presentation/.venv/lib/python3.12/site-packages/PIL/JpegPresets.py @@ -0,0 +1,242 @@ +""" +JPEG quality settings equivalent to the Photoshop settings. +Can be used when saving JPEG files. + +The following presets are available by default: +``web_low``, ``web_medium``, ``web_high``, ``web_very_high``, ``web_maximum``, +``low``, ``medium``, ``high``, ``maximum``. +More presets can be added to the :py:data:`presets` dict if needed. + +To apply the preset, specify:: + + quality="preset_name" + +To apply only the quantization table:: + + qtables="preset_name" + +To apply only the subsampling setting:: + + subsampling="preset_name" + +Example:: + + im.save("image_name.jpg", quality="web_high") + +Subsampling +----------- + +Subsampling is the practice of encoding images by implementing less resolution +for chroma information than for luma information. +(ref.: https://en.wikipedia.org/wiki/Chroma_subsampling) + +Possible subsampling values are 0, 1 and 2 that correspond to 4:4:4, 4:2:2 and +4:2:0. + +You can get the subsampling of a JPEG with the +:func:`.JpegImagePlugin.get_sampling` function. + +In JPEG compressed data a JPEG marker is used instead of an EXIF tag. +(ref.: https://exiv2.org/tags.html) + + +Quantization tables +------------------- + +They are values use by the DCT (Discrete cosine transform) to remove +*unnecessary* information from the image (the lossy part of the compression). +(ref.: https://en.wikipedia.org/wiki/Quantization_matrix#Quantization_matrices, +https://en.wikipedia.org/wiki/JPEG#Quantization) + +You can get the quantization tables of a JPEG with:: + + im.quantization + +This will return a dict with a number of lists. You can pass this dict +directly as the qtables argument when saving a JPEG. + +The quantization table format in presets is a list with sublists. These formats +are interchangeable. + +Libjpeg ref.: +https://web.archive.org/web/20120328125543/http://www.jpegcameras.com/libjpeg/libjpeg-3.html + +""" + +from __future__ import annotations + +# fmt: off +presets = { + 'web_low': {'subsampling': 2, # "4:2:0" + 'quantization': [ + [20, 16, 25, 39, 50, 46, 62, 68, + 16, 18, 23, 38, 38, 53, 65, 68, + 25, 23, 31, 38, 53, 65, 68, 68, + 39, 38, 38, 53, 65, 68, 68, 68, + 50, 38, 53, 65, 68, 68, 68, 68, + 46, 53, 65, 68, 68, 68, 68, 68, + 62, 65, 68, 68, 68, 68, 68, 68, + 68, 68, 68, 68, 68, 68, 68, 68], + [21, 25, 32, 38, 54, 68, 68, 68, + 25, 28, 24, 38, 54, 68, 68, 68, + 32, 24, 32, 43, 66, 68, 68, 68, + 38, 38, 43, 53, 68, 68, 68, 68, + 54, 54, 66, 68, 68, 68, 68, 68, + 68, 68, 68, 68, 68, 68, 68, 68, + 68, 68, 68, 68, 68, 68, 68, 68, + 68, 68, 68, 68, 68, 68, 68, 68] + ]}, + 'web_medium': {'subsampling': 2, # "4:2:0" + 'quantization': [ + [16, 11, 11, 16, 23, 27, 31, 30, + 11, 12, 12, 15, 20, 23, 23, 30, + 11, 12, 13, 16, 23, 26, 35, 47, + 16, 15, 16, 23, 26, 37, 47, 64, + 23, 20, 23, 26, 39, 51, 64, 64, + 27, 23, 26, 37, 51, 64, 64, 64, + 31, 23, 35, 47, 64, 64, 64, 64, + 30, 30, 47, 64, 64, 64, 64, 64], + [17, 15, 17, 21, 20, 26, 38, 48, + 15, 19, 18, 17, 20, 26, 35, 43, + 17, 18, 20, 22, 26, 30, 46, 53, + 21, 17, 22, 28, 30, 39, 53, 64, + 20, 20, 26, 30, 39, 48, 64, 64, + 26, 26, 30, 39, 48, 63, 64, 64, + 38, 35, 46, 53, 64, 64, 64, 64, + 48, 43, 53, 64, 64, 64, 64, 64] + ]}, + 'web_high': {'subsampling': 0, # "4:4:4" + 'quantization': [ + [6, 4, 4, 6, 9, 11, 12, 16, + 4, 5, 5, 6, 8, 10, 12, 12, + 4, 5, 5, 6, 10, 12, 14, 19, + 6, 6, 6, 11, 12, 15, 19, 28, + 9, 8, 10, 12, 16, 20, 27, 31, + 11, 10, 12, 15, 20, 27, 31, 31, + 12, 12, 14, 19, 27, 31, 31, 31, + 16, 12, 19, 28, 31, 31, 31, 31], + [7, 7, 13, 24, 26, 31, 31, 31, + 7, 12, 16, 21, 31, 31, 31, 31, + 13, 16, 17, 31, 31, 31, 31, 31, + 24, 21, 31, 31, 31, 31, 31, 31, + 26, 31, 31, 31, 31, 31, 31, 31, + 31, 31, 31, 31, 31, 31, 31, 31, + 31, 31, 31, 31, 31, 31, 31, 31, + 31, 31, 31, 31, 31, 31, 31, 31] + ]}, + 'web_very_high': {'subsampling': 0, # "4:4:4" + 'quantization': [ + [2, 2, 2, 2, 3, 4, 5, 6, + 2, 2, 2, 2, 3, 4, 5, 6, + 2, 2, 2, 2, 4, 5, 7, 9, + 2, 2, 2, 4, 5, 7, 9, 12, + 3, 3, 4, 5, 8, 10, 12, 12, + 4, 4, 5, 7, 10, 12, 12, 12, + 5, 5, 7, 9, 12, 12, 12, 12, + 6, 6, 9, 12, 12, 12, 12, 12], + [3, 3, 5, 9, 13, 15, 15, 15, + 3, 4, 6, 11, 14, 12, 12, 12, + 5, 6, 9, 14, 12, 12, 12, 12, + 9, 11, 14, 12, 12, 12, 12, 12, + 13, 14, 12, 12, 12, 12, 12, 12, + 15, 12, 12, 12, 12, 12, 12, 12, + 15, 12, 12, 12, 12, 12, 12, 12, + 15, 12, 12, 12, 12, 12, 12, 12] + ]}, + 'web_maximum': {'subsampling': 0, # "4:4:4" + 'quantization': [ + [1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 2, + 1, 1, 1, 1, 1, 1, 2, 2, + 1, 1, 1, 1, 1, 2, 2, 3, + 1, 1, 1, 1, 2, 2, 3, 3, + 1, 1, 1, 2, 2, 3, 3, 3, + 1, 1, 2, 2, 3, 3, 3, 3], + [1, 1, 1, 2, 2, 3, 3, 3, + 1, 1, 1, 2, 3, 3, 3, 3, + 1, 1, 1, 3, 3, 3, 3, 3, + 2, 2, 3, 3, 3, 3, 3, 3, + 2, 3, 3, 3, 3, 3, 3, 3, + 3, 3, 3, 3, 3, 3, 3, 3, + 3, 3, 3, 3, 3, 3, 3, 3, + 3, 3, 3, 3, 3, 3, 3, 3] + ]}, + 'low': {'subsampling': 2, # "4:2:0" + 'quantization': [ + [18, 14, 14, 21, 30, 35, 34, 17, + 14, 16, 16, 19, 26, 23, 12, 12, + 14, 16, 17, 21, 23, 12, 12, 12, + 21, 19, 21, 23, 12, 12, 12, 12, + 30, 26, 23, 12, 12, 12, 12, 12, + 35, 23, 12, 12, 12, 12, 12, 12, + 34, 12, 12, 12, 12, 12, 12, 12, + 17, 12, 12, 12, 12, 12, 12, 12], + [20, 19, 22, 27, 20, 20, 17, 17, + 19, 25, 23, 14, 14, 12, 12, 12, + 22, 23, 14, 14, 12, 12, 12, 12, + 27, 14, 14, 12, 12, 12, 12, 12, + 20, 14, 12, 12, 12, 12, 12, 12, + 20, 12, 12, 12, 12, 12, 12, 12, + 17, 12, 12, 12, 12, 12, 12, 12, + 17, 12, 12, 12, 12, 12, 12, 12] + ]}, + 'medium': {'subsampling': 2, # "4:2:0" + 'quantization': [ + [12, 8, 8, 12, 17, 21, 24, 17, + 8, 9, 9, 11, 15, 19, 12, 12, + 8, 9, 10, 12, 19, 12, 12, 12, + 12, 11, 12, 21, 12, 12, 12, 12, + 17, 15, 19, 12, 12, 12, 12, 12, + 21, 19, 12, 12, 12, 12, 12, 12, + 24, 12, 12, 12, 12, 12, 12, 12, + 17, 12, 12, 12, 12, 12, 12, 12], + [13, 11, 13, 16, 20, 20, 17, 17, + 11, 14, 14, 14, 14, 12, 12, 12, + 13, 14, 14, 14, 12, 12, 12, 12, + 16, 14, 14, 12, 12, 12, 12, 12, + 20, 14, 12, 12, 12, 12, 12, 12, + 20, 12, 12, 12, 12, 12, 12, 12, + 17, 12, 12, 12, 12, 12, 12, 12, + 17, 12, 12, 12, 12, 12, 12, 12] + ]}, + 'high': {'subsampling': 0, # "4:4:4" + 'quantization': [ + [6, 4, 4, 6, 9, 11, 12, 16, + 4, 5, 5, 6, 8, 10, 12, 12, + 4, 5, 5, 6, 10, 12, 12, 12, + 6, 6, 6, 11, 12, 12, 12, 12, + 9, 8, 10, 12, 12, 12, 12, 12, + 11, 10, 12, 12, 12, 12, 12, 12, + 12, 12, 12, 12, 12, 12, 12, 12, + 16, 12, 12, 12, 12, 12, 12, 12], + [7, 7, 13, 24, 20, 20, 17, 17, + 7, 12, 16, 14, 14, 12, 12, 12, + 13, 16, 14, 14, 12, 12, 12, 12, + 24, 14, 14, 12, 12, 12, 12, 12, + 20, 14, 12, 12, 12, 12, 12, 12, + 20, 12, 12, 12, 12, 12, 12, 12, + 17, 12, 12, 12, 12, 12, 12, 12, + 17, 12, 12, 12, 12, 12, 12, 12] + ]}, + 'maximum': {'subsampling': 0, # "4:4:4" + 'quantization': [ + [2, 2, 2, 2, 3, 4, 5, 6, + 2, 2, 2, 2, 3, 4, 5, 6, + 2, 2, 2, 2, 4, 5, 7, 9, + 2, 2, 2, 4, 5, 7, 9, 12, + 3, 3, 4, 5, 8, 10, 12, 12, + 4, 4, 5, 7, 10, 12, 12, 12, + 5, 5, 7, 9, 12, 12, 12, 12, + 6, 6, 9, 12, 12, 12, 12, 12], + [3, 3, 5, 9, 13, 15, 15, 15, + 3, 4, 6, 10, 14, 12, 12, 12, + 5, 6, 9, 14, 12, 12, 12, 12, + 9, 10, 14, 12, 12, 12, 12, 12, + 13, 14, 12, 12, 12, 12, 12, 12, + 15, 12, 12, 12, 12, 12, 12, 12, + 15, 12, 12, 12, 12, 12, 12, 12, + 15, 12, 12, 12, 12, 12, 12, 12] + ]}, +} +# fmt: on diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/McIdasImagePlugin.py b/presentation/.venv/lib/python3.12/site-packages/PIL/McIdasImagePlugin.py new file mode 100644 index 0000000..9a47933 --- /dev/null +++ b/presentation/.venv/lib/python3.12/site-packages/PIL/McIdasImagePlugin.py @@ -0,0 +1,78 @@ +# +# The Python Imaging Library. +# $Id$ +# +# Basic McIdas support for PIL +# +# History: +# 1997-05-05 fl Created (8-bit images only) +# 2009-03-08 fl Added 16/32-bit support. +# +# Thanks to Richard Jones and Craig Swank for specs and samples. +# +# Copyright (c) Secret Labs AB 1997. +# Copyright (c) Fredrik Lundh 1997. +# +# See the README file for information on usage and redistribution. +# +from __future__ import annotations + +import struct + +from . import Image, ImageFile + + +def _accept(prefix: bytes) -> bool: + return prefix.startswith(b"\x00\x00\x00\x00\x00\x00\x00\x04") + + +## +# Image plugin for McIdas area images. + + +class McIdasImageFile(ImageFile.ImageFile): + format = "MCIDAS" + format_description = "McIdas area file" + + def _open(self) -> None: + # parse area file directory + assert self.fp is not None + + s = self.fp.read(256) + if not _accept(s) or len(s) != 256: + msg = "not an McIdas area file" + raise SyntaxError(msg) + + self.area_descriptor_raw = s + self.area_descriptor = w = [0, *struct.unpack("!64i", s)] + + # get mode + if w[11] == 1: + mode = rawmode = "L" + elif w[11] == 2: + mode = rawmode = "I;16B" + elif w[11] == 4: + # FIXME: add memory map support + mode = "I" + rawmode = "I;32B" + else: + msg = "unsupported McIdas format" + raise SyntaxError(msg) + + self._mode = mode + self._size = w[10], w[9] + + offset = w[34] + w[15] + stride = w[15] + w[10] * w[11] * w[14] + + self.tile = [ + ImageFile._Tile("raw", (0, 0) + self.size, offset, (rawmode, stride, 1)) + ] + + +# -------------------------------------------------------------------- +# registry + +Image.register_open(McIdasImageFile.format, McIdasImageFile, _accept) + +# no default extension diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/MicImagePlugin.py b/presentation/.venv/lib/python3.12/site-packages/PIL/MicImagePlugin.py new file mode 100644 index 0000000..99a07ba --- /dev/null +++ b/presentation/.venv/lib/python3.12/site-packages/PIL/MicImagePlugin.py @@ -0,0 +1,103 @@ +# +# The Python Imaging Library. +# $Id$ +# +# Microsoft Image Composer support for PIL +# +# Notes: +# uses TiffImagePlugin.py to read the actual image streams +# +# History: +# 97-01-20 fl Created +# +# Copyright (c) Secret Labs AB 1997. +# Copyright (c) Fredrik Lundh 1997. +# +# See the README file for information on usage and redistribution. +# +from __future__ import annotations + +import olefile + +from . import Image, TiffImagePlugin + +# +# -------------------------------------------------------------------- + + +def _accept(prefix: bytes) -> bool: + return prefix.startswith(olefile.MAGIC) + + +## +# Image plugin for Microsoft's Image Composer file format. + + +class MicImageFile(TiffImagePlugin.TiffImageFile): + format = "MIC" + format_description = "Microsoft Image Composer" + _close_exclusive_fp_after_loading = False + + def _open(self) -> None: + # read the OLE directory and see if this is a likely + # to be a Microsoft Image Composer file + + try: + self.ole = olefile.OleFileIO(self.fp) + except OSError as e: + msg = "not an MIC file; invalid OLE file" + raise SyntaxError(msg) from e + + # find ACI subfiles with Image members (maybe not the + # best way to identify MIC files, but what the... ;-) + + self.images = [ + path + for path in self.ole.listdir() + if path[1:] and path[0].endswith(".ACI") and path[1] == "Image" + ] + + # if we didn't find any images, this is probably not + # an MIC file. + if not self.images: + msg = "not an MIC file; no image entries" + raise SyntaxError(msg) + + self.frame = -1 + self._n_frames = len(self.images) + self.is_animated = self._n_frames > 1 + + assert self.fp is not None + self.__fp = self.fp + self.seek(0) + + def seek(self, frame: int) -> None: + if not self._seek_check(frame): + return + filename = self.images[frame] + self.fp = self.ole.openstream(filename) + + TiffImagePlugin.TiffImageFile._open(self) + + self.frame = frame + + def tell(self) -> int: + return self.frame + + def close(self) -> None: + self.__fp.close() + self.ole.close() + super().close() + + def __exit__(self, *args: object) -> None: + self.__fp.close() + self.ole.close() + super().__exit__() + + +# +# -------------------------------------------------------------------- + +Image.register_open(MicImageFile.format, MicImageFile, _accept) + +Image.register_extension(MicImageFile.format, ".mic") diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/MpegImagePlugin.py b/presentation/.venv/lib/python3.12/site-packages/PIL/MpegImagePlugin.py new file mode 100644 index 0000000..47ebe9d --- /dev/null +++ b/presentation/.venv/lib/python3.12/site-packages/PIL/MpegImagePlugin.py @@ -0,0 +1,84 @@ +# +# The Python Imaging Library. +# $Id$ +# +# MPEG file handling +# +# History: +# 95-09-09 fl Created +# +# Copyright (c) Secret Labs AB 1997. +# Copyright (c) Fredrik Lundh 1995. +# +# See the README file for information on usage and redistribution. +# +from __future__ import annotations + +from . import Image, ImageFile +from ._binary import i8 +from ._typing import SupportsRead + +# +# Bitstream parser + + +class BitStream: + def __init__(self, fp: SupportsRead[bytes]) -> None: + self.fp = fp + self.bits = 0 + self.bitbuffer = 0 + + def next(self) -> int: + return i8(self.fp.read(1)) + + def peek(self, bits: int) -> int: + while self.bits < bits: + self.bitbuffer = (self.bitbuffer << 8) + self.next() + self.bits += 8 + return self.bitbuffer >> (self.bits - bits) & (1 << bits) - 1 + + def skip(self, bits: int) -> None: + while self.bits < bits: + self.bitbuffer = (self.bitbuffer << 8) + i8(self.fp.read(1)) + self.bits += 8 + self.bits = self.bits - bits + + def read(self, bits: int) -> int: + v = self.peek(bits) + self.bits = self.bits - bits + return v + + +def _accept(prefix: bytes) -> bool: + return prefix.startswith(b"\x00\x00\x01\xb3") + + +## +# Image plugin for MPEG streams. This plugin can identify a stream, +# but it cannot read it. + + +class MpegImageFile(ImageFile.ImageFile): + format = "MPEG" + format_description = "MPEG" + + def _open(self) -> None: + assert self.fp is not None + + s = BitStream(self.fp) + if s.read(32) != 0x1B3: + msg = "not an MPEG file" + raise SyntaxError(msg) + + self._mode = "RGB" + self._size = s.read(12), s.read(12) + + +# -------------------------------------------------------------------- +# Registry stuff + +Image.register_open(MpegImageFile.format, MpegImageFile, _accept) + +Image.register_extensions(MpegImageFile.format, [".mpg", ".mpeg"]) + +Image.register_mime(MpegImageFile.format, "video/mpeg") diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/MpoImagePlugin.py b/presentation/.venv/lib/python3.12/site-packages/PIL/MpoImagePlugin.py new file mode 100644 index 0000000..bee0a56 --- /dev/null +++ b/presentation/.venv/lib/python3.12/site-packages/PIL/MpoImagePlugin.py @@ -0,0 +1,203 @@ +# +# The Python Imaging Library. +# $Id$ +# +# MPO file handling +# +# See "Multi-Picture Format" (CIPA DC-007-Translation 2009, Standard of the +# Camera & Imaging Products Association) +# +# The multi-picture object combines multiple JPEG images (with a modified EXIF +# data format) into a single file. While it can theoretically be used much like +# a GIF animation, it is commonly used to represent 3D photographs and is (as +# of this writing) the most commonly used format by 3D cameras. +# +# History: +# 2014-03-13 Feneric Created +# +# See the README file for information on usage and redistribution. +# +from __future__ import annotations + +import os +import struct +from typing import IO, Any, cast + +from . import ( + Image, + ImageFile, + ImageSequence, + JpegImagePlugin, + TiffImagePlugin, +) +from ._binary import o32le +from ._util import DeferredError + + +def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: + JpegImagePlugin._save(im, fp, filename) + + +def _save_all(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: + append_images = im.encoderinfo.get("append_images", []) + if not append_images and not getattr(im, "is_animated", False): + _save(im, fp, filename) + return + + mpf_offset = 28 + offsets: list[int] = [] + im_sequences = [im, *append_images] + total = sum(getattr(seq, "n_frames", 1) for seq in im_sequences) + for im_sequence in im_sequences: + for im_frame in ImageSequence.Iterator(im_sequence): + if not offsets: + # APP2 marker + ifd_length = 66 + 16 * total + im_frame.encoderinfo["extra"] = ( + b"\xff\xe2" + + struct.pack(">H", 6 + ifd_length) + + b"MPF\0" + + b" " * ifd_length + ) + if exif := im_frame.encoderinfo.get("exif"): + if isinstance(exif, Image.Exif): + exif = exif.tobytes() + im_frame.encoderinfo["exif"] = exif + mpf_offset += 4 + len(exif) + + JpegImagePlugin._save(im_frame, fp, filename) + offsets.append(fp.tell()) + else: + encoderinfo = im_frame._attach_default_encoderinfo(im) + im_frame.save(fp, "JPEG") + im_frame.encoderinfo = encoderinfo + offsets.append(fp.tell() - offsets[-1]) + + ifd = TiffImagePlugin.ImageFileDirectory_v2() + ifd[0xB000] = b"0100" + ifd[0xB001] = len(offsets) + + mpentries = b"" + data_offset = 0 + for i, size in enumerate(offsets): + if i == 0: + mptype = 0x030000 # Baseline MP Primary Image + else: + mptype = 0x000000 # Undefined + mpentries += struct.pack("<LLLHH", mptype, size, data_offset, 0, 0) + if i == 0: + data_offset -= mpf_offset + data_offset += size + ifd[0xB002] = mpentries + + fp.seek(mpf_offset) + fp.write(b"II\x2a\x00" + o32le(8) + ifd.tobytes(8)) + fp.seek(0, os.SEEK_END) + + +## +# Image plugin for MPO images. + + +class MpoImageFile(JpegImagePlugin.JpegImageFile): + format = "MPO" + format_description = "MPO (CIPA DC-007)" + _close_exclusive_fp_after_loading = False + + def _open(self) -> None: + assert self.fp is not None + self.fp.seek(0) # prep the fp in order to pass the JPEG test + JpegImagePlugin.JpegImageFile._open(self) + self._after_jpeg_open() + + def _after_jpeg_open(self, mpheader: dict[int, Any] | None = None) -> None: + self.mpinfo = mpheader if mpheader is not None else self._getmp() + if self.mpinfo is None: + msg = "Image appears to be a malformed MPO file" + raise ValueError(msg) + self.n_frames = self.mpinfo[0xB001] + self.__mpoffsets = [ + mpent["DataOffset"] + self.info["mpoffset"] for mpent in self.mpinfo[0xB002] + ] + self.__mpoffsets[0] = 0 + # Note that the following assertion will only be invalid if something + # gets broken within JpegImagePlugin. + assert self.n_frames == len(self.__mpoffsets) + del self.info["mpoffset"] # no longer needed + self.is_animated = self.n_frames > 1 + assert self.fp is not None + self._fp = self.fp # FIXME: hack + self._fp.seek(self.__mpoffsets[0]) # get ready to read first frame + self.__frame = 0 + self.offset = 0 + # for now we can only handle reading and individual frame extraction + self.readonly = 1 + + def load_seek(self, pos: int) -> None: + if isinstance(self._fp, DeferredError): + raise self._fp.ex + self._fp.seek(pos) + + def seek(self, frame: int) -> None: + if not self._seek_check(frame): + return + if isinstance(self._fp, DeferredError): + raise self._fp.ex + self.fp = self._fp + self.offset = self.__mpoffsets[frame] + + original_exif = self.info.get("exif") + if "exif" in self.info: + del self.info["exif"] + + self.fp.seek(self.offset + 2) # skip SOI marker + if not self.fp.read(2): + msg = "No data found for frame" + raise ValueError(msg) + self.fp.seek(self.offset) + JpegImagePlugin.JpegImageFile._open(self) + if self.info.get("exif") != original_exif: + self._reload_exif() + + self.tile = [ + ImageFile._Tile("jpeg", (0, 0) + self.size, self.offset, self.tile[0][-1]) + ] + self.__frame = frame + + def tell(self) -> int: + return self.__frame + + @staticmethod + def adopt( + jpeg_instance: JpegImagePlugin.JpegImageFile, + mpheader: dict[int, Any] | None = None, + ) -> MpoImageFile: + """ + Transform the instance of JpegImageFile into + an instance of MpoImageFile. + After the call, the JpegImageFile is extended + to be an MpoImageFile. + + This is essentially useful when opening a JPEG + file that reveals itself as an MPO, to avoid + double call to _open. + """ + jpeg_instance.__class__ = MpoImageFile + mpo_instance = cast(MpoImageFile, jpeg_instance) + mpo_instance._after_jpeg_open(mpheader) + return mpo_instance + + +# --------------------------------------------------------------------- +# Registry stuff + +# Note that since MPO shares a factory with JPEG, we do not need to do a +# separate registration for it here. +# Image.register_open(MpoImageFile.format, +# JpegImagePlugin.jpeg_factory, _accept) +Image.register_save(MpoImageFile.format, _save) +Image.register_save_all(MpoImageFile.format, _save_all) + +Image.register_extension(MpoImageFile.format, ".mpo") + +Image.register_mime(MpoImageFile.format, "image/mpo") diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/MspImagePlugin.py b/presentation/.venv/lib/python3.12/site-packages/PIL/MspImagePlugin.py new file mode 100644 index 0000000..fa0f52f --- /dev/null +++ b/presentation/.venv/lib/python3.12/site-packages/PIL/MspImagePlugin.py @@ -0,0 +1,200 @@ +# +# The Python Imaging Library. +# +# MSP file handling +# +# This is the format used by the Paint program in Windows 1 and 2. +# +# History: +# 95-09-05 fl Created +# 97-01-03 fl Read/write MSP images +# 17-02-21 es Fixed RLE interpretation +# +# Copyright (c) Secret Labs AB 1997. +# Copyright (c) Fredrik Lundh 1995-97. +# Copyright (c) Eric Soroos 2017. +# +# See the README file for information on usage and redistribution. +# +# More info on this format: https://archive.org/details/gg243631 +# Page 313: +# Figure 205. Windows Paint Version 1: "DanM" Format +# Figure 206. Windows Paint Version 2: "LinS" Format. Used in Windows V2.03 +# +# See also: https://www.fileformat.info/format/mspaint/egff.htm +from __future__ import annotations + +import io +import struct +from typing import IO + +from . import Image, ImageFile +from ._binary import i16le as i16 +from ._binary import o16le as o16 + +# +# read MSP files + + +def _accept(prefix: bytes) -> bool: + return prefix.startswith((b"DanM", b"LinS")) + + +## +# Image plugin for Windows MSP images. This plugin supports both +# uncompressed (Windows 1.0). + + +class MspImageFile(ImageFile.ImageFile): + format = "MSP" + format_description = "Windows Paint" + + def _open(self) -> None: + # Header + assert self.fp is not None + + s = self.fp.read(32) + if not _accept(s): + msg = "not an MSP file" + raise SyntaxError(msg) + + # Header checksum + checksum = 0 + for i in range(0, 32, 2): + checksum = checksum ^ i16(s, i) + if checksum != 0: + msg = "bad MSP checksum" + raise SyntaxError(msg) + + self._mode = "1" + self._size = i16(s, 4), i16(s, 6) + + if s.startswith(b"DanM"): + self.tile = [ImageFile._Tile("raw", (0, 0) + self.size, 32, "1")] + else: + self.tile = [ImageFile._Tile("MSP", (0, 0) + self.size, 32)] + + +class MspDecoder(ImageFile.PyDecoder): + # The algo for the MSP decoder is from + # https://www.fileformat.info/format/mspaint/egff.htm + # cc-by-attribution -- That page references is taken from the + # Encyclopedia of Graphics File Formats and is licensed by + # O'Reilly under the Creative Common/Attribution license + # + # For RLE encoded files, the 32byte header is followed by a scan + # line map, encoded as one 16bit word of encoded byte length per + # line. + # + # NOTE: the encoded length of the line can be 0. This was not + # handled in the previous version of this encoder, and there's no + # mention of how to handle it in the documentation. From the few + # examples I've seen, I've assumed that it is a fill of the + # background color, in this case, white. + # + # + # Pseudocode of the decoder: + # Read a BYTE value as the RunType + # If the RunType value is zero + # Read next byte as the RunCount + # Read the next byte as the RunValue + # Write the RunValue byte RunCount times + # If the RunType value is non-zero + # Use this value as the RunCount + # Read and write the next RunCount bytes literally + # + # e.g.: + # 0x00 03 ff 05 00 01 02 03 04 + # would yield the bytes: + # 0xff ff ff 00 01 02 03 04 + # + # which are then interpreted as a bit packed mode '1' image + + _pulls_fd = True + + def decode(self, buffer: bytes | Image.SupportsArrayInterface) -> tuple[int, int]: + assert self.fd is not None + + img = io.BytesIO() + blank_line = bytearray((0xFF,) * ((self.state.xsize + 7) // 8)) + try: + self.fd.seek(32) + rowmap = struct.unpack_from( + f"<{self.state.ysize}H", self.fd.read(self.state.ysize * 2) + ) + except struct.error as e: + msg = "Truncated MSP file in row map" + raise OSError(msg) from e + + for x, rowlen in enumerate(rowmap): + try: + if rowlen == 0: + img.write(blank_line) + continue + row = self.fd.read(rowlen) + if len(row) != rowlen: + msg = f"Truncated MSP file, expected {rowlen} bytes on row {x}" + raise OSError(msg) + idx = 0 + while idx < rowlen: + runtype = row[idx] + idx += 1 + if runtype == 0: + runcount, runval = struct.unpack_from("Bc", row, idx) + img.write(runval * runcount) + idx += 2 + else: + runcount = runtype + img.write(row[idx : idx + runcount]) + idx += runcount + + except struct.error as e: + msg = f"Corrupted MSP file in row {x}" + raise OSError(msg) from e + + self.set_as_raw(img.getvalue(), "1") + + return -1, 0 + + +Image.register_decoder("MSP", MspDecoder) + + +# +# write MSP files (uncompressed only) + + +def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: + if im.mode != "1": + msg = f"cannot write mode {im.mode} as MSP" + raise OSError(msg) + + # create MSP header + header = [0] * 16 + + header[0], header[1] = i16(b"Da"), i16(b"nM") # version 1 + header[2], header[3] = im.size + header[4], header[5] = 1, 1 + header[6], header[7] = 1, 1 + header[8], header[9] = im.size + + checksum = 0 + for h in header: + checksum = checksum ^ h + header[12] = checksum # FIXME: is this the right field? + + # header + for h in header: + fp.write(o16(h)) + + # image body + ImageFile._save(im, fp, [ImageFile._Tile("raw", (0, 0) + im.size, 32, "1")]) + + +# +# registry + +Image.register_open(MspImageFile.format, MspImageFile, _accept) +Image.register_save(MspImageFile.format, _save) + +Image.register_extension(MspImageFile.format, ".msp") diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/PSDraw.py b/presentation/.venv/lib/python3.12/site-packages/PIL/PSDraw.py new file mode 100644 index 0000000..e6b74a9 --- /dev/null +++ b/presentation/.venv/lib/python3.12/site-packages/PIL/PSDraw.py @@ -0,0 +1,238 @@ +# +# The Python Imaging Library +# $Id$ +# +# Simple PostScript graphics interface +# +# History: +# 1996-04-20 fl Created +# 1999-01-10 fl Added gsave/grestore to image method +# 2005-05-04 fl Fixed floating point issue in image (from Eric Etheridge) +# +# Copyright (c) 1997-2005 by Secret Labs AB. All rights reserved. +# Copyright (c) 1996 by Fredrik Lundh. +# +# See the README file for information on usage and redistribution. +# +from __future__ import annotations + +import sys +from typing import IO + +from . import EpsImagePlugin + +TYPE_CHECKING = False + + +## +# Simple PostScript graphics interface. + + +class PSDraw: + """ + Sets up printing to the given file. If ``fp`` is omitted, + ``sys.stdout.buffer`` is assumed. + """ + + def __init__(self, fp: IO[bytes] | None = None) -> None: + if not fp: + fp = sys.stdout.buffer + self.fp = fp + + def begin_document(self, id: str | None = None) -> None: + """Set up printing of a document. (Write PostScript DSC header.)""" + # FIXME: incomplete + self.fp.write( + b"%!PS-Adobe-3.0\n" + b"save\n" + b"/showpage { } def\n" + b"%%EndComments\n" + b"%%BeginDocument\n" + ) + # self.fp.write(ERROR_PS) # debugging! + self.fp.write(EDROFF_PS) + self.fp.write(VDI_PS) + self.fp.write(b"%%EndProlog\n") + self.isofont: dict[bytes, int] = {} + + def end_document(self) -> None: + """Ends printing. (Write PostScript DSC footer.)""" + self.fp.write(b"%%EndDocument\nrestore showpage\n%%End\n") + if hasattr(self.fp, "flush"): + self.fp.flush() + + def setfont(self, font: str, size: int) -> None: + """ + Selects which font to use. + + :param font: A PostScript font name + :param size: Size in points. + """ + font_bytes = bytes(font, "UTF-8") + if font_bytes not in self.isofont: + # reencode font + self.fp.write( + b"/PSDraw-%s ISOLatin1Encoding /%s E\n" % (font_bytes, font_bytes) + ) + self.isofont[font_bytes] = 1 + # rough + self.fp.write(b"/F0 %d /PSDraw-%s F\n" % (size, font_bytes)) + + def line(self, xy0: tuple[int, int], xy1: tuple[int, int]) -> None: + """ + Draws a line between the two points. Coordinates are given in + PostScript point coordinates (72 points per inch, (0, 0) is the lower + left corner of the page). + """ + self.fp.write(b"%d %d %d %d Vl\n" % (*xy0, *xy1)) + + def rectangle(self, box: tuple[int, int, int, int]) -> None: + """ + Draws a rectangle. + + :param box: A tuple of four integers, specifying left, bottom, width and + height. + """ + self.fp.write(b"%d %d M 0 %d %d Vr\n" % box) + + def text(self, xy: tuple[int, int], text: str) -> None: + """ + Draws text at the given position. You must use + :py:meth:`~PIL.PSDraw.PSDraw.setfont` before calling this method. + """ + # The font is loaded as ISOLatin1Encoding, so use latin-1 here. + text_bytes = bytes(text, "latin-1") + text_bytes = b"\\(".join(text_bytes.split(b"(")) + text_bytes = b"\\)".join(text_bytes.split(b")")) + self.fp.write(b"%d %d M (%s) S\n" % (xy + (text_bytes,))) + + if TYPE_CHECKING: + from . import Image + + def image( + self, box: tuple[int, int, int, int], im: Image.Image, dpi: int | None = None + ) -> None: + """Draw a PIL image, centered in the given box.""" + # default resolution depends on mode + if not dpi: + if im.mode == "1": + dpi = 200 # fax + else: + dpi = 100 # grayscale + # image size (on paper) + x = im.size[0] * 72 / dpi + y = im.size[1] * 72 / dpi + # max allowed size + xmax = float(box[2] - box[0]) + ymax = float(box[3] - box[1]) + if x > xmax: + y = y * xmax / x + x = xmax + if y > ymax: + x = x * ymax / y + y = ymax + dx = (xmax - x) / 2 + box[0] + dy = (ymax - y) / 2 + box[1] + self.fp.write(b"gsave\n%f %f translate\n" % (dx, dy)) + if (x, y) != im.size: + # EpsImagePlugin._save prints the image at (0,0,xsize,ysize) + sx = x / im.size[0] + sy = y / im.size[1] + self.fp.write(b"%f %f scale\n" % (sx, sy)) + EpsImagePlugin._save(im, self.fp, "", 0) + self.fp.write(b"\ngrestore\n") + + +# -------------------------------------------------------------------- +# PostScript driver + +# +# EDROFF.PS -- PostScript driver for Edroff 2 +# +# History: +# 94-01-25 fl: created (edroff 2.04) +# +# Copyright (c) Fredrik Lundh 1994. +# + + +EDROFF_PS = b"""\ +/S { show } bind def +/P { moveto show } bind def +/M { moveto } bind def +/X { 0 rmoveto } bind def +/Y { 0 exch rmoveto } bind def +/E { findfont + dup maxlength dict begin + { + 1 index /FID ne { def } { pop pop } ifelse + } forall + /Encoding exch def + dup /FontName exch def + currentdict end definefont pop +} bind def +/F { findfont exch scalefont dup setfont + [ exch /setfont cvx ] cvx bind def +} bind def +""" + +# +# VDI.PS -- PostScript driver for VDI meta commands +# +# History: +# 94-01-25 fl: created (edroff 2.04) +# +# Copyright (c) Fredrik Lundh 1994. +# + +VDI_PS = b"""\ +/Vm { moveto } bind def +/Va { newpath arcn stroke } bind def +/Vl { moveto lineto stroke } bind def +/Vc { newpath 0 360 arc closepath } bind def +/Vr { exch dup 0 rlineto + exch dup 0 exch rlineto + exch neg 0 rlineto + 0 exch neg rlineto + setgray fill } bind def +/Tm matrix def +/Ve { Tm currentmatrix pop + translate scale newpath 0 0 .5 0 360 arc closepath + Tm setmatrix +} bind def +/Vf { currentgray exch setgray fill setgray } bind def +""" + +# +# ERROR.PS -- Error handler +# +# History: +# 89-11-21 fl: created (pslist 1.10) +# + +ERROR_PS = b"""\ +/landscape false def +/errorBUF 200 string def +/errorNL { currentpoint 10 sub exch pop 72 exch moveto } def +errordict begin /handleerror { + initmatrix /Courier findfont 10 scalefont setfont + newpath 72 720 moveto $error begin /newerror false def + (PostScript Error) show errorNL errorNL + (Error: ) show + /errorname load errorBUF cvs show errorNL errorNL + (Command: ) show + /command load dup type /stringtype ne { errorBUF cvs } if show + errorNL errorNL + (VMstatus: ) show + vmstatus errorBUF cvs show ( bytes available, ) show + errorBUF cvs show ( bytes used at level ) show + errorBUF cvs show errorNL errorNL + (Operand stargck: ) show errorNL /ostargck load { + dup type /stringtype ne { errorBUF cvs } if 72 0 rmoveto show errorNL + } forall errorNL + (Execution stargck: ) show errorNL /estargck load { + dup type /stringtype ne { errorBUF cvs } if 72 0 rmoveto show errorNL + } forall + end showpage +} def end +""" diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/PaletteFile.py b/presentation/.venv/lib/python3.12/site-packages/PIL/PaletteFile.py new file mode 100644 index 0000000..2a26e5d --- /dev/null +++ b/presentation/.venv/lib/python3.12/site-packages/PIL/PaletteFile.py @@ -0,0 +1,54 @@ +# +# Python Imaging Library +# $Id$ +# +# stuff to read simple, teragon-style palette files +# +# History: +# 97-08-23 fl Created +# +# Copyright (c) Secret Labs AB 1997. +# Copyright (c) Fredrik Lundh 1997. +# +# See the README file for information on usage and redistribution. +# +from __future__ import annotations + +from typing import IO + +from ._binary import o8 + + +class PaletteFile: + """File handler for Teragon-style palette files.""" + + rawmode = "RGB" + + def __init__(self, fp: IO[bytes]) -> None: + palette = [o8(i) * 3 for i in range(256)] + + while True: + s = fp.readline() + + if not s: + break + if s.startswith(b"#"): + continue + if len(s) > 100: + msg = "bad palette file" + raise SyntaxError(msg) + + v = [int(x) for x in s.split()] + try: + [i, r, g, b] = v + except ValueError: + [i, r] = v + g = b = r + + if 0 <= i <= 255: + palette[i] = o8(r) + o8(g) + o8(b) + + self.palette = b"".join(palette) + + def getpalette(self) -> tuple[bytes, str]: + return self.palette, self.rawmode diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/PalmImagePlugin.py b/presentation/.venv/lib/python3.12/site-packages/PIL/PalmImagePlugin.py new file mode 100644 index 0000000..232adf3 --- /dev/null +++ b/presentation/.venv/lib/python3.12/site-packages/PIL/PalmImagePlugin.py @@ -0,0 +1,217 @@ +# +# The Python Imaging Library. +# $Id$ +# + +## +# Image plugin for Palm pixmap images (output only). +## +from __future__ import annotations + +from typing import IO + +from . import Image, ImageFile +from ._binary import o8 +from ._binary import o16be as o16b + +# fmt: off +_Palm8BitColormapValues = ( + (255, 255, 255), (255, 204, 255), (255, 153, 255), (255, 102, 255), + (255, 51, 255), (255, 0, 255), (255, 255, 204), (255, 204, 204), + (255, 153, 204), (255, 102, 204), (255, 51, 204), (255, 0, 204), + (255, 255, 153), (255, 204, 153), (255, 153, 153), (255, 102, 153), + (255, 51, 153), (255, 0, 153), (204, 255, 255), (204, 204, 255), + (204, 153, 255), (204, 102, 255), (204, 51, 255), (204, 0, 255), + (204, 255, 204), (204, 204, 204), (204, 153, 204), (204, 102, 204), + (204, 51, 204), (204, 0, 204), (204, 255, 153), (204, 204, 153), + (204, 153, 153), (204, 102, 153), (204, 51, 153), (204, 0, 153), + (153, 255, 255), (153, 204, 255), (153, 153, 255), (153, 102, 255), + (153, 51, 255), (153, 0, 255), (153, 255, 204), (153, 204, 204), + (153, 153, 204), (153, 102, 204), (153, 51, 204), (153, 0, 204), + (153, 255, 153), (153, 204, 153), (153, 153, 153), (153, 102, 153), + (153, 51, 153), (153, 0, 153), (102, 255, 255), (102, 204, 255), + (102, 153, 255), (102, 102, 255), (102, 51, 255), (102, 0, 255), + (102, 255, 204), (102, 204, 204), (102, 153, 204), (102, 102, 204), + (102, 51, 204), (102, 0, 204), (102, 255, 153), (102, 204, 153), + (102, 153, 153), (102, 102, 153), (102, 51, 153), (102, 0, 153), + (51, 255, 255), (51, 204, 255), (51, 153, 255), (51, 102, 255), + (51, 51, 255), (51, 0, 255), (51, 255, 204), (51, 204, 204), + (51, 153, 204), (51, 102, 204), (51, 51, 204), (51, 0, 204), + (51, 255, 153), (51, 204, 153), (51, 153, 153), (51, 102, 153), + (51, 51, 153), (51, 0, 153), (0, 255, 255), (0, 204, 255), + (0, 153, 255), (0, 102, 255), (0, 51, 255), (0, 0, 255), + (0, 255, 204), (0, 204, 204), (0, 153, 204), (0, 102, 204), + (0, 51, 204), (0, 0, 204), (0, 255, 153), (0, 204, 153), + (0, 153, 153), (0, 102, 153), (0, 51, 153), (0, 0, 153), + (255, 255, 102), (255, 204, 102), (255, 153, 102), (255, 102, 102), + (255, 51, 102), (255, 0, 102), (255, 255, 51), (255, 204, 51), + (255, 153, 51), (255, 102, 51), (255, 51, 51), (255, 0, 51), + (255, 255, 0), (255, 204, 0), (255, 153, 0), (255, 102, 0), + (255, 51, 0), (255, 0, 0), (204, 255, 102), (204, 204, 102), + (204, 153, 102), (204, 102, 102), (204, 51, 102), (204, 0, 102), + (204, 255, 51), (204, 204, 51), (204, 153, 51), (204, 102, 51), + (204, 51, 51), (204, 0, 51), (204, 255, 0), (204, 204, 0), + (204, 153, 0), (204, 102, 0), (204, 51, 0), (204, 0, 0), + (153, 255, 102), (153, 204, 102), (153, 153, 102), (153, 102, 102), + (153, 51, 102), (153, 0, 102), (153, 255, 51), (153, 204, 51), + (153, 153, 51), (153, 102, 51), (153, 51, 51), (153, 0, 51), + (153, 255, 0), (153, 204, 0), (153, 153, 0), (153, 102, 0), + (153, 51, 0), (153, 0, 0), (102, 255, 102), (102, 204, 102), + (102, 153, 102), (102, 102, 102), (102, 51, 102), (102, 0, 102), + (102, 255, 51), (102, 204, 51), (102, 153, 51), (102, 102, 51), + (102, 51, 51), (102, 0, 51), (102, 255, 0), (102, 204, 0), + (102, 153, 0), (102, 102, 0), (102, 51, 0), (102, 0, 0), + (51, 255, 102), (51, 204, 102), (51, 153, 102), (51, 102, 102), + (51, 51, 102), (51, 0, 102), (51, 255, 51), (51, 204, 51), + (51, 153, 51), (51, 102, 51), (51, 51, 51), (51, 0, 51), + (51, 255, 0), (51, 204, 0), (51, 153, 0), (51, 102, 0), + (51, 51, 0), (51, 0, 0), (0, 255, 102), (0, 204, 102), + (0, 153, 102), (0, 102, 102), (0, 51, 102), (0, 0, 102), + (0, 255, 51), (0, 204, 51), (0, 153, 51), (0, 102, 51), + (0, 51, 51), (0, 0, 51), (0, 255, 0), (0, 204, 0), + (0, 153, 0), (0, 102, 0), (0, 51, 0), (17, 17, 17), + (34, 34, 34), (68, 68, 68), (85, 85, 85), (119, 119, 119), + (136, 136, 136), (170, 170, 170), (187, 187, 187), (221, 221, 221), + (238, 238, 238), (192, 192, 192), (128, 0, 0), (128, 0, 128), + (0, 128, 0), (0, 128, 128), (0, 0, 0), (0, 0, 0), + (0, 0, 0), (0, 0, 0), (0, 0, 0), (0, 0, 0), + (0, 0, 0), (0, 0, 0), (0, 0, 0), (0, 0, 0), + (0, 0, 0), (0, 0, 0), (0, 0, 0), (0, 0, 0), + (0, 0, 0), (0, 0, 0), (0, 0, 0), (0, 0, 0), + (0, 0, 0), (0, 0, 0), (0, 0, 0), (0, 0, 0), + (0, 0, 0), (0, 0, 0), (0, 0, 0), (0, 0, 0)) +# fmt: on + + +# so build a prototype image to be used for palette resampling +def build_prototype_image() -> Image.Image: + image = Image.new("L", (1, len(_Palm8BitColormapValues))) + image.putdata(list(range(len(_Palm8BitColormapValues)))) + palettedata: tuple[int, ...] = () + for colormapValue in _Palm8BitColormapValues: + palettedata += colormapValue + palettedata += (0, 0, 0) * (256 - len(_Palm8BitColormapValues)) + image.putpalette(palettedata) + return image + + +Palm8BitColormapImage = build_prototype_image() + +# OK, we now have in Palm8BitColormapImage, +# a "P"-mode image with the right palette +# +# -------------------------------------------------------------------- + +_FLAGS = {"custom-colormap": 0x4000, "is-compressed": 0x8000, "has-transparent": 0x2000} + +_COMPRESSION_TYPES = {"none": 0xFF, "rle": 0x01, "scanline": 0x00} + + +# +# -------------------------------------------------------------------- + +## +# (Internal) Image save plugin for the Palm format. + + +def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: + if im.mode == "P": + rawmode = "P" + bpp = 8 + version = 1 + + elif im.mode == "L": + if im.encoderinfo.get("bpp") in (1, 2, 4): + # this is 8-bit grayscale, so we shift it to get the high-order bits, + # and invert it because + # Palm does grayscale from white (0) to black (1) + bpp = im.encoderinfo["bpp"] + maxval = (1 << bpp) - 1 + shift = 8 - bpp + im = im.point(lambda x: maxval - (x >> shift)) + elif im.info.get("bpp") in (1, 2, 4): + # here we assume that even though the inherent mode is 8-bit grayscale, + # only the lower bpp bits are significant. + # We invert them to match the Palm. + bpp = im.info["bpp"] + maxval = (1 << bpp) - 1 + im = im.point(lambda x: maxval - (x & maxval)) + else: + msg = f"cannot write mode {im.mode} as Palm" + raise OSError(msg) + + # we ignore the palette here + im._mode = "P" + rawmode = f"P;{bpp}" + version = 1 + + elif im.mode == "1": + # monochrome -- write it inverted, as is the Palm standard + rawmode = "1;I" + bpp = 1 + version = 0 + + else: + msg = f"cannot write mode {im.mode} as Palm" + raise OSError(msg) + + # + # make sure image data is available + im.load() + + # write header + + cols = im.size[0] + rows = im.size[1] + + rowbytes = int((cols + (16 // bpp - 1)) / (16 // bpp)) * 2 + transparent_index = 0 + compression_type = _COMPRESSION_TYPES["none"] + + flags = 0 + if im.mode == "P": + flags |= _FLAGS["custom-colormap"] + colormap = im.im.getpalette() + colors = len(colormap) // 3 + colormapsize = 4 * colors + 2 + else: + colormapsize = 0 + + if "offset" in im.info: + offset = (rowbytes * rows + 16 + 3 + colormapsize) // 4 + else: + offset = 0 + + fp.write(o16b(cols) + o16b(rows) + o16b(rowbytes) + o16b(flags)) + fp.write(o8(bpp)) + fp.write(o8(version)) + fp.write(o16b(offset)) + fp.write(o8(transparent_index)) + fp.write(o8(compression_type)) + fp.write(o16b(0)) # reserved by Palm + + # now write colormap if necessary + + if colormapsize: + fp.write(o16b(colors)) + for i in range(colors): + fp.write(o8(i)) + fp.write(colormap[3 * i : 3 * i + 3]) + + # now convert data to raw form + ImageFile._save( + im, fp, [ImageFile._Tile("raw", (0, 0) + im.size, 0, (rawmode, rowbytes, 1))] + ) + + if hasattr(fp, "flush"): + fp.flush() + + +# +# -------------------------------------------------------------------- + +Image.register_save("PALM", _save) + +Image.register_extension("PALM", ".palm") + +Image.register_mime("PALM", "image/palm") diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/PcdImagePlugin.py b/presentation/.venv/lib/python3.12/site-packages/PIL/PcdImagePlugin.py new file mode 100644 index 0000000..296f377 --- /dev/null +++ b/presentation/.venv/lib/python3.12/site-packages/PIL/PcdImagePlugin.py @@ -0,0 +1,68 @@ +# +# The Python Imaging Library. +# $Id$ +# +# PCD file handling +# +# History: +# 96-05-10 fl Created +# 96-05-27 fl Added draft mode (128x192, 256x384) +# +# Copyright (c) Secret Labs AB 1997. +# Copyright (c) Fredrik Lundh 1996. +# +# See the README file for information on usage and redistribution. +# +from __future__ import annotations + +from . import Image, ImageFile + +## +# Image plugin for PhotoCD images. This plugin only reads the 768x512 +# image from the file; higher resolutions are encoded in a proprietary +# encoding. + + +class PcdImageFile(ImageFile.ImageFile): + format = "PCD" + format_description = "Kodak PhotoCD" + + def _open(self) -> None: + # rough + assert self.fp is not None + + self.fp.seek(2048) + s = self.fp.read(1539) + + if not s.startswith(b"PCD_"): + msg = "not a PCD file" + raise SyntaxError(msg) + + orientation = s[1538] & 3 + self.tile_post_rotate = None + if orientation == 1: + self.tile_post_rotate = 90 + elif orientation == 3: + self.tile_post_rotate = 270 + + self._mode = "RGB" + self._size = (512, 768) if orientation in (1, 3) else (768, 512) + self.tile = [ImageFile._Tile("pcd", (0, 0, 768, 512), 96 * 2048)] + + def load_prepare(self) -> None: + if self._im is None and self.tile_post_rotate: + self.im = Image.core.new(self.mode, (768, 512)) + ImageFile.ImageFile.load_prepare(self) + + def load_end(self) -> None: + if self.tile_post_rotate: + # Handle rotated PCDs + self.im = self.rotate(self.tile_post_rotate, expand=True).im + + +# +# registry + +Image.register_open(PcdImageFile.format, PcdImageFile) + +Image.register_extension(PcdImageFile.format, ".pcd") diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/PcfFontFile.py b/presentation/.venv/lib/python3.12/site-packages/PIL/PcfFontFile.py new file mode 100644 index 0000000..b923293 --- /dev/null +++ b/presentation/.venv/lib/python3.12/site-packages/PIL/PcfFontFile.py @@ -0,0 +1,258 @@ +# +# THIS IS WORK IN PROGRESS +# +# The Python Imaging Library +# $Id$ +# +# portable compiled font file parser +# +# history: +# 1997-08-19 fl created +# 2003-09-13 fl fixed loading of unicode fonts +# +# Copyright (c) 1997-2003 by Secret Labs AB. +# Copyright (c) 1997-2003 by Fredrik Lundh. +# +# See the README file for information on usage and redistribution. +# +from __future__ import annotations + +import io + +from . import FontFile, Image +from ._binary import i8 +from ._binary import i16be as b16 +from ._binary import i16le as l16 +from ._binary import i32be as b32 +from ._binary import i32le as l32 + +TYPE_CHECKING = False +if TYPE_CHECKING: + from collections.abc import Callable + from typing import BinaryIO + +# -------------------------------------------------------------------- +# declarations + +PCF_MAGIC = 0x70636601 # "\x01fcp" + +PCF_PROPERTIES = 1 << 0 +PCF_ACCELERATORS = 1 << 1 +PCF_METRICS = 1 << 2 +PCF_BITMAPS = 1 << 3 +PCF_INK_METRICS = 1 << 4 +PCF_BDF_ENCODINGS = 1 << 5 +PCF_SWIDTHS = 1 << 6 +PCF_GLYPH_NAMES = 1 << 7 +PCF_BDF_ACCELERATORS = 1 << 8 + +BYTES_PER_ROW: list[Callable[[int], int]] = [ + lambda bits: ((bits + 7) >> 3), + lambda bits: ((bits + 15) >> 3) & ~1, + lambda bits: ((bits + 31) >> 3) & ~3, + lambda bits: ((bits + 63) >> 3) & ~7, +] + + +def sz(s: bytes, o: int) -> bytes: + return s[o : s.index(b"\0", o)] + + +class PcfFontFile(FontFile.FontFile): + """Font file plugin for the X11 PCF format.""" + + name = "name" + + def __init__(self, fp: BinaryIO, charset_encoding: str = "iso8859-1"): + self.charset_encoding = charset_encoding + + magic = l32(fp.read(4)) + if magic != PCF_MAGIC: + msg = "not a PCF file" + raise SyntaxError(msg) + + super().__init__() + + count = l32(fp.read(4)) + self.toc = {} + for i in range(count): + type = l32(fp.read(4)) + self.toc[type] = l32(fp.read(4)), l32(fp.read(4)), l32(fp.read(4)) + + self.fp = fp + + self.info = self._load_properties() + + metrics = self._load_metrics() + bitmaps = self._load_bitmaps(metrics) + encoding = self._load_encoding() + + # + # create glyph structure + + for ch, ix in enumerate(encoding): + if ix is not None: + ( + xsize, + ysize, + left, + right, + width, + ascent, + descent, + attributes, + ) = metrics[ix] + self.glyph[ch] = ( + (width, 0), + (left, descent - ysize, xsize + left, descent), + (0, 0, xsize, ysize), + bitmaps[ix], + ) + + def _getformat( + self, tag: int + ) -> tuple[BinaryIO, int, Callable[[bytes], int], Callable[[bytes], int]]: + format, size, offset = self.toc[tag] + + fp = self.fp + fp.seek(offset) + + format = l32(fp.read(4)) + + if format & 4: + i16, i32 = b16, b32 + else: + i16, i32 = l16, l32 + + return fp, format, i16, i32 + + def _load_properties(self) -> dict[bytes, bytes | int]: + # + # font properties + + properties = {} + + fp, format, i16, i32 = self._getformat(PCF_PROPERTIES) + + nprops = i32(fp.read(4)) + + # read property description + p = [(i32(fp.read(4)), i8(fp.read(1)), i32(fp.read(4))) for _ in range(nprops)] + + if nprops & 3: + fp.seek(4 - (nprops & 3), io.SEEK_CUR) # pad + + data = fp.read(i32(fp.read(4))) + + for k, s, v in p: + property_value: bytes | int = sz(data, v) if s else v + properties[sz(data, k)] = property_value + + return properties + + def _load_metrics(self) -> list[tuple[int, int, int, int, int, int, int, int]]: + # + # font metrics + + metrics: list[tuple[int, int, int, int, int, int, int, int]] = [] + + fp, format, i16, i32 = self._getformat(PCF_METRICS) + + append = metrics.append + + if (format & 0xFF00) == 0x100: + # "compressed" metrics + for i in range(i16(fp.read(2))): + left = i8(fp.read(1)) - 128 + right = i8(fp.read(1)) - 128 + width = i8(fp.read(1)) - 128 + ascent = i8(fp.read(1)) - 128 + descent = i8(fp.read(1)) - 128 + xsize = right - left + ysize = ascent + descent + append((xsize, ysize, left, right, width, ascent, descent, 0)) + + else: + # "jumbo" metrics + for i in range(i32(fp.read(4))): + left = i16(fp.read(2)) + right = i16(fp.read(2)) + width = i16(fp.read(2)) + ascent = i16(fp.read(2)) + descent = i16(fp.read(2)) + attributes = i16(fp.read(2)) + xsize = right - left + ysize = ascent + descent + append((xsize, ysize, left, right, width, ascent, descent, attributes)) + + return metrics + + def _load_bitmaps( + self, metrics: list[tuple[int, int, int, int, int, int, int, int]] + ) -> list[Image.Image]: + # + # bitmap data + + fp, format, i16, i32 = self._getformat(PCF_BITMAPS) + + nbitmaps = i32(fp.read(4)) + + if nbitmaps != len(metrics): + msg = "Wrong number of bitmaps" + raise OSError(msg) + + offsets = [i32(fp.read(4)) for _ in range(nbitmaps)] + + bitmap_sizes = [i32(fp.read(4)) for _ in range(4)] + + # byteorder = format & 4 # non-zero => MSB + bitorder = format & 8 # non-zero => MSB + padindex = format & 3 + + bitmapsize = bitmap_sizes[padindex] + offsets.append(bitmapsize) + + data = fp.read(bitmapsize) + + pad = BYTES_PER_ROW[padindex] + mode = "1;R" + if bitorder: + mode = "1" + + bitmaps = [] + for i in range(nbitmaps): + xsize, ysize = metrics[i][:2] + b, e = offsets[i : i + 2] + bitmaps.append( + Image.frombytes("1", (xsize, ysize), data[b:e], "raw", mode, pad(xsize)) + ) + + return bitmaps + + def _load_encoding(self) -> list[int | None]: + fp, format, i16, i32 = self._getformat(PCF_BDF_ENCODINGS) + + first_col, last_col = i16(fp.read(2)), i16(fp.read(2)) + first_row, last_row = i16(fp.read(2)), i16(fp.read(2)) + + i16(fp.read(2)) # default + + nencoding = (last_col - first_col + 1) * (last_row - first_row + 1) + + # map character code to bitmap index + encoding: list[int | None] = [None] * min(256, nencoding) + + encoding_offsets = [i16(fp.read(2)) for _ in range(nencoding)] + + for i in range(first_col, len(encoding)): + try: + encoding_offset = encoding_offsets[ + ord(bytearray([i]).decode(self.charset_encoding)) + ] + if encoding_offset != 0xFFFF: + encoding[i] = encoding_offset + except UnicodeDecodeError: # noqa: PERF203 + # character is not supported in selected encoding + pass + + return encoding diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/PcxImagePlugin.py b/presentation/.venv/lib/python3.12/site-packages/PIL/PcxImagePlugin.py new file mode 100644 index 0000000..3e34e3c --- /dev/null +++ b/presentation/.venv/lib/python3.12/site-packages/PIL/PcxImagePlugin.py @@ -0,0 +1,232 @@ +# +# The Python Imaging Library. +# $Id$ +# +# PCX file handling +# +# This format was originally used by ZSoft's popular PaintBrush +# program for the IBM PC. It is also supported by many MS-DOS and +# Windows applications, including the Windows PaintBrush program in +# Windows 3. +# +# history: +# 1995-09-01 fl Created +# 1996-05-20 fl Fixed RGB support +# 1997-01-03 fl Fixed 2-bit and 4-bit support +# 1999-02-03 fl Fixed 8-bit support (broken in 1.0b1) +# 1999-02-07 fl Added write support +# 2002-06-09 fl Made 2-bit and 4-bit support a bit more robust +# 2002-07-30 fl Seek from to current position, not beginning of file +# 2003-06-03 fl Extract DPI settings (info["dpi"]) +# +# Copyright (c) 1997-2003 by Secret Labs AB. +# Copyright (c) 1995-2003 by Fredrik Lundh. +# +# See the README file for information on usage and redistribution. +# +from __future__ import annotations + +import io +import logging +from typing import IO + +from . import Image, ImageFile, ImagePalette +from ._binary import i16le as i16 +from ._binary import o8 +from ._binary import o16le as o16 + +logger = logging.getLogger(__name__) + + +def _accept(prefix: bytes) -> bool: + return len(prefix) >= 2 and prefix[0] == 10 and prefix[1] in [0, 2, 3, 5] + + +## +# Image plugin for Paintbrush images. + + +class PcxImageFile(ImageFile.ImageFile): + format = "PCX" + format_description = "Paintbrush" + + def _open(self) -> None: + # header + assert self.fp is not None + + s = self.fp.read(68) + if not _accept(s): + msg = "not a PCX file" + raise SyntaxError(msg) + + # image + bbox = i16(s, 4), i16(s, 6), i16(s, 8) + 1, i16(s, 10) + 1 + if bbox[2] <= bbox[0] or bbox[3] <= bbox[1]: + msg = "bad PCX image size" + raise SyntaxError(msg) + logger.debug("BBox: %s %s %s %s", *bbox) + + offset = self.fp.tell() + 60 + + # format + version = s[1] + bits = s[3] + planes = s[65] + provided_stride = i16(s, 66) + logger.debug( + "PCX version %s, bits %s, planes %s, stride %s", + version, + bits, + planes, + provided_stride, + ) + + self.info["dpi"] = i16(s, 12), i16(s, 14) + + if bits == 1 and planes == 1: + mode = rawmode = "1" + + elif bits == 1 and planes in (2, 4): + mode = "P" + rawmode = f"P;{planes}L" + self.palette = ImagePalette.raw("RGB", s[16:64]) + + elif version == 5 and bits == 8 and planes == 1: + mode = rawmode = "L" + # FIXME: hey, this doesn't work with the incremental loader !!! + self.fp.seek(-769, io.SEEK_END) + s = self.fp.read(769) + if len(s) == 769 and s[0] == 12: + # check if the palette is linear grayscale + for i in range(256): + if s[i * 3 + 1 : i * 3 + 4] != o8(i) * 3: + mode = rawmode = "P" + break + if mode == "P": + self.palette = ImagePalette.raw("RGB", s[1:]) + + elif version == 5 and bits == 8 and planes == 3: + mode = "RGB" + rawmode = "RGB;L" + + else: + msg = "unknown PCX mode" + raise OSError(msg) + + self._mode = mode + self._size = bbox[2] - bbox[0], bbox[3] - bbox[1] + + # Don't trust the passed in stride. + # Calculate the approximate position for ourselves. + # CVE-2020-35653 + stride = (self._size[0] * bits + 7) // 8 + + # While the specification states that this must be even, + # not all images follow this + if provided_stride != stride: + stride += stride % 2 + + bbox = (0, 0) + self.size + logger.debug("size: %sx%s", *self.size) + + self.tile = [ImageFile._Tile("pcx", bbox, offset, (rawmode, planes * stride))] + + +# -------------------------------------------------------------------- +# save PCX files + + +SAVE = { + # mode: (version, bits, planes, raw mode) + "1": (2, 1, 1, "1"), + "L": (5, 8, 1, "L"), + "P": (5, 8, 1, "P"), + "RGB": (5, 8, 3, "RGB;L"), +} + + +def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: + if im.width == 0 or im.height == 0: + msg = "Cannot write empty image as PCX" + raise ValueError(msg) + + try: + version, bits, planes, rawmode = SAVE[im.mode] + except KeyError as e: + msg = f"Cannot save {im.mode} images as PCX" + raise ValueError(msg) from e + + # bytes per plane + stride = (im.size[0] * bits + 7) // 8 + # stride should be even + stride += stride % 2 + # Stride needs to be kept in sync with the PcxEncode.c version. + # Ideally it should be passed in in the state, but the bytes value + # gets overwritten. + + logger.debug( + "PcxImagePlugin._save: xwidth: %d, bits: %d, stride: %d", + im.size[0], + bits, + stride, + ) + + # under windows, we could determine the current screen size with + # "Image.core.display_mode()[1]", but I think that's overkill... + + screen = im.size + + dpi = 100, 100 + + # PCX header + fp.write( + o8(10) + + o8(version) + + o8(1) + + o8(bits) + + o16(0) + + o16(0) + + o16(im.size[0] - 1) + + o16(im.size[1] - 1) + + o16(dpi[0]) + + o16(dpi[1]) + + b"\0" * 24 + + b"\xff" * 24 + + b"\0" + + o8(planes) + + o16(stride) + + o16(1) + + o16(screen[0]) + + o16(screen[1]) + + b"\0" * 54 + ) + + assert fp.tell() == 128 + + ImageFile._save( + im, fp, [ImageFile._Tile("pcx", (0, 0) + im.size, 0, (rawmode, bits * planes))] + ) + + if im.mode == "P": + # colour palette + fp.write(o8(12)) + palette = im.im.getpalette("RGB", "RGB") + palette += b"\x00" * (768 - len(palette)) + fp.write(palette) # 768 bytes + elif im.mode == "L": + # grayscale palette + fp.write(o8(12)) + for i in range(256): + fp.write(o8(i) * 3) + + +# -------------------------------------------------------------------- +# registry + + +Image.register_open(PcxImageFile.format, PcxImageFile, _accept) +Image.register_save(PcxImageFile.format, _save) + +Image.register_extension(PcxImageFile.format, ".pcx") + +Image.register_mime(PcxImageFile.format, "image/x-pcx") diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/PdfImagePlugin.py b/presentation/.venv/lib/python3.12/site-packages/PIL/PdfImagePlugin.py new file mode 100644 index 0000000..5594c7e --- /dev/null +++ b/presentation/.venv/lib/python3.12/site-packages/PIL/PdfImagePlugin.py @@ -0,0 +1,311 @@ +# +# The Python Imaging Library. +# $Id$ +# +# PDF (Acrobat) file handling +# +# History: +# 1996-07-16 fl Created +# 1997-01-18 fl Fixed header +# 2004-02-21 fl Fixes for 1/L/CMYK images, etc. +# 2004-02-24 fl Fixes for 1 and P images. +# +# Copyright (c) 1997-2004 by Secret Labs AB. All rights reserved. +# Copyright (c) 1996-1997 by Fredrik Lundh. +# +# See the README file for information on usage and redistribution. +# + +## +# Image plugin for PDF images (output only). +## +from __future__ import annotations + +import io +import math +import os +import time +from typing import IO, Any + +from . import Image, ImageFile, ImageSequence, PdfParser, features + +# +# -------------------------------------------------------------------- + +# object ids: +# 1. catalogue +# 2. pages +# 3. image +# 4. page +# 5. page contents + + +def _save_all(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: + _save(im, fp, filename, save_all=True) + + +## +# (Internal) Image save plugin for the PDF format. + + +def _write_image( + im: Image.Image, + filename: str | bytes, + existing_pdf: PdfParser.PdfParser, + image_refs: list[PdfParser.IndirectReference], +) -> tuple[PdfParser.IndirectReference, str]: + # FIXME: Should replace ASCIIHexDecode with RunLengthDecode + # (packbits) or LZWDecode (tiff/lzw compression). Note that + # PDF 1.2 also supports Flatedecode (zip compression). + + params = None + decode = None + + # + # Get image characteristics + + width, height = im.size + + dict_obj: dict[str, Any] = {"BitsPerComponent": 8} + if im.mode == "1": + if features.check("libtiff"): + decode_filter = "CCITTFaxDecode" + dict_obj["BitsPerComponent"] = 1 + params = PdfParser.PdfArray( + [ + PdfParser.PdfDict( + { + "K": -1, + "BlackIs1": True, + "Columns": width, + "Rows": height, + } + ) + ] + ) + else: + decode_filter = "DCTDecode" + dict_obj["ColorSpace"] = PdfParser.PdfName("DeviceGray") + procset = "ImageB" # grayscale + elif im.mode == "L": + decode_filter = "DCTDecode" + # params = f"<< /Predictor 15 /Columns {width-2} >>" + dict_obj["ColorSpace"] = PdfParser.PdfName("DeviceGray") + procset = "ImageB" # grayscale + elif im.mode == "LA": + decode_filter = "JPXDecode" + # params = f"<< /Predictor 15 /Columns {width-2} >>" + procset = "ImageB" # grayscale + dict_obj["SMaskInData"] = 1 + elif im.mode == "P": + decode_filter = "ASCIIHexDecode" + palette = im.getpalette() + assert palette is not None + dict_obj["ColorSpace"] = [ + PdfParser.PdfName("Indexed"), + PdfParser.PdfName("DeviceRGB"), + len(palette) // 3 - 1, + PdfParser.PdfBinary(palette), + ] + procset = "ImageI" # indexed color + + if "transparency" in im.info: + smask = im.convert("LA").getchannel("A") + smask.encoderinfo = {} + + image_ref = _write_image(smask, filename, existing_pdf, image_refs)[0] + dict_obj["SMask"] = image_ref + elif im.mode == "RGB": + decode_filter = "DCTDecode" + dict_obj["ColorSpace"] = PdfParser.PdfName("DeviceRGB") + procset = "ImageC" # color images + elif im.mode == "RGBA": + decode_filter = "JPXDecode" + procset = "ImageC" # color images + dict_obj["SMaskInData"] = 1 + elif im.mode == "CMYK": + decode_filter = "DCTDecode" + dict_obj["ColorSpace"] = PdfParser.PdfName("DeviceCMYK") + procset = "ImageC" # color images + decode = [1, 0, 1, 0, 1, 0, 1, 0] + else: + msg = f"cannot save mode {im.mode}" + raise ValueError(msg) + + # + # image + + op = io.BytesIO() + + if decode_filter == "ASCIIHexDecode": + ImageFile._save(im, op, [ImageFile._Tile("hex", (0, 0) + im.size, 0, im.mode)]) + elif decode_filter == "CCITTFaxDecode": + im.save( + op, + "TIFF", + compression="group4", + # use a single strip + strip_size=math.ceil(width / 8) * height, + ) + elif decode_filter == "DCTDecode": + Image.SAVE["JPEG"](im, op, filename) + elif decode_filter == "JPXDecode": + del dict_obj["BitsPerComponent"] + Image.SAVE["JPEG2000"](im, op, filename) + else: + msg = f"unsupported PDF filter ({decode_filter})" + raise ValueError(msg) + + stream = op.getvalue() + filter: PdfParser.PdfArray | PdfParser.PdfName + if decode_filter == "CCITTFaxDecode": + stream = stream[8:] + filter = PdfParser.PdfArray([PdfParser.PdfName(decode_filter)]) + else: + filter = PdfParser.PdfName(decode_filter) + + image_ref = image_refs.pop(0) + existing_pdf.write_obj( + image_ref, + stream=stream, + Type=PdfParser.PdfName("XObject"), + Subtype=PdfParser.PdfName("Image"), + Width=width, # * 72.0 / x_resolution, + Height=height, # * 72.0 / y_resolution, + Filter=filter, + Decode=decode, + DecodeParms=params, + **dict_obj, + ) + + return image_ref, procset + + +def _save( + im: Image.Image, fp: IO[bytes], filename: str | bytes, save_all: bool = False +) -> None: + is_appending = im.encoderinfo.get("append", False) + filename_str = filename.decode() if isinstance(filename, bytes) else filename + if is_appending: + existing_pdf = PdfParser.PdfParser(f=fp, filename=filename_str, mode="r+b") + else: + existing_pdf = PdfParser.PdfParser(f=fp, filename=filename_str, mode="w+b") + + dpi = im.encoderinfo.get("dpi") + if dpi: + x_resolution = dpi[0] + y_resolution = dpi[1] + else: + x_resolution = y_resolution = im.encoderinfo.get("resolution", 72.0) + + info = { + "title": ( + None if is_appending else os.path.splitext(os.path.basename(filename))[0] + ), + "author": None, + "subject": None, + "keywords": None, + "creator": None, + "producer": None, + "creationDate": None if is_appending else time.gmtime(), + "modDate": None if is_appending else time.gmtime(), + } + for k, default in info.items(): + v = im.encoderinfo.get(k) if k in im.encoderinfo else default + if v: + existing_pdf.info[k[0].upper() + k[1:]] = v + + # + # make sure image data is available + im.load() + + existing_pdf.start_writing() + existing_pdf.write_header() + existing_pdf.write_comment("created by Pillow PDF driver") + + # + # pages + ims = [im] + if save_all: + append_images = im.encoderinfo.get("append_images", []) + for append_im in append_images: + append_im.encoderinfo = im.encoderinfo.copy() + ims.append(append_im) + number_of_pages = 0 + image_refs = [] + page_refs = [] + contents_refs = [] + for im in ims: + im_number_of_pages = 1 + if save_all: + im_number_of_pages = getattr(im, "n_frames", 1) + number_of_pages += im_number_of_pages + for i in range(im_number_of_pages): + image_refs.append(existing_pdf.next_object_id(0)) + if im.mode == "P" and "transparency" in im.info: + image_refs.append(existing_pdf.next_object_id(0)) + + page_refs.append(existing_pdf.next_object_id(0)) + contents_refs.append(existing_pdf.next_object_id(0)) + existing_pdf.pages.append(page_refs[-1]) + + # + # catalog and list of pages + existing_pdf.write_catalog() + + page_number = 0 + for im_sequence in ims: + im_pages: ImageSequence.Iterator | list[Image.Image] = ( + ImageSequence.Iterator(im_sequence) if save_all else [im_sequence] + ) + for im in im_pages: + image_ref, procset = _write_image(im, filename, existing_pdf, image_refs) + + # + # page + + existing_pdf.write_page( + page_refs[page_number], + Resources=PdfParser.PdfDict( + ProcSet=[PdfParser.PdfName("PDF"), PdfParser.PdfName(procset)], + XObject=PdfParser.PdfDict(image=image_ref), + ), + MediaBox=[ + 0, + 0, + im.width * 72.0 / x_resolution, + im.height * 72.0 / y_resolution, + ], + Contents=contents_refs[page_number], + ) + + # + # page contents + + page_contents = b"q %f 0 0 %f 0 0 cm /image Do Q\n" % ( + im.width * 72.0 / x_resolution, + im.height * 72.0 / y_resolution, + ) + + existing_pdf.write_obj(contents_refs[page_number], stream=page_contents) + + page_number += 1 + + # + # trailer + existing_pdf.write_xref_and_trailer() + if hasattr(fp, "flush"): + fp.flush() + existing_pdf.close() + + +# +# -------------------------------------------------------------------- + + +Image.register_save("PDF", _save) +Image.register_save_all("PDF", _save_all) + +Image.register_extension("PDF", ".pdf") + +Image.register_mime("PDF", "application/pdf") diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/PdfParser.py b/presentation/.venv/lib/python3.12/site-packages/PIL/PdfParser.py new file mode 100644 index 0000000..f7f3a46 --- /dev/null +++ b/presentation/.venv/lib/python3.12/site-packages/PIL/PdfParser.py @@ -0,0 +1,1081 @@ +from __future__ import annotations + +import calendar +import codecs +import collections +import mmap +import os +import re +import time +import zlib +from typing import Any, NamedTuple + +TYPE_CHECKING = False +if TYPE_CHECKING: + from typing import IO + + _DictBase = collections.UserDict[str | bytes, Any] +else: + _DictBase = collections.UserDict + + +# see 7.9.2.2 Text String Type on page 86 and D.3 PDFDocEncoding Character Set +# on page 656 +def encode_text(s: str) -> bytes: + return codecs.BOM_UTF16_BE + s.encode("utf_16_be") + + +PDFDocEncoding = { + 0x16: "\u0017", + 0x18: "\u02d8", + 0x19: "\u02c7", + 0x1A: "\u02c6", + 0x1B: "\u02d9", + 0x1C: "\u02dd", + 0x1D: "\u02db", + 0x1E: "\u02da", + 0x1F: "\u02dc", + 0x80: "\u2022", + 0x81: "\u2020", + 0x82: "\u2021", + 0x83: "\u2026", + 0x84: "\u2014", + 0x85: "\u2013", + 0x86: "\u0192", + 0x87: "\u2044", + 0x88: "\u2039", + 0x89: "\u203a", + 0x8A: "\u2212", + 0x8B: "\u2030", + 0x8C: "\u201e", + 0x8D: "\u201c", + 0x8E: "\u201d", + 0x8F: "\u2018", + 0x90: "\u2019", + 0x91: "\u201a", + 0x92: "\u2122", + 0x93: "\ufb01", + 0x94: "\ufb02", + 0x95: "\u0141", + 0x96: "\u0152", + 0x97: "\u0160", + 0x98: "\u0178", + 0x99: "\u017d", + 0x9A: "\u0131", + 0x9B: "\u0142", + 0x9C: "\u0153", + 0x9D: "\u0161", + 0x9E: "\u017e", + 0xA0: "\u20ac", +} + + +def decode_text(b: bytes) -> str: + if b[: len(codecs.BOM_UTF16_BE)] == codecs.BOM_UTF16_BE: + return b[len(codecs.BOM_UTF16_BE) :].decode("utf_16_be") + else: + return "".join(PDFDocEncoding.get(byte, chr(byte)) for byte in b) + + +class PdfFormatError(RuntimeError): + """An error that probably indicates a syntactic or semantic error in the + PDF file structure""" + + pass + + +def check_format_condition(condition: bool, error_message: str) -> None: + if not condition: + raise PdfFormatError(error_message) + + +class IndirectReferenceTuple(NamedTuple): + object_id: int + generation: int + + +class IndirectReference(IndirectReferenceTuple): + def __str__(self) -> str: + return f"{self.object_id} {self.generation} R" + + def __bytes__(self) -> bytes: + return self.__str__().encode("us-ascii") + + def __eq__(self, other: object) -> bool: + if self.__class__ is not other.__class__: + return False + assert isinstance(other, IndirectReference) + return other.object_id == self.object_id and other.generation == self.generation + + def __ne__(self, other: object) -> bool: + return not (self == other) + + def __hash__(self) -> int: + return hash((self.object_id, self.generation)) + + +class IndirectObjectDef(IndirectReference): + def __str__(self) -> str: + return f"{self.object_id} {self.generation} obj" + + +class XrefTable: + def __init__(self) -> None: + self.existing_entries: dict[int, tuple[int, int]] = ( + {} + ) # object ID => (offset, generation) + self.new_entries: dict[int, tuple[int, int]] = ( + {} + ) # object ID => (offset, generation) + self.deleted_entries = {0: 65536} # object ID => generation + self.reading_finished = False + + def __setitem__(self, key: int, value: tuple[int, int]) -> None: + if self.reading_finished: + self.new_entries[key] = value + else: + self.existing_entries[key] = value + if key in self.deleted_entries: + del self.deleted_entries[key] + + def __getitem__(self, key: int) -> tuple[int, int]: + try: + return self.new_entries[key] + except KeyError: + return self.existing_entries[key] + + def __delitem__(self, key: int) -> None: + if key in self.new_entries: + generation = self.new_entries[key][1] + 1 + del self.new_entries[key] + self.deleted_entries[key] = generation + elif key in self.existing_entries: + generation = self.existing_entries[key][1] + 1 + self.deleted_entries[key] = generation + elif key in self.deleted_entries: + generation = self.deleted_entries[key] + else: + msg = f"object ID {key} cannot be deleted because it doesn't exist" + raise IndexError(msg) + + def __contains__(self, key: int) -> bool: + return key in self.existing_entries or key in self.new_entries + + def __len__(self) -> int: + return len( + set(self.existing_entries.keys()) + | set(self.new_entries.keys()) + | set(self.deleted_entries.keys()) + ) + + def keys(self) -> set[int]: + return ( + set(self.existing_entries.keys()) - set(self.deleted_entries.keys()) + ) | set(self.new_entries.keys()) + + def write(self, f: IO[bytes]) -> int: + keys = sorted(set(self.new_entries.keys()) | set(self.deleted_entries.keys())) + deleted_keys = sorted(set(self.deleted_entries.keys())) + startxref = f.tell() + f.write(b"xref\n") + while keys: + # find a contiguous sequence of object IDs + prev: int | None = None + for index, key in enumerate(keys): + if prev is None or prev + 1 == key: + prev = key + else: + contiguous_keys = keys[:index] + keys = keys[index:] + break + else: + contiguous_keys = keys + keys = [] + f.write(b"%d %d\n" % (contiguous_keys[0], len(contiguous_keys))) + for object_id in contiguous_keys: + if object_id in self.new_entries: + f.write(b"%010d %05d n \n" % self.new_entries[object_id]) + else: + this_deleted_object_id = deleted_keys.pop(0) + check_format_condition( + object_id == this_deleted_object_id, + f"expected the next deleted object ID to be {object_id}, " + f"instead found {this_deleted_object_id}", + ) + try: + next_in_linked_list = deleted_keys[0] + except IndexError: + next_in_linked_list = 0 + f.write( + b"%010d %05d f \n" + % (next_in_linked_list, self.deleted_entries[object_id]) + ) + return startxref + + +class PdfName: + name: bytes + + def __init__(self, name: PdfName | bytes | str) -> None: + if isinstance(name, PdfName): + self.name = name.name + elif isinstance(name, bytes): + self.name = name + else: + self.name = name.encode("us-ascii") + + def name_as_str(self) -> str: + return self.name.decode("us-ascii") + + def __eq__(self, other: object) -> bool: + return ( + isinstance(other, PdfName) and other.name == self.name + ) or other == self.name + + def __hash__(self) -> int: + return hash(self.name) + + def __repr__(self) -> str: + return f"{self.__class__.__name__}({repr(self.name)})" + + @classmethod + def from_pdf_stream(cls, data: bytes) -> PdfName: + return cls(PdfParser.interpret_name(data)) + + allowed_chars = set(range(33, 127)) - {ord(c) for c in "#%/()<>[]{}"} + + def __bytes__(self) -> bytes: + result = bytearray(b"/") + for b in self.name: + if b in self.allowed_chars: + result.append(b) + else: + result.extend(b"#%02X" % b) + return bytes(result) + + +class PdfArray(list[Any]): + def __bytes__(self) -> bytes: + return b"[ " + b" ".join(pdf_repr(x) for x in self) + b" ]" + + +class PdfDict(_DictBase): + def __setattr__(self, key: str, value: Any) -> None: + if key == "data": + collections.UserDict.__setattr__(self, key, value) + else: + self[key.encode("us-ascii")] = value + + def __getattr__(self, key: str) -> str | time.struct_time: + try: + value = self[key.encode("us-ascii")] + except KeyError as e: + raise AttributeError(key) from e + if isinstance(value, bytes): + value = decode_text(value) + if key.endswith("Date"): + if value.startswith("D:"): + value = value[2:] + + relationship = "Z" + if len(value) > 17: + relationship = value[14] + offset = int(value[15:17]) * 60 + if len(value) > 20: + offset += int(value[18:20]) + + format = "%Y%m%d%H%M%S"[: len(value) - 2] + value = time.strptime(value[: len(format) + 2], format) + if relationship in ["+", "-"]: + offset *= 60 + if relationship == "+": + offset *= -1 + value = time.gmtime(calendar.timegm(value) + offset) + return value + + def __bytes__(self) -> bytes: + out = bytearray(b"<<") + for key, value in self.items(): + if value is None: + continue + value = pdf_repr(value) + out.extend(b"\n") + out.extend(bytes(PdfName(key))) + out.extend(b" ") + out.extend(value) + out.extend(b"\n>>") + return bytes(out) + + +class PdfBinary: + def __init__(self, data: list[int] | bytes) -> None: + self.data = data + + def __bytes__(self) -> bytes: + return b"<%s>" % b"".join(b"%02X" % b for b in self.data) + + +class PdfStream: + def __init__(self, dictionary: PdfDict, buf: bytes) -> None: + self.dictionary = dictionary + self.buf = buf + + def decode(self) -> bytes: + try: + filter = self.dictionary[b"Filter"] + except KeyError: + return self.buf + if filter == b"FlateDecode": + try: + expected_length = self.dictionary[b"DL"] + except KeyError: + expected_length = self.dictionary[b"Length"] + return zlib.decompress(self.buf, bufsize=int(expected_length)) + else: + msg = f"stream filter {repr(filter)} unknown/unsupported" + raise NotImplementedError(msg) + + +def pdf_repr(x: Any) -> bytes: + if x is True: + return b"true" + elif x is False: + return b"false" + elif x is None: + return b"null" + elif isinstance(x, (PdfName, PdfDict, PdfArray, PdfBinary)): + return bytes(x) + elif isinstance(x, (int, float)): + return str(x).encode("us-ascii") + elif isinstance(x, time.struct_time): + return b"(D:" + time.strftime("%Y%m%d%H%M%SZ", x).encode("us-ascii") + b")" + elif isinstance(x, dict): + return bytes(PdfDict(x)) + elif isinstance(x, list): + return bytes(PdfArray(x)) + elif isinstance(x, str): + return pdf_repr(encode_text(x)) + elif isinstance(x, bytes): + # XXX escape more chars? handle binary garbage + x = x.replace(b"\\", b"\\\\") + x = x.replace(b"(", b"\\(") + x = x.replace(b")", b"\\)") + return b"(" + x + b")" + else: + return bytes(x) + + +class PdfParser: + """Based on + https://www.adobe.com/content/dam/acom/en/devnet/acrobat/pdfs/PDF32000_2008.pdf + Supports PDF up to 1.4 + """ + + def __init__( + self, + filename: str | None = None, + f: IO[bytes] | None = None, + buf: bytes | bytearray | None = None, + start_offset: int = 0, + mode: str = "rb", + ) -> None: + if buf and f: + msg = "specify buf or f or filename, but not both buf and f" + raise RuntimeError(msg) + self.filename = filename + self.buf: bytes | bytearray | mmap.mmap | None = buf + self.f = f + self.start_offset = start_offset + self.should_close_buf = False + self.should_close_file = False + if filename is not None and f is None: + self.f = f = open(filename, mode) + self.should_close_file = True + if f is not None: + self.buf = self.get_buf_from_file(f) + self.should_close_buf = True + if not filename and hasattr(f, "name"): + self.filename = f.name + self.cached_objects: dict[IndirectReference, Any] = {} + self.root_ref: IndirectReference | None + self.info_ref: IndirectReference | None + self.pages_ref: IndirectReference | None + self.last_xref_section_offset: int | None + if self.buf: + self.read_pdf_info() + else: + self.file_size_total = self.file_size_this = 0 + self.root = PdfDict() + self.root_ref = None + self.info = PdfDict() + self.info_ref = None + self.page_tree_root = PdfDict() + self.pages: list[IndirectReference] = [] + self.orig_pages: list[IndirectReference] = [] + self.pages_ref = None + self.last_xref_section_offset = None + self.trailer_dict: dict[bytes, Any] = {} + self.xref_table = XrefTable() + self.xref_table.reading_finished = True + if f: + self.seek_end() + + def __enter__(self) -> PdfParser: + return self + + def __exit__(self, *args: object) -> None: + self.close() + + def start_writing(self) -> None: + self.close_buf() + self.seek_end() + + def close_buf(self) -> None: + if isinstance(self.buf, mmap.mmap): + self.buf.close() + self.buf = None + + def close(self) -> None: + if self.should_close_buf: + self.close_buf() + if self.f is not None and self.should_close_file: + self.f.close() + self.f = None + + def seek_end(self) -> None: + assert self.f is not None + self.f.seek(0, os.SEEK_END) + + def write_header(self) -> None: + assert self.f is not None + self.f.write(b"%PDF-1.4\n") + + def write_comment(self, s: str) -> None: + assert self.f is not None + self.f.write(f"% {s}\n".encode()) + + def write_catalog(self) -> IndirectReference: + assert self.f is not None + self.del_root() + self.root_ref = self.next_object_id(self.f.tell()) + self.pages_ref = self.next_object_id(0) + self.rewrite_pages() + self.write_obj(self.root_ref, Type=PdfName(b"Catalog"), Pages=self.pages_ref) + self.write_obj( + self.pages_ref, + Type=PdfName(b"Pages"), + Count=len(self.pages), + Kids=self.pages, + ) + return self.root_ref + + def rewrite_pages(self) -> None: + pages_tree_nodes_to_delete = [] + for i, page_ref in enumerate(self.orig_pages): + page_info = self.cached_objects[page_ref] + del self.xref_table[page_ref.object_id] + pages_tree_nodes_to_delete.append(page_info[PdfName(b"Parent")]) + if page_ref not in self.pages: + # the page has been deleted + continue + # make dict keys into strings for passing to write_page + stringified_page_info = {} + for key, value in page_info.items(): + # key should be a PdfName + stringified_page_info[key.name_as_str()] = value + stringified_page_info["Parent"] = self.pages_ref + new_page_ref = self.write_page(None, **stringified_page_info) + for j, cur_page_ref in enumerate(self.pages): + if cur_page_ref == page_ref: + # replace the page reference with the new one + self.pages[j] = new_page_ref + # delete redundant Pages tree nodes from xref table + for pages_tree_node_ref in pages_tree_nodes_to_delete: + while pages_tree_node_ref: + pages_tree_node = self.cached_objects[pages_tree_node_ref] + if pages_tree_node_ref.object_id in self.xref_table: + del self.xref_table[pages_tree_node_ref.object_id] + pages_tree_node_ref = pages_tree_node.get(b"Parent", None) + self.orig_pages = [] + + def write_xref_and_trailer( + self, new_root_ref: IndirectReference | None = None + ) -> None: + assert self.f is not None + if new_root_ref: + self.del_root() + self.root_ref = new_root_ref + if self.info: + self.info_ref = self.write_obj(None, self.info) + start_xref = self.xref_table.write(self.f) + num_entries = len(self.xref_table) + trailer_dict: dict[str | bytes, Any] = { + b"Root": self.root_ref, + b"Size": num_entries, + } + if self.last_xref_section_offset is not None: + trailer_dict[b"Prev"] = self.last_xref_section_offset + if self.info: + trailer_dict[b"Info"] = self.info_ref + self.last_xref_section_offset = start_xref + self.f.write( + b"trailer\n" + + bytes(PdfDict(trailer_dict)) + + b"\nstartxref\n%d\n%%%%EOF" % start_xref + ) + + def write_page( + self, ref: int | IndirectReference | None, *objs: Any, **dict_obj: Any + ) -> IndirectReference: + obj_ref = self.pages[ref] if isinstance(ref, int) else ref + if "Type" not in dict_obj: + dict_obj["Type"] = PdfName(b"Page") + if "Parent" not in dict_obj: + dict_obj["Parent"] = self.pages_ref + return self.write_obj(obj_ref, *objs, **dict_obj) + + def write_obj( + self, ref: IndirectReference | None, *objs: Any, **dict_obj: Any + ) -> IndirectReference: + assert self.f is not None + f = self.f + if ref is None: + ref = self.next_object_id(f.tell()) + else: + self.xref_table[ref.object_id] = (f.tell(), ref.generation) + f.write(bytes(IndirectObjectDef(*ref))) + stream = dict_obj.pop("stream", None) + if stream is not None: + dict_obj["Length"] = len(stream) + if dict_obj: + f.write(pdf_repr(dict_obj)) + for obj in objs: + f.write(pdf_repr(obj)) + if stream is not None: + f.write(b"stream\n") + f.write(stream) + f.write(b"\nendstream\n") + f.write(b"endobj\n") + return ref + + def del_root(self) -> None: + if self.root_ref is None: + return + del self.xref_table[self.root_ref.object_id] + del self.xref_table[self.root[b"Pages"].object_id] + + @staticmethod + def get_buf_from_file(f: IO[bytes]) -> bytes | mmap.mmap: + if hasattr(f, "getbuffer"): + return f.getbuffer() + elif hasattr(f, "getvalue"): + return f.getvalue() + else: + try: + return mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_READ) + except ValueError: # cannot mmap an empty file + return b"" + + def read_pdf_info(self) -> None: + assert self.buf is not None + self.file_size_total = len(self.buf) + self.file_size_this = self.file_size_total - self.start_offset + self.read_trailer() + check_format_condition( + self.trailer_dict.get(b"Root") is not None, "Root is missing" + ) + self.root_ref = self.trailer_dict[b"Root"] + assert self.root_ref is not None + self.info_ref = self.trailer_dict.get(b"Info", None) + self.root = PdfDict(self.read_indirect(self.root_ref)) + if self.info_ref is None: + self.info = PdfDict() + else: + self.info = PdfDict(self.read_indirect(self.info_ref)) + check_format_condition(b"Type" in self.root, "/Type missing in Root") + check_format_condition( + self.root[b"Type"] == b"Catalog", "/Type in Root is not /Catalog" + ) + check_format_condition( + self.root.get(b"Pages") is not None, "/Pages missing in Root" + ) + check_format_condition( + isinstance(self.root[b"Pages"], IndirectReference), + "/Pages in Root is not an indirect reference", + ) + self.pages_ref = self.root[b"Pages"] + assert self.pages_ref is not None + self.page_tree_root = self.read_indirect(self.pages_ref) + self.pages = self.linearize_page_tree(self.page_tree_root) + # save the original list of page references + # in case the user modifies, adds or deletes some pages + # and we need to rewrite the pages and their list + self.orig_pages = self.pages[:] + + def next_object_id(self, offset: int | None = None) -> IndirectReference: + try: + # TODO: support reuse of deleted objects + reference = IndirectReference(max(self.xref_table.keys()) + 1, 0) + except ValueError: + reference = IndirectReference(1, 0) + if offset is not None: + self.xref_table[reference.object_id] = (offset, 0) + return reference + + delimiter = rb"[][()<>{}/%]" + delimiter_or_ws = rb"[][()<>{}/%\000\011\012\014\015\040]" + whitespace = rb"[\000\011\012\014\015\040]" + whitespace_or_hex = rb"[\000\011\012\014\015\0400-9a-fA-F]" + whitespace_optional = whitespace + b"*" + whitespace_mandatory = whitespace + b"+" + # No "\012" aka "\n" or "\015" aka "\r": + whitespace_optional_no_nl = rb"[\000\011\014\040]*" + newline_only = rb"[\r\n]+" + newline = whitespace_optional_no_nl + newline_only + whitespace_optional_no_nl + re_trailer_end = re.compile( + whitespace_mandatory + + rb"trailer" + + whitespace_optional + + rb"<<(.*>>)" + + newline + + rb"startxref" + + newline + + rb"([0-9]+)" + + newline + + rb"%%EOF" + + whitespace_optional + + rb"$", + re.DOTALL, + ) + re_trailer_prev = re.compile( + whitespace_optional + + rb"trailer" + + whitespace_optional + + rb"<<(.*?>>)" + + newline + + rb"startxref" + + newline + + rb"([0-9]+)" + + newline + + rb"%%EOF" + + whitespace_optional, + re.DOTALL, + ) + + def read_trailer(self) -> None: + assert self.buf is not None + search_start_offset = len(self.buf) - 16384 + if search_start_offset < self.start_offset: + search_start_offset = self.start_offset + m = self.re_trailer_end.search(self.buf, search_start_offset) + check_format_condition(m is not None, "trailer end not found") + # make sure we found the LAST trailer + last_match = m + while m: + last_match = m + m = self.re_trailer_end.search(self.buf, m.start() + 16) + if not m: + m = last_match + assert m is not None + trailer_data = m.group(1) + self.last_xref_section_offset = int(m.group(2)) + self.trailer_dict = self.interpret_trailer(trailer_data) + self.xref_table = XrefTable() + self.read_xref_table(xref_section_offset=self.last_xref_section_offset) + if b"Prev" in self.trailer_dict: + self.read_prev_trailer(self.trailer_dict[b"Prev"]) + + def read_prev_trailer( + self, xref_section_offset: int, processed_offsets: list[int] = [] + ) -> None: + assert self.buf is not None + trailer_offset = self.read_xref_table(xref_section_offset=xref_section_offset) + m = self.re_trailer_prev.search( + self.buf[trailer_offset : trailer_offset + 16384] + ) + check_format_condition(m is not None, "previous trailer not found") + assert m is not None + trailer_data = m.group(1) + check_format_condition( + int(m.group(2)) == xref_section_offset, + "xref section offset in previous trailer doesn't match what was expected", + ) + trailer_dict = self.interpret_trailer(trailer_data) + if b"Prev" in trailer_dict: + processed_offsets.append(xref_section_offset) + check_format_condition( + trailer_dict[b"Prev"] not in processed_offsets, "trailer loop found" + ) + self.read_prev_trailer(trailer_dict[b"Prev"], processed_offsets) + + re_whitespace_optional = re.compile(whitespace_optional) + re_name = re.compile( + whitespace_optional + + rb"/([!-$&'*-.0-;=?-Z\\^-z|~]+)(?=" + + delimiter_or_ws + + rb")" + ) + re_dict_start = re.compile(whitespace_optional + rb"<<") + re_dict_end = re.compile(whitespace_optional + rb">>" + whitespace_optional) + + @classmethod + def interpret_trailer(cls, trailer_data: bytes) -> dict[bytes, Any]: + trailer = {} + offset = 0 + while True: + m = cls.re_name.match(trailer_data, offset) + if not m: + m = cls.re_dict_end.match(trailer_data, offset) + check_format_condition( + m is not None and m.end() == len(trailer_data), + "name not found in trailer, remaining data: " + + repr(trailer_data[offset:]), + ) + break + key = cls.interpret_name(m.group(1)) + assert isinstance(key, bytes) + value, value_offset = cls.get_value(trailer_data, m.end()) + trailer[key] = value + if value_offset is None: + break + offset = value_offset + check_format_condition( + b"Size" in trailer and isinstance(trailer[b"Size"], int), + "/Size not in trailer or not an integer", + ) + check_format_condition( + b"Root" in trailer and isinstance(trailer[b"Root"], IndirectReference), + "/Root not in trailer or not an indirect reference", + ) + return trailer + + re_hashes_in_name = re.compile(rb"([^#]*)(#([0-9a-fA-F]{2}))?") + + @classmethod + def interpret_name(cls, raw: bytes, as_text: bool = False) -> str | bytes: + name = b"" + for m in cls.re_hashes_in_name.finditer(raw): + if m.group(3): + name += m.group(1) + bytearray.fromhex(m.group(3).decode("us-ascii")) + else: + name += m.group(1) + if as_text: + return name.decode("utf-8") + else: + return bytes(name) + + re_null = re.compile(whitespace_optional + rb"null(?=" + delimiter_or_ws + rb")") + re_true = re.compile(whitespace_optional + rb"true(?=" + delimiter_or_ws + rb")") + re_false = re.compile(whitespace_optional + rb"false(?=" + delimiter_or_ws + rb")") + re_int = re.compile( + whitespace_optional + rb"([-+]?[0-9]+)(?=" + delimiter_or_ws + rb")" + ) + re_real = re.compile( + whitespace_optional + + rb"([-+]?([0-9]+\.[0-9]*|[0-9]*\.[0-9]+))(?=" + + delimiter_or_ws + + rb")" + ) + re_array_start = re.compile(whitespace_optional + rb"\[") + re_array_end = re.compile(whitespace_optional + rb"]") + re_string_hex = re.compile( + whitespace_optional + rb"<(" + whitespace_or_hex + rb"*)>" + ) + re_string_lit = re.compile(whitespace_optional + rb"\(") + re_indirect_reference = re.compile( + whitespace_optional + + rb"([-+]?[0-9]+)" + + whitespace_mandatory + + rb"([-+]?[0-9]+)" + + whitespace_mandatory + + rb"R(?=" + + delimiter_or_ws + + rb")" + ) + re_indirect_def_start = re.compile( + whitespace_optional + + rb"([-+]?[0-9]+)" + + whitespace_mandatory + + rb"([-+]?[0-9]+)" + + whitespace_mandatory + + rb"obj(?=" + + delimiter_or_ws + + rb")" + ) + re_indirect_def_end = re.compile( + whitespace_optional + rb"endobj(?=" + delimiter_or_ws + rb")" + ) + re_comment = re.compile( + rb"(" + whitespace_optional + rb"%[^\r\n]*" + newline + rb")*" + ) + re_stream_start = re.compile(whitespace_optional + rb"stream\r?\n") + re_stream_end = re.compile( + whitespace_optional + rb"endstream(?=" + delimiter_or_ws + rb")" + ) + + @classmethod + def get_value( + cls, + data: bytes | bytearray | mmap.mmap, + offset: int, + expect_indirect: IndirectReference | None = None, + max_nesting: int = -1, + ) -> tuple[Any, int | None]: + if max_nesting == 0: + return None, None + m = cls.re_comment.match(data, offset) + if m: + offset = m.end() + m = cls.re_indirect_def_start.match(data, offset) + if m: + check_format_condition( + int(m.group(1)) > 0, + "indirect object definition: object ID must be greater than 0", + ) + check_format_condition( + int(m.group(2)) >= 0, + "indirect object definition: generation must be non-negative", + ) + check_format_condition( + expect_indirect is None + or expect_indirect + == IndirectReference(int(m.group(1)), int(m.group(2))), + "indirect object definition different than expected", + ) + object, object_offset = cls.get_value( + data, m.end(), max_nesting=max_nesting - 1 + ) + if object_offset is None: + return object, None + m = cls.re_indirect_def_end.match(data, object_offset) + check_format_condition( + m is not None, "indirect object definition end not found" + ) + assert m is not None + return object, m.end() + check_format_condition( + not expect_indirect, "indirect object definition not found" + ) + m = cls.re_indirect_reference.match(data, offset) + if m: + check_format_condition( + int(m.group(1)) > 0, + "indirect object reference: object ID must be greater than 0", + ) + check_format_condition( + int(m.group(2)) >= 0, + "indirect object reference: generation must be non-negative", + ) + return IndirectReference(int(m.group(1)), int(m.group(2))), m.end() + m = cls.re_dict_start.match(data, offset) + if m: + offset = m.end() + result: dict[Any, Any] = {} + m = cls.re_dict_end.match(data, offset) + current_offset: int | None = offset + while not m: + assert current_offset is not None + key, current_offset = cls.get_value( + data, current_offset, max_nesting=max_nesting - 1 + ) + if current_offset is None: + return result, None + value, current_offset = cls.get_value( + data, current_offset, max_nesting=max_nesting - 1 + ) + result[key] = value + if current_offset is None: + return result, None + m = cls.re_dict_end.match(data, current_offset) + current_offset = m.end() + m = cls.re_stream_start.match(data, current_offset) + if m: + stream_len = result.get(b"Length") + if stream_len is None or not isinstance(stream_len, int): + msg = f"bad or missing Length in stream dict ({stream_len})" + raise PdfFormatError(msg) + stream_data = data[m.end() : m.end() + stream_len] + m = cls.re_stream_end.match(data, m.end() + stream_len) + check_format_condition(m is not None, "stream end not found") + assert m is not None + current_offset = m.end() + return PdfStream(PdfDict(result), stream_data), current_offset + return PdfDict(result), current_offset + m = cls.re_array_start.match(data, offset) + if m: + offset = m.end() + results = [] + m = cls.re_array_end.match(data, offset) + current_offset = offset + while not m: + assert current_offset is not None + value, current_offset = cls.get_value( + data, current_offset, max_nesting=max_nesting - 1 + ) + results.append(value) + if current_offset is None: + return results, None + m = cls.re_array_end.match(data, current_offset) + return results, m.end() + m = cls.re_null.match(data, offset) + if m: + return None, m.end() + m = cls.re_true.match(data, offset) + if m: + return True, m.end() + m = cls.re_false.match(data, offset) + if m: + return False, m.end() + m = cls.re_name.match(data, offset) + if m: + return PdfName(cls.interpret_name(m.group(1))), m.end() + m = cls.re_int.match(data, offset) + if m: + return int(m.group(1)), m.end() + m = cls.re_real.match(data, offset) + if m: + # XXX Decimal instead of float??? + return float(m.group(1)), m.end() + m = cls.re_string_hex.match(data, offset) + if m: + # filter out whitespace + hex_string = bytearray( + b for b in m.group(1) if b in b"0123456789abcdefABCDEF" + ) + if len(hex_string) % 2 == 1: + # append a 0 if the length is not even - yes, at the end + hex_string.append(ord(b"0")) + return bytearray.fromhex(hex_string.decode("us-ascii")), m.end() + m = cls.re_string_lit.match(data, offset) + if m: + return cls.get_literal_string(data, m.end()) + # return None, offset # fallback (only for debugging) + msg = f"unrecognized object: {repr(data[offset : offset + 32])}" + raise PdfFormatError(msg) + + re_lit_str_token = re.compile( + rb"(\\[nrtbf()\\])|(\\[0-9]{1,3})|(\\(\r\n|\r|\n))|(\r\n|\r|\n)|(\()|(\))" + ) + escaped_chars = { + b"n": b"\n", + b"r": b"\r", + b"t": b"\t", + b"b": b"\b", + b"f": b"\f", + b"(": b"(", + b")": b")", + b"\\": b"\\", + ord(b"n"): b"\n", + ord(b"r"): b"\r", + ord(b"t"): b"\t", + ord(b"b"): b"\b", + ord(b"f"): b"\f", + ord(b"("): b"(", + ord(b")"): b")", + ord(b"\\"): b"\\", + } + + @classmethod + def get_literal_string( + cls, data: bytes | bytearray | mmap.mmap, offset: int + ) -> tuple[bytes, int]: + nesting_depth = 0 + result = bytearray() + for m in cls.re_lit_str_token.finditer(data, offset): + result.extend(data[offset : m.start()]) + if m.group(1): + result.extend(cls.escaped_chars[m.group(1)[1]]) + elif m.group(2): + result.append(int(m.group(2)[1:], 8)) + elif m.group(3): + pass + elif m.group(5): + result.extend(b"\n") + elif m.group(6): + result.extend(b"(") + nesting_depth += 1 + elif m.group(7): + if nesting_depth == 0: + return bytes(result), m.end() + result.extend(b")") + nesting_depth -= 1 + offset = m.end() + msg = "unfinished literal string" + raise PdfFormatError(msg) + + re_xref_section_start = re.compile(whitespace_optional + rb"xref" + newline) + re_xref_subsection_start = re.compile( + whitespace_optional + + rb"([0-9]+)" + + whitespace_mandatory + + rb"([0-9]+)" + + whitespace_optional + + newline_only + ) + re_xref_entry = re.compile(rb"([0-9]{10}) ([0-9]{5}) ([fn])( \r| \n|\r\n)") + + def read_xref_table(self, xref_section_offset: int) -> int: + assert self.buf is not None + subsection_found = False + m = self.re_xref_section_start.match( + self.buf, xref_section_offset + self.start_offset + ) + check_format_condition(m is not None, "xref section start not found") + assert m is not None + offset = m.end() + while True: + m = self.re_xref_subsection_start.match(self.buf, offset) + if not m: + check_format_condition( + subsection_found, "xref subsection start not found" + ) + break + subsection_found = True + offset = m.end() + first_object = int(m.group(1)) + num_objects = int(m.group(2)) + for i in range(first_object, first_object + num_objects): + m = self.re_xref_entry.match(self.buf, offset) + check_format_condition(m is not None, "xref entry not found") + assert m is not None + offset = m.end() + is_free = m.group(3) == b"f" + if not is_free: + generation = int(m.group(2)) + new_entry = (int(m.group(1)), generation) + if i not in self.xref_table: + self.xref_table[i] = new_entry + return offset + + def read_indirect(self, ref: IndirectReference, max_nesting: int = -1) -> Any: + offset, generation = self.xref_table[ref[0]] + check_format_condition( + generation == ref[1], + f"expected to find generation {ref[1]} for object ID {ref[0]} in xref " + f"table, instead found generation {generation} at offset {offset}", + ) + assert self.buf is not None + value = self.get_value( + self.buf, + offset + self.start_offset, + expect_indirect=IndirectReference(*ref), + max_nesting=max_nesting, + )[0] + self.cached_objects[ref] = value + return value + + def linearize_page_tree( + self, node: PdfDict | None = None + ) -> list[IndirectReference]: + page_node = node if node is not None else self.page_tree_root + check_format_condition( + page_node[b"Type"] == b"Pages", "/Type of page tree node is not /Pages" + ) + pages = [] + for kid in page_node[b"Kids"]: + kid_object = self.read_indirect(kid) + if kid_object[b"Type"] == b"Page": + pages.append(kid) + else: + pages.extend(self.linearize_page_tree(node=kid_object)) + return pages diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/PixarImagePlugin.py b/presentation/.venv/lib/python3.12/site-packages/PIL/PixarImagePlugin.py new file mode 100644 index 0000000..d2b6d0a --- /dev/null +++ b/presentation/.venv/lib/python3.12/site-packages/PIL/PixarImagePlugin.py @@ -0,0 +1,72 @@ +# +# The Python Imaging Library. +# $Id$ +# +# PIXAR raster support for PIL +# +# history: +# 97-01-29 fl Created +# +# notes: +# This is incomplete; it is based on a few samples created with +# Photoshop 2.5 and 3.0, and a summary description provided by +# Greg Coats <gcoats@labiris.er.usgs.gov>. Hopefully, "L" and +# "RGBA" support will be added in future versions. +# +# Copyright (c) Secret Labs AB 1997. +# Copyright (c) Fredrik Lundh 1997. +# +# See the README file for information on usage and redistribution. +# +from __future__ import annotations + +from . import Image, ImageFile +from ._binary import i16le as i16 + +# +# helpers + + +def _accept(prefix: bytes) -> bool: + return prefix.startswith(b"\200\350\000\000") + + +## +# Image plugin for PIXAR raster images. + + +class PixarImageFile(ImageFile.ImageFile): + format = "PIXAR" + format_description = "PIXAR raster image" + + def _open(self) -> None: + # assuming a 4-byte magic label + assert self.fp is not None + + s = self.fp.read(4) + if not _accept(s): + msg = "not a PIXAR file" + raise SyntaxError(msg) + + # read rest of header + s = s + self.fp.read(508) + + self._size = i16(s, 418), i16(s, 416) + + # get channel/depth descriptions + mode = i16(s, 424), i16(s, 426) + + if mode == (14, 2): + self._mode = "RGB" + # FIXME: to be continued... + + # create tile descriptor (assuming "dumped") + self.tile = [ImageFile._Tile("raw", (0, 0) + self.size, 1024, self.mode)] + + +# +# -------------------------------------------------------------------- + +Image.register_open(PixarImageFile.format, PixarImageFile, _accept) + +Image.register_extension(PixarImageFile.format, ".pxr") diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/PngImagePlugin.py b/presentation/.venv/lib/python3.12/site-packages/PIL/PngImagePlugin.py new file mode 100644 index 0000000..76a15bd --- /dev/null +++ b/presentation/.venv/lib/python3.12/site-packages/PIL/PngImagePlugin.py @@ -0,0 +1,1563 @@ +# +# The Python Imaging Library. +# $Id$ +# +# PNG support code +# +# See "PNG (Portable Network Graphics) Specification, version 1.0; +# W3C Recommendation", 1996-10-01, Thomas Boutell (ed.). +# +# history: +# 1996-05-06 fl Created (couldn't resist it) +# 1996-12-14 fl Upgraded, added read and verify support (0.2) +# 1996-12-15 fl Separate PNG stream parser +# 1996-12-29 fl Added write support, added getchunks +# 1996-12-30 fl Eliminated circular references in decoder (0.3) +# 1998-07-12 fl Read/write 16-bit images as mode I (0.4) +# 2001-02-08 fl Added transparency support (from Zircon) (0.5) +# 2001-04-16 fl Don't close data source in "open" method (0.6) +# 2004-02-24 fl Don't even pretend to support interlaced files (0.7) +# 2004-08-31 fl Do basic sanity check on chunk identifiers (0.8) +# 2004-09-20 fl Added PngInfo chunk container +# 2004-12-18 fl Added DPI read support (based on code by Niki Spahiev) +# 2008-08-13 fl Added tRNS support for RGB images +# 2009-03-06 fl Support for preserving ICC profiles (by Florian Hoech) +# 2009-03-08 fl Added zTXT support (from Lowell Alleman) +# 2009-03-29 fl Read interlaced PNG files (from Conrado Porto Lopes Gouvua) +# +# Copyright (c) 1997-2009 by Secret Labs AB +# Copyright (c) 1996 by Fredrik Lundh +# +# See the README file for information on usage and redistribution. +# +from __future__ import annotations + +import itertools +import logging +import re +import struct +import warnings +import zlib +from enum import IntEnum +from fractions import Fraction +from typing import IO, NamedTuple, cast + +from . import Image, ImageChops, ImageFile, ImagePalette, ImageSequence +from ._binary import i16be as i16 +from ._binary import i32be as i32 +from ._binary import o8 +from ._binary import o16be as o16 +from ._binary import o32be as o32 +from ._deprecate import deprecate +from ._util import DeferredError + +TYPE_CHECKING = False +if TYPE_CHECKING: + from collections.abc import Callable + from typing import Any, NoReturn + + from . import _imaging + +logger = logging.getLogger(__name__) + +is_cid = re.compile(rb"\w\w\w\w").match + + +_MAGIC = b"\211PNG\r\n\032\n" + + +_MODES = { + # supported bits/color combinations, and corresponding modes/rawmodes + # Grayscale + (1, 0): ("1", "1"), + (2, 0): ("L", "L;2"), + (4, 0): ("L", "L;4"), + (8, 0): ("L", "L"), + (16, 0): ("I;16", "I;16B"), + # Truecolour + (8, 2): ("RGB", "RGB"), + (16, 2): ("RGB", "RGB;16B"), + # Indexed-colour + (1, 3): ("P", "P;1"), + (2, 3): ("P", "P;2"), + (4, 3): ("P", "P;4"), + (8, 3): ("P", "P"), + # Grayscale with alpha + (8, 4): ("LA", "LA"), + (16, 4): ("RGBA", "LA;16B"), # LA;16B->LA not yet available + # Truecolour with alpha + (8, 6): ("RGBA", "RGBA"), + (16, 6): ("RGBA", "RGBA;16B"), +} + + +_simple_palette = re.compile(b"^\xff*\x00\xff*$") + +MAX_TEXT_CHUNK = ImageFile.SAFEBLOCK +""" +Maximum decompressed size for a iTXt or zTXt chunk. +Eliminates decompression bombs where compressed chunks can expand 1000x. +See :ref:`Text in PNG File Format<png-text>`. +""" +MAX_TEXT_MEMORY = 64 * MAX_TEXT_CHUNK +""" +Set the maximum total text chunk size. +See :ref:`Text in PNG File Format<png-text>`. +""" + + +# APNG frame disposal modes +class Disposal(IntEnum): + OP_NONE = 0 + """ + No disposal is done on this frame before rendering the next frame. + See :ref:`Saving APNG sequences<apng-saving>`. + """ + OP_BACKGROUND = 1 + """ + This frame’s modified region is cleared to fully transparent black before rendering + the next frame. + See :ref:`Saving APNG sequences<apng-saving>`. + """ + OP_PREVIOUS = 2 + """ + This frame’s modified region is reverted to the previous frame’s contents before + rendering the next frame. + See :ref:`Saving APNG sequences<apng-saving>`. + """ + + +# APNG frame blend modes +class Blend(IntEnum): + OP_SOURCE = 0 + """ + All color components of this frame, including alpha, overwrite the previous output + image contents. + See :ref:`Saving APNG sequences<apng-saving>`. + """ + OP_OVER = 1 + """ + This frame should be alpha composited with the previous output image contents. + See :ref:`Saving APNG sequences<apng-saving>`. + """ + + +def _safe_zlib_decompress(s: bytes) -> bytes: + dobj = zlib.decompressobj() + plaintext = dobj.decompress(s, MAX_TEXT_CHUNK) + if dobj.unconsumed_tail: + msg = "Decompressed data too large for PngImagePlugin.MAX_TEXT_CHUNK" + raise ValueError(msg) + return plaintext + + +def _crc32(data: bytes, seed: int = 0) -> int: + return zlib.crc32(data, seed) & 0xFFFFFFFF + + +# -------------------------------------------------------------------- +# Support classes. Suitable for PNG and related formats like MNG etc. + + +class ChunkStream: + def __init__(self, fp: IO[bytes]) -> None: + self.fp: IO[bytes] | None = fp + self.queue: list[tuple[bytes, int, int]] | None = [] + + def read(self) -> tuple[bytes, int, int]: + """Fetch a new chunk. Returns header information.""" + cid = None + + assert self.fp is not None + if self.queue: + cid, pos, length = self.queue.pop() + self.fp.seek(pos) + else: + s = self.fp.read(8) + cid = s[4:] + pos = self.fp.tell() + length = i32(s) + + if not is_cid(cid): + if not ImageFile.LOAD_TRUNCATED_IMAGES: + msg = f"broken PNG file (chunk {repr(cid)})" + raise SyntaxError(msg) + + return cid, pos, length + + def __enter__(self) -> ChunkStream: + return self + + def __exit__(self, *args: object) -> None: + self.close() + + def close(self) -> None: + self.queue = self.fp = None + + def push(self, cid: bytes, pos: int, length: int) -> None: + assert self.queue is not None + self.queue.append((cid, pos, length)) + + def call(self, cid: bytes, pos: int, length: int) -> bytes: + """Call the appropriate chunk handler""" + + logger.debug("STREAM %r %s %s", cid, pos, length) + return getattr(self, f"chunk_{cid.decode('ascii')}")(pos, length) + + def crc(self, cid: bytes, data: bytes) -> None: + """Read and verify checksum""" + + # Skip CRC checks for ancillary chunks if allowed to load truncated + # images + # 5th byte of first char is 1 [specs, section 5.4] + if ImageFile.LOAD_TRUNCATED_IMAGES and (cid[0] >> 5 & 1): + self.crc_skip(cid, data) + return + + assert self.fp is not None + try: + crc1 = _crc32(data, _crc32(cid)) + crc2 = i32(self.fp.read(4)) + if crc1 != crc2: + msg = f"broken PNG file (bad header checksum in {repr(cid)})" + raise SyntaxError(msg) + except struct.error as e: + msg = f"broken PNG file (incomplete checksum in {repr(cid)})" + raise SyntaxError(msg) from e + + def crc_skip(self, cid: bytes, data: bytes) -> None: + """Read checksum""" + + assert self.fp is not None + self.fp.read(4) + + def verify(self, endchunk: bytes = b"IEND") -> list[bytes]: + # Simple approach; just calculate checksum for all remaining + # blocks. Must be called directly after open. + + cids = [] + + assert self.fp is not None + while True: + try: + cid, pos, length = self.read() + except struct.error as e: + msg = "truncated PNG file" + raise OSError(msg) from e + + if cid == endchunk: + break + self.crc(cid, ImageFile._safe_read(self.fp, length)) + cids.append(cid) + + return cids + + +class iTXt(str): + """ + Subclass of string to allow iTXt chunks to look like strings while + keeping their extra information + + """ + + lang: str | bytes | None + tkey: str | bytes | None + + @staticmethod + def __new__( + cls, text: str, lang: str | None = None, tkey: str | None = None + ) -> iTXt: + """ + :param cls: the class to use when creating the instance + :param text: value for this key + :param lang: language code + :param tkey: UTF-8 version of the key name + """ + + self = str.__new__(cls, text) + self.lang = lang + self.tkey = tkey + return self + + +class PngInfo: + """ + PNG chunk container (for use with save(pnginfo=)) + + """ + + def __init__(self) -> None: + self.chunks: list[tuple[bytes, bytes, bool]] = [] + + def add(self, cid: bytes, data: bytes, after_idat: bool = False) -> None: + """Appends an arbitrary chunk. Use with caution. + + :param cid: a byte string, 4 bytes long. + :param data: a byte string of the encoded data + :param after_idat: for use with private chunks. Whether the chunk + should be written after IDAT + + """ + + self.chunks.append((cid, data, after_idat)) + + def add_itxt( + self, + key: str | bytes, + value: str | bytes, + lang: str | bytes = "", + tkey: str | bytes = "", + zip: bool = False, + ) -> None: + """Appends an iTXt chunk. + + :param key: latin-1 encodable text key name + :param value: value for this key + :param lang: language code + :param tkey: UTF-8 version of the key name + :param zip: compression flag + + """ + + if not isinstance(key, bytes): + key = key.encode("latin-1", "strict") + if not isinstance(value, bytes): + value = value.encode("utf-8", "strict") + if not isinstance(lang, bytes): + lang = lang.encode("utf-8", "strict") + if not isinstance(tkey, bytes): + tkey = tkey.encode("utf-8", "strict") + + if zip: + self.add( + b"iTXt", + key + b"\0\x01\0" + lang + b"\0" + tkey + b"\0" + zlib.compress(value), + ) + else: + self.add(b"iTXt", key + b"\0\0\0" + lang + b"\0" + tkey + b"\0" + value) + + def add_text( + self, key: str | bytes, value: str | bytes | iTXt, zip: bool = False + ) -> None: + """Appends a text chunk. + + :param key: latin-1 encodable text key name + :param value: value for this key, text or an + :py:class:`PIL.PngImagePlugin.iTXt` instance + :param zip: compression flag + + """ + if isinstance(value, iTXt): + return self.add_itxt( + key, + value, + value.lang if value.lang is not None else b"", + value.tkey if value.tkey is not None else b"", + zip=zip, + ) + + # The tEXt chunk stores latin-1 text + if not isinstance(value, bytes): + try: + value = value.encode("latin-1", "strict") + except UnicodeError: + return self.add_itxt(key, value, zip=zip) + + if not isinstance(key, bytes): + key = key.encode("latin-1", "strict") + + if zip: + self.add(b"zTXt", key + b"\0\0" + zlib.compress(value)) + else: + self.add(b"tEXt", key + b"\0" + value) + + +# -------------------------------------------------------------------- +# PNG image stream (IHDR/IEND) + + +class _RewindState(NamedTuple): + info: dict[str | tuple[int, int], Any] + tile: list[ImageFile._Tile] + seq_num: int | None + + +class PngStream(ChunkStream): + def __init__(self, fp: IO[bytes]) -> None: + super().__init__(fp) + + # local copies of Image attributes + self.im_info: dict[str | tuple[int, int], Any] = {} + self.im_text: dict[str, str | iTXt] = {} + self.im_size = (0, 0) + self.im_mode = "" + self.im_tile: list[ImageFile._Tile] = [] + self.im_palette: tuple[str, bytes] | None = None + self.im_custom_mimetype: str | None = None + self.im_n_frames: int | None = None + self._seq_num: int | None = None + self.rewind_state = _RewindState({}, [], None) + + self.text_memory = 0 + + def check_text_memory(self, chunklen: int) -> None: + self.text_memory += chunklen + if self.text_memory > MAX_TEXT_MEMORY: + msg = ( + "Too much memory used in text chunks: " + f"{self.text_memory}>MAX_TEXT_MEMORY" + ) + raise ValueError(msg) + + def save_rewind(self) -> None: + self.rewind_state = _RewindState( + self.im_info.copy(), + self.im_tile, + self._seq_num, + ) + + def rewind(self) -> None: + self.im_info = self.rewind_state.info.copy() + self.im_tile = self.rewind_state.tile + self._seq_num = self.rewind_state.seq_num + + def chunk_iCCP(self, pos: int, length: int) -> bytes: + # ICC profile + assert self.fp is not None + s = ImageFile._safe_read(self.fp, length) + # according to PNG spec, the iCCP chunk contains: + # Profile name 1-79 bytes (character string) + # Null separator 1 byte (null character) + # Compression method 1 byte (0) + # Compressed profile n bytes (zlib with deflate compression) + i = s.find(b"\0") + logger.debug("iCCP profile name %r", s[:i]) + comp_method = s[i + 1] + logger.debug("Compression method %s", comp_method) + if comp_method != 0: + msg = f"Unknown compression method {comp_method} in iCCP chunk" + raise SyntaxError(msg) + try: + icc_profile = _safe_zlib_decompress(s[i + 2 :]) + except ValueError: + if ImageFile.LOAD_TRUNCATED_IMAGES: + icc_profile = None + else: + raise + except zlib.error: + icc_profile = None # FIXME + self.im_info["icc_profile"] = icc_profile + return s + + def chunk_IHDR(self, pos: int, length: int) -> bytes: + # image header + assert self.fp is not None + s = ImageFile._safe_read(self.fp, length) + if length < 13: + if ImageFile.LOAD_TRUNCATED_IMAGES: + return s + msg = "Truncated IHDR chunk" + raise ValueError(msg) + self.im_size = i32(s, 0), i32(s, 4) + try: + self.im_mode, self.im_rawmode = _MODES[(s[8], s[9])] + except Exception: + pass + if s[12]: + self.im_info["interlace"] = 1 + if s[11]: + msg = "unknown filter category" + raise SyntaxError(msg) + return s + + def chunk_IDAT(self, pos: int, length: int) -> NoReturn: + # image data + if "bbox" in self.im_info: + tile = [ImageFile._Tile("zip", self.im_info["bbox"], pos, self.im_rawmode)] + else: + if self.im_n_frames is not None: + self.im_info["default_image"] = True + tile = [ImageFile._Tile("zip", (0, 0) + self.im_size, pos, self.im_rawmode)] + self.im_tile = tile + self.im_idat = length + msg = "image data found" + raise EOFError(msg) + + def chunk_IEND(self, pos: int, length: int) -> NoReturn: + msg = "end of PNG image" + raise EOFError(msg) + + def chunk_PLTE(self, pos: int, length: int) -> bytes: + # palette + assert self.fp is not None + s = ImageFile._safe_read(self.fp, length) + if self.im_mode == "P": + self.im_palette = "RGB", s + return s + + def chunk_tRNS(self, pos: int, length: int) -> bytes: + # transparency + assert self.fp is not None + s = ImageFile._safe_read(self.fp, length) + if self.im_mode == "P": + if _simple_palette.match(s): + # tRNS contains only one full-transparent entry, + # other entries are full opaque + i = s.find(b"\0") + if i >= 0: + self.im_info["transparency"] = i + else: + # otherwise, we have a byte string with one alpha value + # for each palette entry + self.im_info["transparency"] = s + elif self.im_mode == "1": + self.im_info["transparency"] = 255 if i16(s) else 0 + elif self.im_mode in ("L", "I;16"): + self.im_info["transparency"] = i16(s) + elif self.im_mode == "RGB": + self.im_info["transparency"] = i16(s), i16(s, 2), i16(s, 4) + return s + + def chunk_gAMA(self, pos: int, length: int) -> bytes: + # gamma setting + assert self.fp is not None + s = ImageFile._safe_read(self.fp, length) + self.im_info["gamma"] = i32(s) / 100000.0 + return s + + def chunk_cHRM(self, pos: int, length: int) -> bytes: + # chromaticity, 8 unsigned ints, actual value is scaled by 100,000 + # WP x,y, Red x,y, Green x,y Blue x,y + + assert self.fp is not None + s = ImageFile._safe_read(self.fp, length) + raw_vals = struct.unpack(f">{len(s) // 4}I", s) + self.im_info["chromaticity"] = tuple(elt / 100000.0 for elt in raw_vals) + return s + + def chunk_sRGB(self, pos: int, length: int) -> bytes: + # srgb rendering intent, 1 byte + # 0 perceptual + # 1 relative colorimetric + # 2 saturation + # 3 absolute colorimetric + + assert self.fp is not None + s = ImageFile._safe_read(self.fp, length) + if length < 1: + if ImageFile.LOAD_TRUNCATED_IMAGES: + return s + msg = "Truncated sRGB chunk" + raise ValueError(msg) + self.im_info["srgb"] = s[0] + return s + + def chunk_pHYs(self, pos: int, length: int) -> bytes: + # pixels per unit + assert self.fp is not None + s = ImageFile._safe_read(self.fp, length) + if length < 9: + if ImageFile.LOAD_TRUNCATED_IMAGES: + return s + msg = "Truncated pHYs chunk" + raise ValueError(msg) + px, py = i32(s, 0), i32(s, 4) + unit = s[8] + if unit == 1: # meter + dpi = px * 0.0254, py * 0.0254 + self.im_info["dpi"] = dpi + elif unit == 0: + self.im_info["aspect"] = px, py + return s + + def chunk_tEXt(self, pos: int, length: int) -> bytes: + # text + assert self.fp is not None + s = ImageFile._safe_read(self.fp, length) + try: + k, v = s.split(b"\0", 1) + except ValueError: + # fallback for broken tEXt tags + k = s + v = b"" + if k: + k_str = k.decode("latin-1", "strict") + v_str = v.decode("latin-1", "replace") + + self.im_info[k_str] = v if k == b"exif" else v_str + self.im_text[k_str] = v_str + self.check_text_memory(len(v_str)) + + return s + + def chunk_zTXt(self, pos: int, length: int) -> bytes: + # compressed text + assert self.fp is not None + s = ImageFile._safe_read(self.fp, length) + try: + k, v = s.split(b"\0", 1) + except ValueError: + k = s + v = b"" + if v: + comp_method = v[0] + else: + comp_method = 0 + if comp_method != 0: + msg = f"Unknown compression method {comp_method} in zTXt chunk" + raise SyntaxError(msg) + try: + v = _safe_zlib_decompress(v[1:]) + except ValueError: + if ImageFile.LOAD_TRUNCATED_IMAGES: + v = b"" + else: + raise + except zlib.error: + v = b"" + + if k: + k_str = k.decode("latin-1", "strict") + v_str = v.decode("latin-1", "replace") + + self.im_info[k_str] = self.im_text[k_str] = v_str + self.check_text_memory(len(v_str)) + + return s + + def chunk_iTXt(self, pos: int, length: int) -> bytes: + # international text + assert self.fp is not None + r = s = ImageFile._safe_read(self.fp, length) + try: + k, r = r.split(b"\0", 1) + except ValueError: + return s + if len(r) < 2: + return s + cf, cm, r = r[0], r[1], r[2:] + try: + lang, tk, v = r.split(b"\0", 2) + except ValueError: + return s + if cf != 0: + if cm == 0: + try: + v = _safe_zlib_decompress(v) + except ValueError: + if ImageFile.LOAD_TRUNCATED_IMAGES: + return s + else: + raise + except zlib.error: + return s + else: + return s + if k == b"XML:com.adobe.xmp": + self.im_info["xmp"] = v + try: + k_str = k.decode("latin-1", "strict") + lang_str = lang.decode("utf-8", "strict") + tk_str = tk.decode("utf-8", "strict") + v_str = v.decode("utf-8", "strict") + except UnicodeError: + return s + + self.im_info[k_str] = self.im_text[k_str] = iTXt(v_str, lang_str, tk_str) + self.check_text_memory(len(v_str)) + + return s + + def chunk_eXIf(self, pos: int, length: int) -> bytes: + assert self.fp is not None + s = ImageFile._safe_read(self.fp, length) + self.im_info["exif"] = b"Exif\x00\x00" + s + return s + + # APNG chunks + def chunk_acTL(self, pos: int, length: int) -> bytes: + assert self.fp is not None + s = ImageFile._safe_read(self.fp, length) + if length < 8: + if ImageFile.LOAD_TRUNCATED_IMAGES: + return s + msg = "APNG contains truncated acTL chunk" + raise ValueError(msg) + if self.im_n_frames is not None: + self.im_n_frames = None + warnings.warn("Invalid APNG, will use default PNG image if possible") + return s + n_frames = i32(s) + if n_frames == 0 or n_frames > 0x80000000: + warnings.warn("Invalid APNG, will use default PNG image if possible") + return s + self.im_n_frames = n_frames + self.im_info["loop"] = i32(s, 4) + self.im_custom_mimetype = "image/apng" + return s + + def chunk_fcTL(self, pos: int, length: int) -> bytes: + assert self.fp is not None + s = ImageFile._safe_read(self.fp, length) + if length < 26: + if ImageFile.LOAD_TRUNCATED_IMAGES: + return s + msg = "APNG contains truncated fcTL chunk" + raise ValueError(msg) + seq = i32(s) + if (self._seq_num is None and seq != 0) or ( + self._seq_num is not None and self._seq_num != seq - 1 + ): + msg = "APNG contains frame sequence errors" + raise SyntaxError(msg) + self._seq_num = seq + width, height = i32(s, 4), i32(s, 8) + px, py = i32(s, 12), i32(s, 16) + im_w, im_h = self.im_size + if px + width > im_w or py + height > im_h: + msg = "APNG contains invalid frames" + raise SyntaxError(msg) + self.im_info["bbox"] = (px, py, px + width, py + height) + delay_num, delay_den = i16(s, 20), i16(s, 22) + if delay_den == 0: + delay_den = 100 + self.im_info["duration"] = float(delay_num) / float(delay_den) * 1000 + self.im_info["disposal"] = s[24] + self.im_info["blend"] = s[25] + return s + + def chunk_fdAT(self, pos: int, length: int) -> bytes: + assert self.fp is not None + if length < 4: + if ImageFile.LOAD_TRUNCATED_IMAGES: + s = ImageFile._safe_read(self.fp, length) + return s + msg = "APNG contains truncated fDAT chunk" + raise ValueError(msg) + s = ImageFile._safe_read(self.fp, 4) + seq = i32(s) + if self._seq_num != seq - 1: + msg = "APNG contains frame sequence errors" + raise SyntaxError(msg) + self._seq_num = seq + return self.chunk_IDAT(pos + 4, length - 4) + + +# -------------------------------------------------------------------- +# PNG reader + + +def _accept(prefix: bytes) -> bool: + return prefix.startswith(_MAGIC) + + +## +# Image plugin for PNG images. + + +class PngImageFile(ImageFile.ImageFile): + format = "PNG" + format_description = "Portable network graphics" + + def _open(self) -> None: + assert self.fp is not None + if not _accept(self.fp.read(8)): + msg = "not a PNG file" + raise SyntaxError(msg) + self._fp = self.fp + self.__frame = 0 + + # + # Parse headers up to the first IDAT or fDAT chunk + + self.private_chunks: list[tuple[bytes, bytes] | tuple[bytes, bytes, bool]] = [] + self.png: PngStream | None = PngStream(self.fp) + + while True: + # + # get next chunk + + cid, pos, length = self.png.read() + + try: + s = self.png.call(cid, pos, length) + except EOFError: + break + except AttributeError: + logger.debug("%r %s %s (unknown)", cid, pos, length) + s = ImageFile._safe_read(self.fp, length) + if cid[1:2].islower(): + self.private_chunks.append((cid, s)) + + self.png.crc(cid, s) + + # + # Copy relevant attributes from the PngStream. An alternative + # would be to let the PngStream class modify these attributes + # directly, but that introduces circular references which are + # difficult to break if things go wrong in the decoder... + # (believe me, I've tried ;-) + + self._mode = self.png.im_mode + self._size = self.png.im_size + self.info = self.png.im_info + self._text: dict[str, str | iTXt] | None = None + self.tile = self.png.im_tile + self.custom_mimetype = self.png.im_custom_mimetype + self.n_frames = self.png.im_n_frames or 1 + self.default_image = self.info.get("default_image", False) + + if self.png.im_palette: + rawmode, data = self.png.im_palette + self.palette = ImagePalette.raw(rawmode, data) + + if cid == b"fdAT": + self.__prepare_idat = length - 4 + else: + self.__prepare_idat = length # used by load_prepare() + + if self.png.im_n_frames is not None: + self._close_exclusive_fp_after_loading = False + self.png.save_rewind() + self.__rewind_idat = self.__prepare_idat + self.__rewind = self._fp.tell() + if self.default_image: + # IDAT chunk contains default image and not first animation frame + self.n_frames += 1 + self._seek(0) + self.is_animated = self.n_frames > 1 + + @property + def text(self) -> dict[str, str | iTXt]: + # experimental + if self._text is None: + # iTxt, tEXt and zTXt chunks may appear at the end of the file + # So load the file to ensure that they are read + if self.is_animated: + frame = self.__frame + # for APNG, seek to the final frame before loading + self.seek(self.n_frames - 1) + self.load() + if self.is_animated: + self.seek(frame) + assert self._text is not None + return self._text + + def verify(self) -> None: + """Verify PNG file""" + + if self.fp is None: + msg = "verify must be called directly after open" + raise RuntimeError(msg) + + # back up to beginning of IDAT block + self.fp.seek(self.tile[0][2] - 8) + + assert self.png is not None + self.png.verify() + self.png.close() + + super().verify() + + def seek(self, frame: int) -> None: + if not self._seek_check(frame): + return + if frame < self.__frame: + self._seek(0, True) + + last_frame = self.__frame + try: + for f in range(self.__frame + 1, frame + 1): + self._seek(f) + except EOFError as e: + self.seek(last_frame) + msg = "no more images in APNG file" + raise EOFError(msg) from e + + def _seek(self, frame: int, rewind: bool = False) -> None: + assert self.png is not None + if isinstance(self._fp, DeferredError): + raise self._fp.ex + + self.dispose: _imaging.ImagingCore | None + dispose_extent = None + if frame == 0: + if rewind: + self._fp.seek(self.__rewind) + self.png.rewind() + self.__prepare_idat = self.__rewind_idat + self._im = None + self.info = self.png.im_info + self.tile = self.png.im_tile + self.fp = self._fp + self._prev_im = None + self.dispose = None + self.default_image = self.info.get("default_image", False) + self.dispose_op = self.info.get("disposal") + self.blend_op = self.info.get("blend") + dispose_extent = self.info.get("bbox") + self.__frame = 0 + else: + if frame != self.__frame + 1: + msg = f"cannot seek to frame {frame}" + raise ValueError(msg) + + # ensure previous frame was loaded + self.load() + + if self.dispose: + self.im.paste(self.dispose, self.dispose_extent) + self._prev_im = self.im.copy() + + self.fp = self._fp + + # advance to the next frame + if self.__prepare_idat: + ImageFile._safe_read(self.fp, self.__prepare_idat) + self.__prepare_idat = 0 + frame_start = False + while True: + self.fp.read(4) # CRC + + try: + cid, pos, length = self.png.read() + except (struct.error, SyntaxError): + break + + if cid == b"IEND": + msg = "No more images in APNG file" + raise EOFError(msg) + if cid == b"fcTL": + if frame_start: + # there must be at least one fdAT chunk between fcTL chunks + msg = "APNG missing frame data" + raise SyntaxError(msg) + frame_start = True + + try: + self.png.call(cid, pos, length) + except UnicodeDecodeError: + break + except EOFError: + if cid == b"fdAT": + length -= 4 + if frame_start: + self.__prepare_idat = length + break + ImageFile._safe_read(self.fp, length) + except AttributeError: + logger.debug("%r %s %s (unknown)", cid, pos, length) + ImageFile._safe_read(self.fp, length) + + self.__frame = frame + self.tile = self.png.im_tile + self.dispose_op = self.info.get("disposal") + self.blend_op = self.info.get("blend") + dispose_extent = self.info.get("bbox") + + if not self.tile: + msg = "image not found in APNG frame" + raise EOFError(msg) + if dispose_extent: + self.dispose_extent: tuple[float, float, float, float] = dispose_extent + + # setup frame disposal (actual disposal done when needed in the next _seek()) + if self._prev_im is None and self.dispose_op == Disposal.OP_PREVIOUS: + self.dispose_op = Disposal.OP_BACKGROUND + + self.dispose = None + if self.dispose_op == Disposal.OP_PREVIOUS: + if self._prev_im: + self.dispose = self._prev_im.copy() + self.dispose = self._crop(self.dispose, self.dispose_extent) + elif self.dispose_op == Disposal.OP_BACKGROUND: + self.dispose = Image.core.fill(self.mode, self.size) + self.dispose = self._crop(self.dispose, self.dispose_extent) + + def tell(self) -> int: + return self.__frame + + def load_prepare(self) -> None: + """internal: prepare to read PNG file""" + + if self.info.get("interlace"): + self.decoderconfig = self.decoderconfig + (1,) + + self.__idat = self.__prepare_idat # used by load_read() + ImageFile.ImageFile.load_prepare(self) + + def load_read(self, read_bytes: int) -> bytes: + """internal: read more image data""" + + assert self.png is not None + assert self.fp is not None + while self.__idat == 0: + # end of chunk, skip forward to next one + + self.fp.read(4) # CRC + + cid, pos, length = self.png.read() + + if cid not in [b"IDAT", b"DDAT", b"fdAT"]: + self.png.push(cid, pos, length) + return b"" + + if cid == b"fdAT": + try: + self.png.call(cid, pos, length) + except EOFError: + pass + self.__idat = length - 4 # sequence_num has already been read + else: + self.__idat = length # empty chunks are allowed + + # read more data from this chunk + if read_bytes <= 0: + read_bytes = self.__idat + else: + read_bytes = min(read_bytes, self.__idat) + + self.__idat = self.__idat - read_bytes + + return self.fp.read(read_bytes) + + def load_end(self) -> None: + """internal: finished reading image data""" + assert self.png is not None + assert self.fp is not None + if self.__idat != 0: + self.fp.read(self.__idat) + while True: + self.fp.read(4) # CRC + + try: + cid, pos, length = self.png.read() + except (struct.error, SyntaxError): + break + + if cid == b"IEND": + break + elif cid == b"fcTL" and self.is_animated: + # start of the next frame, stop reading + self.__prepare_idat = 0 + self.png.push(cid, pos, length) + break + + try: + self.png.call(cid, pos, length) + except UnicodeDecodeError: + break + except EOFError: + if cid == b"fdAT": + length -= 4 + try: + ImageFile._safe_read(self.fp, length) + except OSError as e: + if ImageFile.LOAD_TRUNCATED_IMAGES: + break + else: + raise e + except AttributeError: + logger.debug("%r %s %s (unknown)", cid, pos, length) + s = ImageFile._safe_read(self.fp, length) + if cid[1:2].islower(): + self.private_chunks.append((cid, s, True)) + self._text = self.png.im_text + if not self.is_animated: + self.png.close() + self.png = None + else: + if self._prev_im and self.blend_op == Blend.OP_OVER: + updated = self._crop(self.im, self.dispose_extent) + if self.im.mode == "RGB" and "transparency" in self.info: + mask = updated.convert_transparent( + "RGBA", self.info["transparency"] + ) + else: + if self.im.mode == "P" and "transparency" in self.info: + t = self.info["transparency"] + if isinstance(t, bytes): + updated.putpalettealphas(t) + elif isinstance(t, int): + updated.putpalettealpha(t) + mask = updated.convert("RGBA") + self._prev_im.paste(updated, self.dispose_extent, mask) + self.im = self._prev_im + + def _getexif(self) -> dict[int, Any] | None: + if "exif" not in self.info: + self.load() + if "exif" not in self.info and "Raw profile type exif" not in self.info: + return None + return self.getexif()._get_merged_dict() + + def getexif(self) -> Image.Exif: + if "exif" not in self.info: + self.load() + + return super().getexif() + + +# -------------------------------------------------------------------- +# PNG writer + +_OUTMODES = { + # supported PIL modes, and corresponding rawmode, bit depth and color type + "1": ("1", b"\x01", b"\x00"), + "L;1": ("L;1", b"\x01", b"\x00"), + "L;2": ("L;2", b"\x02", b"\x00"), + "L;4": ("L;4", b"\x04", b"\x00"), + "L": ("L", b"\x08", b"\x00"), + "LA": ("LA", b"\x08", b"\x04"), + "I": ("I;16B", b"\x10", b"\x00"), + "I;16": ("I;16B", b"\x10", b"\x00"), + "I;16B": ("I;16B", b"\x10", b"\x00"), + "P;1": ("P;1", b"\x01", b"\x03"), + "P;2": ("P;2", b"\x02", b"\x03"), + "P;4": ("P;4", b"\x04", b"\x03"), + "P": ("P", b"\x08", b"\x03"), + "RGB": ("RGB", b"\x08", b"\x02"), + "RGBA": ("RGBA", b"\x08", b"\x06"), +} + + +def putchunk(fp: IO[bytes], cid: bytes, *data: bytes) -> None: + """Write a PNG chunk (including CRC field)""" + + byte_data = b"".join(data) + + fp.write(o32(len(byte_data)) + cid) + fp.write(byte_data) + crc = _crc32(byte_data, _crc32(cid)) + fp.write(o32(crc)) + + +class _idat: + # wrap output from the encoder in IDAT chunks + + def __init__(self, fp: IO[bytes], chunk: Callable[..., None]) -> None: + self.fp = fp + self.chunk = chunk + + def write(self, data: bytes) -> None: + self.chunk(self.fp, b"IDAT", data) + + +class _fdat: + # wrap encoder output in fdAT chunks + + def __init__(self, fp: IO[bytes], chunk: Callable[..., None], seq_num: int) -> None: + self.fp = fp + self.chunk = chunk + self.seq_num = seq_num + + def write(self, data: bytes) -> None: + self.chunk(self.fp, b"fdAT", o32(self.seq_num), data) + self.seq_num += 1 + + +def _apply_encoderinfo(im: Image.Image, encoderinfo: dict[str, Any]) -> None: + im.encoderconfig = ( + encoderinfo.get("optimize", False), + encoderinfo.get("compress_level", -1), + encoderinfo.get("compress_type", -1), + encoderinfo.get("dictionary", b""), + ) + + +class _Frame(NamedTuple): + im: Image.Image + bbox: tuple[int, int, int, int] | None + encoderinfo: dict[str, Any] + + +def _write_multiple_frames( + im: Image.Image, + fp: IO[bytes], + chunk: Callable[..., None], + mode: str, + rawmode: str, + default_image: Image.Image | None, + append_images: list[Image.Image], +) -> Image.Image | None: + duration = im.encoderinfo.get("duration") + loop = im.encoderinfo.get("loop", im.info.get("loop", 0)) + disposal = im.encoderinfo.get("disposal", im.info.get("disposal", Disposal.OP_NONE)) + blend = im.encoderinfo.get("blend", im.info.get("blend", Blend.OP_SOURCE)) + + if default_image: + chain = itertools.chain(append_images) + else: + chain = itertools.chain([im], append_images) + + im_frames: list[_Frame] = [] + frame_count = 0 + for im_seq in chain: + for im_frame in ImageSequence.Iterator(im_seq): + if im_frame.mode == mode: + im_frame = im_frame.copy() + else: + im_frame = im_frame.convert(mode) + encoderinfo = im.encoderinfo.copy() + if isinstance(duration, (list, tuple)): + encoderinfo["duration"] = duration[frame_count] + elif duration is None and "duration" in im_frame.info: + encoderinfo["duration"] = im_frame.info["duration"] + if isinstance(disposal, (list, tuple)): + encoderinfo["disposal"] = disposal[frame_count] + if isinstance(blend, (list, tuple)): + encoderinfo["blend"] = blend[frame_count] + frame_count += 1 + + if im_frames: + previous = im_frames[-1] + prev_disposal = previous.encoderinfo.get("disposal") + prev_blend = previous.encoderinfo.get("blend") + if prev_disposal == Disposal.OP_PREVIOUS and len(im_frames) < 2: + prev_disposal = Disposal.OP_BACKGROUND + + if prev_disposal == Disposal.OP_BACKGROUND: + base_im = previous.im.copy() + dispose = Image.core.fill("RGBA", im.size, (0, 0, 0, 0)) + bbox = previous.bbox + if bbox: + dispose = dispose.crop(bbox) + else: + bbox = (0, 0) + im.size + base_im.paste(dispose, bbox) + elif prev_disposal == Disposal.OP_PREVIOUS: + base_im = im_frames[-2].im + else: + base_im = previous.im + delta = ImageChops.subtract_modulo( + im_frame.convert("RGBA"), base_im.convert("RGBA") + ) + bbox = delta.getbbox(alpha_only=False) + if ( + not bbox + and prev_disposal == encoderinfo.get("disposal") + and prev_blend == encoderinfo.get("blend") + and "duration" in encoderinfo + ): + previous.encoderinfo["duration"] += encoderinfo["duration"] + continue + else: + bbox = None + im_frames.append(_Frame(im_frame, bbox, encoderinfo)) + + if len(im_frames) == 1 and not default_image: + return im_frames[0].im + + # animation control + chunk( + fp, + b"acTL", + o32(len(im_frames)), # 0: num_frames + o32(loop), # 4: num_plays + ) + + # default image IDAT (if it exists) + if default_image: + default_im = im if im.mode == mode else im.convert(mode) + _apply_encoderinfo(default_im, im.encoderinfo) + ImageFile._save( + default_im, + cast(IO[bytes], _idat(fp, chunk)), + [ImageFile._Tile("zip", (0, 0) + im.size, 0, rawmode)], + ) + + seq_num = 0 + for frame, frame_data in enumerate(im_frames): + im_frame = frame_data.im + if not frame_data.bbox: + bbox = (0, 0) + im_frame.size + else: + bbox = frame_data.bbox + im_frame = im_frame.crop(bbox) + size = im_frame.size + encoderinfo = frame_data.encoderinfo + frame_duration = encoderinfo.get("duration", 0) + delay = Fraction(frame_duration / 1000).limit_denominator(65535) + if delay.numerator > 65535: + msg = "cannot write duration" + raise ValueError(msg) + frame_disposal = encoderinfo.get("disposal", disposal) + frame_blend = encoderinfo.get("blend", blend) + # frame control + chunk( + fp, + b"fcTL", + o32(seq_num), # sequence_number + o32(size[0]), # width + o32(size[1]), # height + o32(bbox[0]), # x_offset + o32(bbox[1]), # y_offset + o16(delay.numerator), # delay_numerator + o16(delay.denominator), # delay_denominator + o8(frame_disposal), # dispose_op + o8(frame_blend), # blend_op + ) + seq_num += 1 + # frame data + _apply_encoderinfo(im_frame, im.encoderinfo) + if frame == 0 and not default_image: + # first frame must be in IDAT chunks for backwards compatibility + ImageFile._save( + im_frame, + cast(IO[bytes], _idat(fp, chunk)), + [ImageFile._Tile("zip", (0, 0) + im_frame.size, 0, rawmode)], + ) + else: + fdat_chunks = _fdat(fp, chunk, seq_num) + ImageFile._save( + im_frame, + cast(IO[bytes], fdat_chunks), + [ImageFile._Tile("zip", (0, 0) + im_frame.size, 0, rawmode)], + ) + seq_num = fdat_chunks.seq_num + return None + + +def _save_all(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: + _save(im, fp, filename, save_all=True) + + +def _save( + im: Image.Image, + fp: IO[bytes], + filename: str | bytes, + chunk: Callable[..., None] = putchunk, + save_all: bool = False, +) -> None: + # save an image to disk (called by the save method) + + if save_all: + default_image = im.encoderinfo.get( + "default_image", im.info.get("default_image") + ) + modes = set() + sizes = set() + append_images = im.encoderinfo.get("append_images", []) + for im_seq in itertools.chain([im], append_images): + for im_frame in ImageSequence.Iterator(im_seq): + modes.add(im_frame.mode) + sizes.add(im_frame.size) + for mode in ("RGBA", "RGB", "P"): + if mode in modes: + break + else: + mode = modes.pop() + size = tuple(max(frame_size[i] for frame_size in sizes) for i in range(2)) + else: + size = im.size + mode = im.mode + + outmode = mode + palette = [] + if im.palette: + palette = im.getpalette() or [] + if mode == "P": + # + # attempt to minimize storage requirements for palette images + if "bits" in im.encoderinfo: + # number of bits specified by user + colors = min(1 << im.encoderinfo["bits"], 256) + else: + # check palette contents + if im.palette: + colors = max(min(len(palette) // 3, 256), 1) + else: + colors = 256 + + if colors <= 16: + if colors <= 2: + bits = 1 + elif colors <= 4: + bits = 2 + else: + bits = 4 + outmode += f";{bits}" + + # get the corresponding PNG mode + try: + rawmode, bit_depth, color_type = _OUTMODES[outmode] + except KeyError as e: + msg = f"cannot write mode {mode} as PNG" + raise OSError(msg) from e + if outmode == "I": + deprecate("Saving I mode images as PNG", 13, stacklevel=4) + + # + # write minimal PNG file + + fp.write(_MAGIC) + + chunk( + fp, + b"IHDR", + o32(size[0]), # 0: size + o32(size[1]), + bit_depth, + color_type, + b"\0", # 10: compression + b"\0", # 11: filter category + b"\0", # 12: interlace flag + ) + + chunks = [b"cHRM", b"cICP", b"gAMA", b"sBIT", b"sRGB", b"tIME"] + + if icc := im.encoderinfo.get("icc_profile", im.info.get("icc_profile")): + # ICC profile + # according to PNG spec, the iCCP chunk contains: + # Profile name 1-79 bytes (character string) + # Null separator 1 byte (null character) + # Compression method 1 byte (0) + # Compressed profile n bytes (zlib with deflate compression) + name = b"ICC Profile" + data = name + b"\0\0" + zlib.compress(icc) + chunk(fp, b"iCCP", data) + + # You must either have sRGB or iCCP. + # Disallow sRGB chunks when an iCCP-chunk has been emitted. + chunks.remove(b"sRGB") + + if info := im.encoderinfo.get("pnginfo"): + chunks_multiple_allowed = [b"sPLT", b"iTXt", b"tEXt", b"zTXt"] + for info_chunk in info.chunks: + cid, data = info_chunk[:2] + if cid in chunks: + chunks.remove(cid) + chunk(fp, cid, data) + elif cid in chunks_multiple_allowed: + chunk(fp, cid, data) + elif cid[1:2].islower(): + # Private chunk + after_idat = len(info_chunk) == 3 and info_chunk[2] + if not after_idat: + chunk(fp, cid, data) + + if im.mode == "P": + palette_byte_number = colors * 3 + palette_bytes = bytes(palette[:palette_byte_number]) + while len(palette_bytes) < palette_byte_number: + palette_bytes += b"\0" + chunk(fp, b"PLTE", palette_bytes) + + transparency = im.encoderinfo.get("transparency", im.info.get("transparency", None)) + + if transparency or transparency == 0: + if im.mode == "P": + # limit to actual palette size + alpha_bytes = colors + if isinstance(transparency, bytes): + chunk(fp, b"tRNS", transparency[:alpha_bytes]) + else: + transparency = max(0, min(255, transparency)) + alpha = b"\xff" * transparency + b"\0" + chunk(fp, b"tRNS", alpha[:alpha_bytes]) + elif im.mode in ("1", "L", "I", "I;16"): + transparency = max(0, min(65535, transparency)) + chunk(fp, b"tRNS", o16(transparency)) + elif im.mode == "RGB": + red, green, blue = transparency + chunk(fp, b"tRNS", o16(red) + o16(green) + o16(blue)) + else: + if "transparency" in im.encoderinfo: + # don't bother with transparency if it's an RGBA + # and it's in the info dict. It's probably just stale. + msg = "cannot use transparency for this mode" + raise OSError(msg) + else: + if im.mode == "P" and im.im.getpalettemode() == "RGBA": + alpha = im.im.getpalette("RGBA", "A") + alpha_bytes = colors + chunk(fp, b"tRNS", alpha[:alpha_bytes]) + + if dpi := im.encoderinfo.get("dpi"): + chunk( + fp, + b"pHYs", + o32(int(dpi[0] / 0.0254 + 0.5)), + o32(int(dpi[1] / 0.0254 + 0.5)), + b"\x01", + ) + + if info: + chunks = [b"bKGD", b"hIST"] + for info_chunk in info.chunks: + cid, data = info_chunk[:2] + if cid in chunks: + chunks.remove(cid) + chunk(fp, cid, data) + + if exif := im.encoderinfo.get("exif"): + if isinstance(exif, Image.Exif): + exif = exif.tobytes(8) + if exif.startswith(b"Exif\x00\x00"): + exif = exif[6:] + chunk(fp, b"eXIf", exif) + + single_im: Image.Image | None = im + if save_all: + single_im = _write_multiple_frames( + im, fp, chunk, mode, rawmode, default_image, append_images + ) + if single_im: + _apply_encoderinfo(single_im, im.encoderinfo) + ImageFile._save( + single_im, + cast(IO[bytes], _idat(fp, chunk)), + [ImageFile._Tile("zip", (0, 0) + single_im.size, 0, rawmode)], + ) + + if info: + for info_chunk in info.chunks: + cid, data = info_chunk[:2] + if cid[1:2].islower(): + # Private chunk + after_idat = len(info_chunk) == 3 and info_chunk[2] + if after_idat: + chunk(fp, cid, data) + + chunk(fp, b"IEND", b"") + + if hasattr(fp, "flush"): + fp.flush() + + +# -------------------------------------------------------------------- +# PNG chunk converter + + +def getchunks(im: Image.Image, **params: Any) -> list[tuple[bytes, bytes, bytes]]: + """Return a list of PNG chunks representing this image.""" + from io import BytesIO + + chunks = [] + + def append(fp: IO[bytes], cid: bytes, *data: bytes) -> None: + byte_data = b"".join(data) + crc = o32(_crc32(byte_data, _crc32(cid))) + chunks.append((cid, byte_data, crc)) + + fp = BytesIO() + + try: + im.encoderinfo = params + _save(im, fp, "", append) + finally: + del im.encoderinfo + + return chunks + + +# -------------------------------------------------------------------- +# Registry + +Image.register_open(PngImageFile.format, PngImageFile, _accept) +Image.register_save(PngImageFile.format, _save) +Image.register_save_all(PngImageFile.format, _save_all) + +Image.register_extensions(PngImageFile.format, [".png", ".apng"]) + +Image.register_mime(PngImageFile.format, "image/png") diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/PpmImagePlugin.py b/presentation/.venv/lib/python3.12/site-packages/PIL/PpmImagePlugin.py new file mode 100644 index 0000000..307bc97 --- /dev/null +++ b/presentation/.venv/lib/python3.12/site-packages/PIL/PpmImagePlugin.py @@ -0,0 +1,375 @@ +# +# The Python Imaging Library. +# $Id$ +# +# PPM support for PIL +# +# History: +# 96-03-24 fl Created +# 98-03-06 fl Write RGBA images (as RGB, that is) +# +# Copyright (c) Secret Labs AB 1997-98. +# Copyright (c) Fredrik Lundh 1996. +# +# See the README file for information on usage and redistribution. +# +from __future__ import annotations + +import math +from typing import IO + +from . import Image, ImageFile +from ._binary import i16be as i16 +from ._binary import o8 +from ._binary import o32le as o32 + +# +# -------------------------------------------------------------------- + +b_whitespace = b"\x20\x09\x0a\x0b\x0c\x0d" + +MODES = { + # standard + b"P1": "1", + b"P2": "L", + b"P3": "RGB", + b"P4": "1", + b"P5": "L", + b"P6": "RGB", + # extensions + b"P0CMYK": "CMYK", + b"Pf": "F", + # PIL extensions (for test purposes only) + b"PyP": "P", + b"PyRGBA": "RGBA", + b"PyCMYK": "CMYK", +} + + +def _accept(prefix: bytes) -> bool: + return len(prefix) >= 2 and prefix.startswith(b"P") and prefix[1] in b"0123456fy" + + +## +# Image plugin for PBM, PGM, and PPM images. + + +class PpmImageFile(ImageFile.ImageFile): + format = "PPM" + format_description = "Pbmplus image" + + def _read_magic(self) -> bytes: + assert self.fp is not None + + magic = b"" + # read until whitespace or longest available magic number + for _ in range(6): + c = self.fp.read(1) + if not c or c in b_whitespace: + break + magic += c + return magic + + def _read_token(self) -> bytes: + assert self.fp is not None + + token = b"" + while len(token) <= 10: # read until next whitespace or limit of 10 characters + c = self.fp.read(1) + if not c: + break + elif c in b_whitespace: # token ended + if not token: + # skip whitespace at start + continue + break + elif c == b"#": + # ignores rest of the line; stops at CR, LF or EOF + while self.fp.read(1) not in b"\r\n": + pass + continue + token += c + if not token: + # Token was not even 1 byte + msg = "Reached EOF while reading header" + raise ValueError(msg) + elif len(token) > 10: + msg_too_long = b"Token too long in file header: %s" % token + raise ValueError(msg_too_long) + return token + + def _open(self) -> None: + assert self.fp is not None + + magic_number = self._read_magic() + try: + mode = MODES[magic_number] + except KeyError: + msg = "not a PPM file" + raise SyntaxError(msg) + self._mode = mode + + if magic_number in (b"P1", b"P4"): + self.custom_mimetype = "image/x-portable-bitmap" + elif magic_number in (b"P2", b"P5"): + self.custom_mimetype = "image/x-portable-graymap" + elif magic_number in (b"P3", b"P6"): + self.custom_mimetype = "image/x-portable-pixmap" + + self._size = int(self._read_token()), int(self._read_token()) + + decoder_name = "raw" + if magic_number in (b"P1", b"P2", b"P3"): + decoder_name = "ppm_plain" + + args: str | tuple[str | int, ...] + if mode == "1": + args = "1;I" + elif mode == "F": + scale = float(self._read_token()) + if scale == 0.0 or not math.isfinite(scale): + msg = "scale must be finite and non-zero" + raise ValueError(msg) + self.info["scale"] = abs(scale) + + rawmode = "F;32F" if scale < 0 else "F;32BF" + args = (rawmode, 0, -1) + else: + maxval = int(self._read_token()) + if not 0 < maxval < 65536: + msg = "maxval must be greater than 0 and less than 65536" + raise ValueError(msg) + if maxval > 255 and mode == "L": + self._mode = "I" + + rawmode = mode + if decoder_name != "ppm_plain": + # If maxval matches a bit depth, use the raw decoder directly + if maxval == 65535 and mode == "L": + rawmode = "I;16B" + elif maxval != 255: + decoder_name = "ppm" + + args = rawmode if decoder_name == "raw" else (rawmode, maxval) + self.tile = [ + ImageFile._Tile(decoder_name, (0, 0) + self.size, self.fp.tell(), args) + ] + + +# +# -------------------------------------------------------------------- + + +class PpmPlainDecoder(ImageFile.PyDecoder): + _pulls_fd = True + _comment_spans: bool + + def _read_block(self) -> bytes: + assert self.fd is not None + + return self.fd.read(ImageFile.SAFEBLOCK) + + def _find_comment_end(self, block: bytes, start: int = 0) -> int: + a = block.find(b"\n", start) + b = block.find(b"\r", start) + return min(a, b) if a * b > 0 else max(a, b) # lowest nonnegative index (or -1) + + def _ignore_comments(self, block: bytes) -> bytes: + if self._comment_spans: + # Finish current comment + while block: + comment_end = self._find_comment_end(block) + if comment_end != -1: + # Comment ends in this block + # Delete tail of comment + block = block[comment_end + 1 :] + break + else: + # Comment spans whole block + # So read the next block, looking for the end + block = self._read_block() + + # Search for any further comments + self._comment_spans = False + while True: + comment_start = block.find(b"#") + if comment_start == -1: + # No comment found + break + comment_end = self._find_comment_end(block, comment_start) + if comment_end != -1: + # Comment ends in this block + # Delete comment + block = block[:comment_start] + block[comment_end + 1 :] + else: + # Comment continues to next block(s) + block = block[:comment_start] + self._comment_spans = True + break + return block + + def _decode_bitonal(self) -> bytearray: + """ + This is a separate method because in the plain PBM format, all data tokens are + exactly one byte, so the inter-token whitespace is optional. + """ + data = bytearray() + total_bytes = self.state.xsize * self.state.ysize + + while len(data) != total_bytes: + block = self._read_block() # read next block + if not block: + # eof + break + + block = self._ignore_comments(block) + + tokens = b"".join(block.split()) + for token in tokens: + if token not in (48, 49): + msg = b"Invalid token for this mode: %s" % bytes([token]) + raise ValueError(msg) + data = (data + tokens)[:total_bytes] + invert = bytes.maketrans(b"01", b"\xff\x00") + return data.translate(invert) + + def _decode_blocks(self, maxval: int) -> bytearray: + data = bytearray() + max_len = 10 + out_byte_count = 4 if self.mode == "I" else 1 + out_max = 65535 if self.mode == "I" else 255 + bands = Image.getmodebands(self.mode) + total_bytes = self.state.xsize * self.state.ysize * bands * out_byte_count + + half_token = b"" + while len(data) != total_bytes: + block = self._read_block() # read next block + if not block: + if half_token: + block = bytearray(b" ") # flush half_token + else: + # eof + break + + block = self._ignore_comments(block) + + if half_token: + block = half_token + block # stitch half_token to new block + half_token = b"" + + tokens = block.split() + + if block and not block[-1:].isspace(): # block might split token + half_token = tokens.pop() # save half token for later + if len(half_token) > max_len: # prevent buildup of half_token + msg = ( + b"Token too long found in data: %s" % half_token[: max_len + 1] + ) + raise ValueError(msg) + + for token in tokens: + if len(token) > max_len: + msg = b"Token too long found in data: %s" % token[: max_len + 1] + raise ValueError(msg) + value = int(token) + if value < 0: + msg_str = f"Channel value is negative: {value}" + raise ValueError(msg_str) + if value > maxval: + msg_str = f"Channel value too large for this mode: {value}" + raise ValueError(msg_str) + value = round(value / maxval * out_max) + data += o32(value) if self.mode == "I" else o8(value) + if len(data) == total_bytes: # finished! + break + return data + + def decode(self, buffer: bytes | Image.SupportsArrayInterface) -> tuple[int, int]: + self._comment_spans = False + if self.mode == "1": + data = self._decode_bitonal() + rawmode = "1;8" + else: + maxval = self.args[-1] + data = self._decode_blocks(maxval) + rawmode = "I;32" if self.mode == "I" else self.mode + self.set_as_raw(bytes(data), rawmode) + return -1, 0 + + +class PpmDecoder(ImageFile.PyDecoder): + _pulls_fd = True + + def decode(self, buffer: bytes | Image.SupportsArrayInterface) -> tuple[int, int]: + assert self.fd is not None + + data = bytearray() + maxval = self.args[-1] + in_byte_count = 1 if maxval < 256 else 2 + out_byte_count = 4 if self.mode == "I" else 1 + out_max = 65535 if self.mode == "I" else 255 + bands = Image.getmodebands(self.mode) + dest_length = self.state.xsize * self.state.ysize * bands * out_byte_count + while len(data) < dest_length: + pixels = self.fd.read(in_byte_count * bands) + if len(pixels) < in_byte_count * bands: + # eof + break + for b in range(bands): + value = ( + pixels[b] if in_byte_count == 1 else i16(pixels, b * in_byte_count) + ) + value = min(out_max, round(value / maxval * out_max)) + data += o32(value) if self.mode == "I" else o8(value) + rawmode = "I;32" if self.mode == "I" else self.mode + self.set_as_raw(bytes(data), rawmode) + return -1, 0 + + +# +# -------------------------------------------------------------------- + + +def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: + if im.mode == "1": + rawmode, head = "1;I", b"P4" + elif im.mode == "L": + rawmode, head = "L", b"P5" + elif im.mode in ("I", "I;16"): + rawmode, head = "I;16B", b"P5" + elif im.mode in ("RGB", "RGBA"): + rawmode, head = "RGB", b"P6" + elif im.mode == "F": + rawmode, head = "F;32F", b"Pf" + else: + msg = f"cannot write mode {im.mode} as PPM" + raise OSError(msg) + fp.write(head + b"\n%d %d\n" % im.size) + if head == b"P6": + fp.write(b"255\n") + elif head == b"P5": + if rawmode == "L": + fp.write(b"255\n") + else: + fp.write(b"65535\n") + elif head == b"Pf": + fp.write(b"-1.0\n") + row_order = -1 if im.mode == "F" else 1 + ImageFile._save( + im, fp, [ImageFile._Tile("raw", (0, 0) + im.size, 0, (rawmode, 0, row_order))] + ) + + +# +# -------------------------------------------------------------------- + + +Image.register_open(PpmImageFile.format, PpmImageFile, _accept) +Image.register_save(PpmImageFile.format, _save) + +Image.register_decoder("ppm", PpmDecoder) +Image.register_decoder("ppm_plain", PpmPlainDecoder) + +Image.register_extensions(PpmImageFile.format, [".pbm", ".pgm", ".ppm", ".pnm", ".pfm"]) + +Image.register_mime(PpmImageFile.format, "image/x-portable-anymap") diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/PsdImagePlugin.py b/presentation/.venv/lib/python3.12/site-packages/PIL/PsdImagePlugin.py new file mode 100644 index 0000000..dd3d5ab --- /dev/null +++ b/presentation/.venv/lib/python3.12/site-packages/PIL/PsdImagePlugin.py @@ -0,0 +1,337 @@ +# +# The Python Imaging Library +# $Id$ +# +# Adobe PSD 2.5/3.0 file handling +# +# History: +# 1995-09-01 fl Created +# 1997-01-03 fl Read most PSD images +# 1997-01-18 fl Fixed P and CMYK support +# 2001-10-21 fl Added seek/tell support (for layers) +# +# Copyright (c) 1997-2001 by Secret Labs AB. +# Copyright (c) 1995-2001 by Fredrik Lundh +# +# See the README file for information on usage and redistribution. +# +from __future__ import annotations + +import io +from functools import cached_property +from typing import IO + +from . import Image, ImageFile, ImagePalette +from ._binary import i8 +from ._binary import i16be as i16 +from ._binary import i32be as i32 +from ._binary import si16be as si16 +from ._binary import si32be as si32 +from ._util import DeferredError + +MODES = { + # (photoshop mode, bits) -> (pil mode, required channels) + (0, 1): ("1", 1), + (0, 8): ("L", 1), + (1, 8): ("L", 1), + (2, 8): ("P", 1), + (3, 8): ("RGB", 3), + (4, 8): ("CMYK", 4), + (7, 8): ("L", 1), # FIXME: multilayer + (8, 8): ("L", 1), # duotone + (9, 8): ("LAB", 3), +} + + +# --------------------------------------------------------------------. +# read PSD images + + +def _accept(prefix: bytes) -> bool: + return prefix.startswith(b"8BPS") + + +## +# Image plugin for Photoshop images. + + +class PsdImageFile(ImageFile.ImageFile): + format = "PSD" + format_description = "Adobe Photoshop" + _close_exclusive_fp_after_loading = False + + def _open(self) -> None: + assert self.fp is not None + read = self.fp.read + + # + # header + + s = read(26) + if not _accept(s) or i16(s, 4) != 1: + msg = "not a PSD file" + raise SyntaxError(msg) + + psd_bits = i16(s, 22) + psd_channels = i16(s, 12) + psd_mode = i16(s, 24) + + mode, channels = MODES[(psd_mode, psd_bits)] + + if channels > psd_channels: + msg = "not enough channels" + raise OSError(msg) + if mode == "RGB" and psd_channels == 4: + mode = "RGBA" + channels = 4 + + self._mode = mode + self._size = i32(s, 18), i32(s, 14) + + # + # color mode data + + size = i32(read(4)) + if size: + data = read(size) + if mode == "P" and size == 768: + self.palette = ImagePalette.raw("RGB;L", data) + + # + # image resources + + self.resources = [] + + size = i32(read(4)) + if size: + # load resources + end = self.fp.tell() + size + while self.fp.tell() < end: + read(4) # signature + id = i16(read(2)) + name = read(i8(read(1))) + if not (len(name) & 1): + read(1) # padding + data = read(i32(read(4))) + if len(data) & 1: + read(1) # padding + self.resources.append((id, name, data)) + if id == 1039: # ICC profile + self.info["icc_profile"] = data + + # + # layer and mask information + + self._layers_position = None + + size = i32(read(4)) + if size: + end = self.fp.tell() + size + size = i32(read(4)) + if size: + self._layers_position = self.fp.tell() + self._layers_size = size + self.fp.seek(end) + self._n_frames: int | None = None + + # + # image descriptor + + self.tile = _maketile(self.fp, mode, (0, 0) + self.size, channels) + + # keep the file open + self._fp = self.fp + self.frame = 1 + self._min_frame = 1 + + @cached_property + def layers( + self, + ) -> list[tuple[str, str, tuple[int, int, int, int], list[ImageFile._Tile]]]: + layers = [] + if self._layers_position is not None: + if isinstance(self._fp, DeferredError): + raise self._fp.ex + self._fp.seek(self._layers_position) + _layer_data = io.BytesIO(ImageFile._safe_read(self._fp, self._layers_size)) + layers = _layerinfo(_layer_data, self._layers_size) + self._n_frames = len(layers) + return layers + + @property + def n_frames(self) -> int: + if self._n_frames is None: + self._n_frames = len(self.layers) + return self._n_frames + + @property + def is_animated(self) -> bool: + return len(self.layers) > 1 + + def seek(self, layer: int) -> None: + if not self._seek_check(layer): + return + if isinstance(self._fp, DeferredError): + raise self._fp.ex + + # seek to given layer (1..max) + if layer > len(self.layers): + msg = "no more images in PSD file" + raise EOFError(msg) + _, mode, _, tile = self.layers[layer - 1] + self._mode = mode + self.tile = tile + self.frame = layer + self.fp = self._fp + + def tell(self) -> int: + # return layer number (0=image, 1..max=layers) + return self.frame + + +def _layerinfo( + fp: IO[bytes], ct_bytes: int +) -> list[tuple[str, str, tuple[int, int, int, int], list[ImageFile._Tile]]]: + # read layerinfo block + layers = [] + + def read(size: int) -> bytes: + return ImageFile._safe_read(fp, size) + + ct = si16(read(2)) + + # sanity check + if ct_bytes < (abs(ct) * 20): + msg = "Layer block too short for number of layers requested" + raise SyntaxError(msg) + + for _ in range(abs(ct)): + # bounding box + y0 = si32(read(4)) + x0 = si32(read(4)) + y1 = si32(read(4)) + x1 = si32(read(4)) + + # image info + bands = [] + ct_types = i16(read(2)) + if ct_types > 4: + fp.seek(ct_types * 6 + 12, io.SEEK_CUR) + size = i32(read(4)) + fp.seek(size, io.SEEK_CUR) + continue + + for _ in range(ct_types): + type = i16(read(2)) + + if type == 65535: + b = "A" + else: + b = "RGBA"[type] + + bands.append(b) + read(4) # size + + # figure out the image mode + bands.sort() + if bands == ["R"]: + mode = "L" + elif bands == ["B", "G", "R"]: + mode = "RGB" + elif bands == ["A", "B", "G", "R"]: + mode = "RGBA" + else: + mode = "" # unknown + + # skip over blend flags and extra information + read(12) # filler + name = "" + size = i32(read(4)) # length of the extra data field + if size: + data_end = fp.tell() + size + + length = i32(read(4)) + if length: + fp.seek(length - 16, io.SEEK_CUR) + + length = i32(read(4)) + if length: + fp.seek(length, io.SEEK_CUR) + + length = i8(read(1)) + if length: + # Don't know the proper encoding, + # Latin-1 should be a good guess + name = read(length).decode("latin-1", "replace") + + fp.seek(data_end) + layers.append((name, mode, (x0, y0, x1, y1))) + + # get tiles + layerinfo = [] + for i, (name, mode, bbox) in enumerate(layers): + tile = [] + for m in mode: + t = _maketile(fp, m, bbox, 1) + if t: + tile.extend(t) + layerinfo.append((name, mode, bbox, tile)) + + return layerinfo + + +def _maketile( + file: IO[bytes], mode: str, bbox: tuple[int, int, int, int], channels: int +) -> list[ImageFile._Tile]: + tiles = [] + read = file.read + + compression = i16(read(2)) + + xsize = bbox[2] - bbox[0] + ysize = bbox[3] - bbox[1] + + offset = file.tell() + + if compression == 0: + # + # raw compression + for channel in range(channels): + layer = mode[channel] + if mode == "CMYK": + layer += ";I" + tiles.append(ImageFile._Tile("raw", bbox, offset, layer)) + offset = offset + xsize * ysize + + elif compression == 1: + # + # packbits compression + i = 0 + bytecount = read(channels * ysize * 2) + offset = file.tell() + for channel in range(channels): + layer = mode[channel] + if mode == "CMYK": + layer += ";I" + tiles.append(ImageFile._Tile("packbits", bbox, offset, layer)) + for y in range(ysize): + offset = offset + i16(bytecount, i) + i += 2 + + file.seek(offset) + + if offset & 1: + read(1) # padding + + return tiles + + +# -------------------------------------------------------------------- +# registry + + +Image.register_open(PsdImageFile.format, PsdImageFile, _accept) + +Image.register_extension(PsdImageFile.format, ".psd") + +Image.register_mime(PsdImageFile.format, "image/vnd.adobe.photoshop") diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/QoiImagePlugin.py b/presentation/.venv/lib/python3.12/site-packages/PIL/QoiImagePlugin.py new file mode 100644 index 0000000..d0709b1 --- /dev/null +++ b/presentation/.venv/lib/python3.12/site-packages/PIL/QoiImagePlugin.py @@ -0,0 +1,235 @@ +# +# The Python Imaging Library. +# +# QOI support for PIL +# +# See the README file for information on usage and redistribution. +# +from __future__ import annotations + +import os +from typing import IO + +from . import Image, ImageFile +from ._binary import i32be as i32 +from ._binary import o8 +from ._binary import o32be as o32 + + +def _accept(prefix: bytes) -> bool: + return prefix.startswith(b"qoif") + + +class QoiImageFile(ImageFile.ImageFile): + format = "QOI" + format_description = "Quite OK Image" + + def _open(self) -> None: + assert self.fp is not None + if not _accept(self.fp.read(4)): + msg = "not a QOI file" + raise SyntaxError(msg) + + self._size = i32(self.fp.read(4)), i32(self.fp.read(4)) + + channels = self.fp.read(1)[0] + self._mode = "RGB" if channels == 3 else "RGBA" + + self.fp.seek(1, os.SEEK_CUR) # colorspace + self.tile = [ImageFile._Tile("qoi", (0, 0) + self._size, self.fp.tell())] + + +class QoiDecoder(ImageFile.PyDecoder): + _pulls_fd = True + _previous_pixel: bytes | bytearray | None = None + _previously_seen_pixels: dict[int, bytes | bytearray] = {} + + def _add_to_previous_pixels(self, value: bytes | bytearray) -> None: + self._previous_pixel = value + + r, g, b, a = value + hash_value = (r * 3 + g * 5 + b * 7 + a * 11) % 64 + self._previously_seen_pixels[hash_value] = value + + def decode(self, buffer: bytes | Image.SupportsArrayInterface) -> tuple[int, int]: + assert self.fd is not None + + self._previously_seen_pixels = {} + self._previous_pixel = bytearray((0, 0, 0, 255)) + + data = bytearray() + bands = Image.getmodebands(self.mode) + dest_length = self.state.xsize * self.state.ysize * bands + while len(data) < dest_length: + byte = self.fd.read(1)[0] + value: bytes | bytearray + if byte == 0b11111110 and self._previous_pixel: # QOI_OP_RGB + value = bytearray(self.fd.read(3)) + self._previous_pixel[3:] + elif byte == 0b11111111: # QOI_OP_RGBA + value = self.fd.read(4) + else: + op = byte >> 6 + if op == 0: # QOI_OP_INDEX + op_index = byte & 0b00111111 + value = self._previously_seen_pixels.get( + op_index, bytearray((0, 0, 0, 0)) + ) + elif op == 1 and self._previous_pixel: # QOI_OP_DIFF + value = bytearray( + ( + (self._previous_pixel[0] + ((byte & 0b00110000) >> 4) - 2) + % 256, + (self._previous_pixel[1] + ((byte & 0b00001100) >> 2) - 2) + % 256, + (self._previous_pixel[2] + (byte & 0b00000011) - 2) % 256, + self._previous_pixel[3], + ) + ) + elif op == 2 and self._previous_pixel: # QOI_OP_LUMA + second_byte = self.fd.read(1)[0] + diff_green = (byte & 0b00111111) - 32 + diff_red = ((second_byte & 0b11110000) >> 4) - 8 + diff_blue = (second_byte & 0b00001111) - 8 + + value = bytearray( + tuple( + (self._previous_pixel[i] + diff_green + diff) % 256 + for i, diff in enumerate((diff_red, 0, diff_blue)) + ) + ) + value += self._previous_pixel[3:] + elif op == 3 and self._previous_pixel: # QOI_OP_RUN + run_length = (byte & 0b00111111) + 1 + value = self._previous_pixel + if bands == 3: + value = value[:3] + data += value * run_length + continue + self._add_to_previous_pixels(value) + + if bands == 3: + value = value[:3] + data += value + self.set_as_raw(data) + return -1, 0 + + +def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: + if im.mode == "RGB": + channels = 3 + elif im.mode == "RGBA": + channels = 4 + else: + msg = "Unsupported QOI image mode" + raise ValueError(msg) + + colorspace = 0 if im.encoderinfo.get("colorspace") == "sRGB" else 1 + + fp.write(b"qoif") + fp.write(o32(im.size[0])) + fp.write(o32(im.size[1])) + fp.write(o8(channels)) + fp.write(o8(colorspace)) + + ImageFile._save(im, fp, [ImageFile._Tile("qoi", (0, 0) + im.size)]) + + +class QoiEncoder(ImageFile.PyEncoder): + _pushes_fd = True + _previous_pixel: tuple[int, int, int, int] | None = None + _previously_seen_pixels: dict[int, tuple[int, int, int, int]] = {} + _run = 0 + + def _write_run(self) -> bytes: + data = o8(0b11000000 | (self._run - 1)) # QOI_OP_RUN + self._run = 0 + return data + + def _delta(self, left: int, right: int) -> int: + result = (left - right) & 255 + if result >= 128: + result -= 256 + return result + + def encode(self, bufsize: int) -> tuple[int, int, bytes]: + assert self.im is not None + + self._previously_seen_pixels = {0: (0, 0, 0, 0)} + self._previous_pixel = (0, 0, 0, 255) + + data = bytearray() + w, h = self.im.size + bands = Image.getmodebands(self.mode) + + for y in range(h): + for x in range(w): + pixel = self.im.getpixel((x, y)) + if bands == 3: + pixel = (*pixel, 255) + + if pixel == self._previous_pixel: + self._run += 1 + if self._run == 62: + data += self._write_run() + else: + if self._run: + data += self._write_run() + + r, g, b, a = pixel + hash_value = (r * 3 + g * 5 + b * 7 + a * 11) % 64 + if self._previously_seen_pixels.get(hash_value) == pixel: + data += o8(hash_value) # QOI_OP_INDEX + elif self._previous_pixel: + self._previously_seen_pixels[hash_value] = pixel + + prev_r, prev_g, prev_b, prev_a = self._previous_pixel + if prev_a == a: + delta_r = self._delta(r, prev_r) + delta_g = self._delta(g, prev_g) + delta_b = self._delta(b, prev_b) + + if ( + -2 <= delta_r < 2 + and -2 <= delta_g < 2 + and -2 <= delta_b < 2 + ): + data += o8( + 0b01000000 + | (delta_r + 2) << 4 + | (delta_g + 2) << 2 + | (delta_b + 2) + ) # QOI_OP_DIFF + else: + delta_gr = self._delta(delta_r, delta_g) + delta_gb = self._delta(delta_b, delta_g) + if ( + -8 <= delta_gr < 8 + and -32 <= delta_g < 32 + and -8 <= delta_gb < 8 + ): + data += o8( + 0b10000000 | (delta_g + 32) + ) # QOI_OP_LUMA + data += o8((delta_gr + 8) << 4 | (delta_gb + 8)) + else: + data += o8(0b11111110) # QOI_OP_RGB + data += bytes(pixel[:3]) + else: + data += o8(0b11111111) # QOI_OP_RGBA + data += bytes(pixel) + + self._previous_pixel = pixel + + if self._run: + data += self._write_run() + data += bytes((0, 0, 0, 0, 0, 0, 0, 1)) # padding + + return len(data), 0, data + + +Image.register_open(QoiImageFile.format, QoiImageFile, _accept) +Image.register_decoder("qoi", QoiDecoder) +Image.register_extension(QoiImageFile.format, ".qoi") + +Image.register_save(QoiImageFile.format, _save) +Image.register_encoder("qoi", QoiEncoder) diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/SgiImagePlugin.py b/presentation/.venv/lib/python3.12/site-packages/PIL/SgiImagePlugin.py new file mode 100644 index 0000000..8530221 --- /dev/null +++ b/presentation/.venv/lib/python3.12/site-packages/PIL/SgiImagePlugin.py @@ -0,0 +1,231 @@ +# +# The Python Imaging Library. +# $Id$ +# +# SGI image file handling +# +# See "The SGI Image File Format (Draft version 0.97)", Paul Haeberli. +# <ftp://ftp.sgi.com/graphics/SGIIMAGESPEC> +# +# +# History: +# 2017-22-07 mb Add RLE decompression +# 2016-16-10 mb Add save method without compression +# 1995-09-10 fl Created +# +# Copyright (c) 2016 by Mickael Bonfill. +# Copyright (c) 2008 by Karsten Hiddemann. +# Copyright (c) 1997 by Secret Labs AB. +# Copyright (c) 1995 by Fredrik Lundh. +# +# See the README file for information on usage and redistribution. +# +from __future__ import annotations + +import os +import struct +from typing import IO + +from . import Image, ImageFile +from ._binary import i16be as i16 +from ._binary import o8 + + +def _accept(prefix: bytes) -> bool: + return len(prefix) >= 2 and i16(prefix) == 474 + + +MODES = { + (1, 1, 1): "L", + (1, 2, 1): "L", + (2, 1, 1): "L;16B", + (2, 2, 1): "L;16B", + (1, 3, 3): "RGB", + (2, 3, 3): "RGB;16B", + (1, 3, 4): "RGBA", + (2, 3, 4): "RGBA;16B", +} + + +## +# Image plugin for SGI images. +class SgiImageFile(ImageFile.ImageFile): + format = "SGI" + format_description = "SGI Image File Format" + + def _open(self) -> None: + # HEAD + assert self.fp is not None + + headlen = 512 + s = self.fp.read(headlen) + + if not _accept(s): + msg = "Not an SGI image file" + raise ValueError(msg) + + # compression : verbatim or RLE + compression = s[2] + + # bpc : 1 or 2 bytes (8bits or 16bits) + bpc = s[3] + + # dimension : 1, 2 or 3 (depending on xsize, ysize and zsize) + dimension = i16(s, 4) + + # xsize : width + xsize = i16(s, 6) + + # ysize : height + ysize = i16(s, 8) + + # zsize : channels count + zsize = i16(s, 10) + + # determine mode from bits/zsize + try: + rawmode = MODES[(bpc, dimension, zsize)] + except KeyError: + msg = "Unsupported SGI image mode" + raise ValueError(msg) + + self._size = xsize, ysize + self._mode = rawmode.split(";")[0] + if self.mode == "RGB": + self.custom_mimetype = "image/rgb" + + # orientation -1 : scanlines begins at the bottom-left corner + orientation = -1 + + # decoder info + if compression == 0: + pagesize = xsize * ysize * bpc + if bpc == 2: + self.tile = [ + ImageFile._Tile( + "SGI16", + (0, 0) + self.size, + headlen, + (self.mode, 0, orientation), + ) + ] + else: + self.tile = [] + offset = headlen + for layer in self.mode: + self.tile.append( + ImageFile._Tile( + "raw", (0, 0) + self.size, offset, (layer, 0, orientation) + ) + ) + offset += pagesize + elif compression == 1: + self.tile = [ + ImageFile._Tile( + "sgi_rle", (0, 0) + self.size, headlen, (rawmode, orientation, bpc) + ) + ] + + +def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: + if im.mode not in {"RGB", "RGBA", "L"}: + msg = "Unsupported SGI image mode" + raise ValueError(msg) + + # Get the keyword arguments + info = im.encoderinfo + + # Byte-per-pixel precision, 1 = 8bits per pixel + bpc = info.get("bpc", 1) + + if bpc not in (1, 2): + msg = "Unsupported number of bytes per pixel" + raise ValueError(msg) + + # Flip the image, since the origin of SGI file is the bottom-left corner + orientation = -1 + # Define the file as SGI File Format + magic_number = 474 + # Run-Length Encoding Compression - Unsupported at this time + rle = 0 + + # X Dimension = width / Y Dimension = height + x, y = im.size + # Z Dimension: Number of channels + z = len(im.mode) + # Number of dimensions (x,y,z) + if im.mode == "L": + dimension = 1 if y == 1 else 2 + else: + dimension = 3 + + # Minimum Byte value + pinmin = 0 + # Maximum Byte value (255 = 8bits per pixel) + pinmax = 255 + # Image name (79 characters max, truncated below in write) + img_name = os.path.splitext(os.path.basename(filename))[0] + if isinstance(img_name, str): + img_name = img_name.encode("ascii", "ignore") + # Standard representation of pixel in the file + colormap = 0 + fp.write(struct.pack(">h", magic_number)) + fp.write(o8(rle)) + fp.write(o8(bpc)) + fp.write(struct.pack(">H", dimension)) + fp.write(struct.pack(">H", x)) + fp.write(struct.pack(">H", y)) + fp.write(struct.pack(">H", z)) + fp.write(struct.pack(">l", pinmin)) + fp.write(struct.pack(">l", pinmax)) + fp.write(struct.pack("4s", b"")) # dummy + fp.write(struct.pack("79s", img_name)) # truncates to 79 chars + fp.write(struct.pack("s", b"")) # force null byte after img_name + fp.write(struct.pack(">l", colormap)) + fp.write(struct.pack("404s", b"")) # dummy + + rawmode = "L" + if bpc == 2: + rawmode = "L;16B" + + for channel in im.split(): + fp.write(channel.tobytes("raw", rawmode, 0, orientation)) + + if hasattr(fp, "flush"): + fp.flush() + + +class SGI16Decoder(ImageFile.PyDecoder): + _pulls_fd = True + + def decode(self, buffer: bytes | Image.SupportsArrayInterface) -> tuple[int, int]: + assert self.fd is not None + assert self.im is not None + + rawmode, stride, orientation = self.args + pagesize = self.state.xsize * self.state.ysize + zsize = len(self.mode) + self.fd.seek(512) + + for band in range(zsize): + channel = Image.new("L", (self.state.xsize, self.state.ysize)) + channel.frombytes( + self.fd.read(2 * pagesize), "raw", "L;16B", stride, orientation + ) + self.im.putband(channel.im, band) + + return -1, 0 + + +# +# registry + + +Image.register_decoder("SGI16", SGI16Decoder) +Image.register_open(SgiImageFile.format, SgiImageFile, _accept) +Image.register_save(SgiImageFile.format, _save) +Image.register_mime(SgiImageFile.format, "image/sgi") + +Image.register_extensions(SgiImageFile.format, [".bw", ".rgb", ".rgba", ".sgi"]) + +# End of file diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/SpiderImagePlugin.py b/presentation/.venv/lib/python3.12/site-packages/PIL/SpiderImagePlugin.py new file mode 100644 index 0000000..11d9069 --- /dev/null +++ b/presentation/.venv/lib/python3.12/site-packages/PIL/SpiderImagePlugin.py @@ -0,0 +1,332 @@ +# +# The Python Imaging Library. +# +# SPIDER image file handling +# +# History: +# 2004-08-02 Created BB +# 2006-03-02 added save method +# 2006-03-13 added support for stack images +# +# Copyright (c) 2004 by Health Research Inc. (HRI) RENSSELAER, NY 12144. +# Copyright (c) 2004 by William Baxter. +# Copyright (c) 2004 by Secret Labs AB. +# Copyright (c) 2004 by Fredrik Lundh. +# + +## +# Image plugin for the Spider image format. This format is used +# by the SPIDER software, in processing image data from electron +# microscopy and tomography. +## + +# +# SpiderImagePlugin.py +# +# The Spider image format is used by SPIDER software, in processing +# image data from electron microscopy and tomography. +# +# Spider home page: +# https://spider.wadsworth.org/spider_doc/spider/docs/spider.html +# +# Details about the Spider image format: +# https://spider.wadsworth.org/spider_doc/spider/docs/image_doc.html +# +from __future__ import annotations + +import os +import struct +import sys +from typing import IO, Any, cast + +from . import Image, ImageFile +from ._util import DeferredError + +TYPE_CHECKING = False + + +def isInt(f: Any) -> int: + try: + i = int(f) + if f - i == 0: + return 1 + else: + return 0 + except (ValueError, OverflowError): + return 0 + + +iforms = [1, 3, -11, -12, -21, -22] + + +# There is no magic number to identify Spider files, so just check a +# series of header locations to see if they have reasonable values. +# Returns no. of bytes in the header, if it is a valid Spider header, +# otherwise returns 0 + + +def isSpiderHeader(t: tuple[float, ...]) -> int: + h = (99,) + t # add 1 value so can use spider header index start=1 + # header values 1,2,5,12,13,22,23 should be integers + for i in [1, 2, 5, 12, 13, 22, 23]: + if not isInt(h[i]): + return 0 + # check iform + iform = int(h[5]) + if iform not in iforms: + return 0 + # check other header values + labrec = int(h[13]) # no. records in file header + labbyt = int(h[22]) # total no. of bytes in header + lenbyt = int(h[23]) # record length in bytes + if labbyt != (labrec * lenbyt): + return 0 + # looks like a valid header + return labbyt + + +def isSpiderImage(filename: str) -> int: + with open(filename, "rb") as fp: + f = fp.read(92) # read 23 * 4 bytes + t = struct.unpack(">23f", f) # try big-endian first + hdrlen = isSpiderHeader(t) + if hdrlen == 0: + t = struct.unpack("<23f", f) # little-endian + hdrlen = isSpiderHeader(t) + return hdrlen + + +class SpiderImageFile(ImageFile.ImageFile): + format = "SPIDER" + format_description = "Spider 2D image" + _close_exclusive_fp_after_loading = False + + def _open(self) -> None: + # check header + n = 27 * 4 # read 27 float values + assert self.fp is not None + f = self.fp.read(n) + + try: + self.bigendian = 1 + t = struct.unpack(">27f", f) # try big-endian first + hdrlen = isSpiderHeader(t) + if hdrlen == 0: + self.bigendian = 0 + t = struct.unpack("<27f", f) # little-endian + hdrlen = isSpiderHeader(t) + if hdrlen == 0: + msg = "not a valid Spider file" + raise SyntaxError(msg) + except struct.error as e: + msg = "not a valid Spider file" + raise SyntaxError(msg) from e + + h = (99,) + t # add 1 value : spider header index starts at 1 + iform = int(h[5]) + if iform != 1: + msg = "not a Spider 2D image" + raise SyntaxError(msg) + + self._size = int(h[12]), int(h[2]) # size in pixels (width, height) + self.istack = int(h[24]) + self.imgnumber = int(h[27]) + + if self.istack == 0 and self.imgnumber == 0: + # stk=0, img=0: a regular 2D image + offset = hdrlen + self._nimages = 1 + elif self.istack > 0 and self.imgnumber == 0: + # stk>0, img=0: Opening the stack for the first time + self.imgbytes = int(h[12]) * int(h[2]) * 4 + self.hdrlen = hdrlen + self._nimages = int(h[26]) + # Point to the first image in the stack + offset = hdrlen * 2 + self.imgnumber = 1 + elif self.istack == 0 and self.imgnumber > 0: + # stk=0, img>0: an image within the stack + offset = hdrlen + self.stkoffset + self.istack = 2 # So Image knows it's still a stack + else: + msg = "inconsistent stack header values" + raise SyntaxError(msg) + + if self.bigendian: + self.rawmode = "F;32BF" + else: + self.rawmode = "F;32F" + self._mode = "F" + + self.tile = [ImageFile._Tile("raw", (0, 0) + self.size, offset, self.rawmode)] + self._fp = self.fp # FIXME: hack + + @property + def n_frames(self) -> int: + return self._nimages + + @property + def is_animated(self) -> bool: + return self._nimages > 1 + + # 1st image index is zero (although SPIDER imgnumber starts at 1) + def tell(self) -> int: + if self.imgnumber < 1: + return 0 + else: + return self.imgnumber - 1 + + def seek(self, frame: int) -> None: + if self.istack == 0: + msg = "attempt to seek in a non-stack file" + raise EOFError(msg) + if not self._seek_check(frame): + return + if isinstance(self._fp, DeferredError): + raise self._fp.ex + self.stkoffset = self.hdrlen + frame * (self.hdrlen + self.imgbytes) + self.fp = self._fp + self.fp.seek(self.stkoffset) + self._open() + + # returns a byte image after rescaling to 0..255 + def convert2byte(self, depth: int = 255) -> Image.Image: + extrema = self.getextrema() + assert isinstance(extrema[0], float) + minimum, maximum = cast(tuple[float, float], extrema) + m: float = 1 + if maximum != minimum: + m = depth / (maximum - minimum) + b = -m * minimum + return self.point(lambda i: i * m + b).convert("L") + + if TYPE_CHECKING: + from . import ImageTk + + # returns a ImageTk.PhotoImage object, after rescaling to 0..255 + def tkPhotoImage(self) -> ImageTk.PhotoImage: + from . import ImageTk + + return ImageTk.PhotoImage(self.convert2byte(), palette=256) + + +# -------------------------------------------------------------------- +# Image series + + +# given a list of filenames, return a list of images +def loadImageSeries(filelist: list[str] | None = None) -> list[Image.Image] | None: + """create a list of :py:class:`~PIL.Image.Image` objects for use in a montage""" + if filelist is None or len(filelist) < 1: + return None + + byte_imgs = [] + for img in filelist: + if not os.path.exists(img): + print(f"unable to find {img}") + continue + try: + with Image.open(img) as im: + assert isinstance(im, SpiderImageFile) + byte_im = im.convert2byte() + except Exception: + if not isSpiderImage(img): + print(f"{img} is not a Spider image file") + continue + byte_im.info["filename"] = img + byte_imgs.append(byte_im) + return byte_imgs + + +# -------------------------------------------------------------------- +# For saving images in Spider format + + +def makeSpiderHeader(im: Image.Image) -> list[bytes]: + nsam, nrow = im.size + lenbyt = max(1, nsam) * 4 # There are labrec records in the header + labrec = int(1024 / lenbyt) + if 1024 % lenbyt != 0: + labrec += 1 + labbyt = labrec * lenbyt + nvalues = int(labbyt / 4) + if nvalues < 23: + return [] + + hdr = [0.0] * nvalues + + # NB these are Fortran indices + hdr[1] = 1.0 # nslice (=1 for an image) + hdr[2] = float(nrow) # number of rows per slice + hdr[3] = float(nrow) # number of records in the image + hdr[5] = 1.0 # iform for 2D image + hdr[12] = float(nsam) # number of pixels per line + hdr[13] = float(labrec) # number of records in file header + hdr[22] = float(labbyt) # total number of bytes in header + hdr[23] = float(lenbyt) # record length in bytes + + # adjust for Fortran indexing + hdr = hdr[1:] + hdr.append(0.0) + # pack binary data into a string + return [struct.pack("f", v) for v in hdr] + + +def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: + if im.mode != "F": + im = im.convert("F") + + hdr = makeSpiderHeader(im) + if len(hdr) < 256: + msg = "Error creating Spider header" + raise OSError(msg) + + # write the SPIDER header + fp.writelines(hdr) + + rawmode = "F;32NF" # 32-bit native floating point + ImageFile._save(im, fp, [ImageFile._Tile("raw", (0, 0) + im.size, 0, rawmode)]) + + +def _save_spider(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: + # get the filename extension and register it with Image + if filename_ext := os.path.splitext(filename)[1]: + ext = filename_ext.decode() if isinstance(filename_ext, bytes) else filename_ext + Image.register_extension(SpiderImageFile.format, ext) + _save(im, fp, filename) + + +# -------------------------------------------------------------------- + + +Image.register_open(SpiderImageFile.format, SpiderImageFile) +Image.register_save(SpiderImageFile.format, _save_spider) + +if __name__ == "__main__": + if len(sys.argv) < 2: + print("Syntax: python3 SpiderImagePlugin.py [infile] [outfile]") + sys.exit() + + filename = sys.argv[1] + if not isSpiderImage(filename): + print("input image must be in Spider format") + sys.exit() + + with Image.open(filename) as im: + print(f"image: {im}") + print(f"format: {im.format}") + print(f"size: {im.size}") + print(f"mode: {im.mode}") + print("max, min: ", end=" ") + print(im.getextrema()) + + if len(sys.argv) > 2: + outfile = sys.argv[2] + + # perform some image operation + transposed_im = im.transpose(Image.Transpose.FLIP_LEFT_RIGHT) + print( + f"saving a flipped version of {os.path.basename(filename)} " + f"as {outfile} " + ) + transposed_im.save(outfile, SpiderImageFile.format) diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/SunImagePlugin.py b/presentation/.venv/lib/python3.12/site-packages/PIL/SunImagePlugin.py new file mode 100644 index 0000000..8912379 --- /dev/null +++ b/presentation/.venv/lib/python3.12/site-packages/PIL/SunImagePlugin.py @@ -0,0 +1,145 @@ +# +# The Python Imaging Library. +# $Id$ +# +# Sun image file handling +# +# History: +# 1995-09-10 fl Created +# 1996-05-28 fl Fixed 32-bit alignment +# 1998-12-29 fl Import ImagePalette module +# 2001-12-18 fl Fixed palette loading (from Jean-Claude Rimbault) +# +# Copyright (c) 1997-2001 by Secret Labs AB +# Copyright (c) 1995-1996 by Fredrik Lundh +# +# See the README file for information on usage and redistribution. +# +from __future__ import annotations + +from . import Image, ImageFile, ImagePalette +from ._binary import i32be as i32 + + +def _accept(prefix: bytes) -> bool: + return len(prefix) >= 4 and i32(prefix) == 0x59A66A95 + + +## +# Image plugin for Sun raster files. + + +class SunImageFile(ImageFile.ImageFile): + format = "SUN" + format_description = "Sun Raster File" + + def _open(self) -> None: + # The Sun Raster file header is 32 bytes in length + # and has the following format: + + # typedef struct _SunRaster + # { + # DWORD MagicNumber; /* Magic (identification) number */ + # DWORD Width; /* Width of image in pixels */ + # DWORD Height; /* Height of image in pixels */ + # DWORD Depth; /* Number of bits per pixel */ + # DWORD Length; /* Size of image data in bytes */ + # DWORD Type; /* Type of raster file */ + # DWORD ColorMapType; /* Type of color map */ + # DWORD ColorMapLength; /* Size of the color map in bytes */ + # } SUNRASTER; + + assert self.fp is not None + + # HEAD + s = self.fp.read(32) + if not _accept(s): + msg = "not an SUN raster file" + raise SyntaxError(msg) + + offset = 32 + + self._size = i32(s, 4), i32(s, 8) + + depth = i32(s, 12) + # data_length = i32(s, 16) # unreliable, ignore. + file_type = i32(s, 20) + palette_type = i32(s, 24) # 0: None, 1: RGB, 2: Raw/arbitrary + palette_length = i32(s, 28) + + if depth == 1: + self._mode, rawmode = "1", "1;I" + elif depth == 4: + self._mode, rawmode = "L", "L;4" + elif depth == 8: + self._mode = rawmode = "L" + elif depth == 24: + if file_type == 3: + self._mode, rawmode = "RGB", "RGB" + else: + self._mode, rawmode = "RGB", "BGR" + elif depth == 32: + if file_type == 3: + self._mode, rawmode = "RGB", "RGBX" + else: + self._mode, rawmode = "RGB", "BGRX" + else: + msg = "Unsupported Mode/Bit Depth" + raise SyntaxError(msg) + + if palette_length: + if palette_length > 1024: + msg = "Unsupported Color Palette Length" + raise SyntaxError(msg) + + if palette_type != 1: + msg = "Unsupported Palette Type" + raise SyntaxError(msg) + + offset = offset + palette_length + self.palette = ImagePalette.raw("RGB;L", self.fp.read(palette_length)) + if self.mode == "L": + self._mode = "P" + rawmode = rawmode.replace("L", "P") + + # 16 bit boundaries on stride + stride = ((self.size[0] * depth + 15) // 16) * 2 + + # file type: Type is the version (or flavor) of the bitmap + # file. The following values are typically found in the Type + # field: + # 0000h Old + # 0001h Standard + # 0002h Byte-encoded + # 0003h RGB format + # 0004h TIFF format + # 0005h IFF format + # FFFFh Experimental + + # Old and standard are the same, except for the length tag. + # byte-encoded is run-length-encoded + # RGB looks similar to standard, but RGB byte order + # TIFF and IFF mean that they were converted from T/IFF + # Experimental means that it's something else. + # (https://www.fileformat.info/format/sunraster/egff.htm) + + if file_type in (0, 1, 3, 4, 5): + self.tile = [ + ImageFile._Tile("raw", (0, 0) + self.size, offset, (rawmode, stride)) + ] + elif file_type == 2: + self.tile = [ + ImageFile._Tile("sun_rle", (0, 0) + self.size, offset, rawmode) + ] + else: + msg = "Unsupported Sun Raster file type" + raise SyntaxError(msg) + + +# +# registry + + +Image.register_open(SunImageFile.format, SunImageFile, _accept) + +Image.register_extension(SunImageFile.format, ".ras") diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/TarIO.py b/presentation/.venv/lib/python3.12/site-packages/PIL/TarIO.py new file mode 100644 index 0000000..86490a4 --- /dev/null +++ b/presentation/.venv/lib/python3.12/site-packages/PIL/TarIO.py @@ -0,0 +1,61 @@ +# +# The Python Imaging Library. +# $Id$ +# +# read files from within a tar file +# +# History: +# 95-06-18 fl Created +# 96-05-28 fl Open files in binary mode +# +# Copyright (c) Secret Labs AB 1997. +# Copyright (c) Fredrik Lundh 1995-96. +# +# See the README file for information on usage and redistribution. +# +from __future__ import annotations + +import io + +from . import ContainerIO + + +class TarIO(ContainerIO.ContainerIO[bytes]): + """A file object that provides read access to a given member of a TAR file.""" + + def __init__(self, tarfile: str, file: str) -> None: + """ + Create file object. + + :param tarfile: Name of TAR file. + :param file: Name of member file. + """ + self.fh = open(tarfile, "rb") + + while True: + s = self.fh.read(512) + if len(s) != 512: + self.fh.close() + + msg = "unexpected end of tar file" + raise OSError(msg) + + name = s[:100].decode("utf-8") + i = name.find("\0") + if i == 0: + self.fh.close() + + msg = "cannot find subfile" + raise OSError(msg) + if i > 0: + name = name[:i] + + size = int(s[124:135], 8) + + if file == name: + break + + self.fh.seek((size + 511) & (~511), io.SEEK_CUR) + + # Open region + super().__init__(self.fh, self.fh.tell(), size) diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/TgaImagePlugin.py b/presentation/.venv/lib/python3.12/site-packages/PIL/TgaImagePlugin.py new file mode 100644 index 0000000..b2989a4 --- /dev/null +++ b/presentation/.venv/lib/python3.12/site-packages/PIL/TgaImagePlugin.py @@ -0,0 +1,280 @@ +# +# The Python Imaging Library. +# $Id$ +# +# TGA file handling +# +# History: +# 95-09-01 fl created (reads 24-bit files only) +# 97-01-04 fl support more TGA versions, including compressed images +# 98-07-04 fl fixed orientation and alpha layer bugs +# 98-09-11 fl fixed orientation for runlength decoder +# +# Copyright (c) Secret Labs AB 1997-98. +# Copyright (c) Fredrik Lundh 1995-97. +# +# See the README file for information on usage and redistribution. +# +from __future__ import annotations + +import os +import warnings +from typing import IO + +from . import Image, ImageFile, ImagePalette +from ._binary import i16le as i16 +from ._binary import i32le as i32 +from ._binary import o8 +from ._binary import o16le as o16 + +# +# -------------------------------------------------------------------- +# Read RGA file + + +MODES = { + # map imagetype/depth to rawmode + (1, 8): "P", + (3, 1): "1", + (3, 8): "L", + (3, 16): "LA", + (2, 16): "BGRA;15Z", + (2, 24): "BGR", + (2, 32): "BGRA", +} + + +## +# Image plugin for Targa files. + + +class TgaImageFile(ImageFile.ImageFile): + format = "TGA" + format_description = "Targa" + + def _open(self) -> None: + # process header + assert self.fp is not None + + s = self.fp.read(18) + + id_len = s[0] + + colormaptype = s[1] + imagetype = s[2] + + depth = s[16] + + flags = s[17] + + self._size = i16(s, 12), i16(s, 14) + + # validate header fields + if ( + colormaptype not in (0, 1) + or self.size[0] <= 0 + or self.size[1] <= 0 + or depth not in (1, 8, 16, 24, 32) + ): + msg = "not a TGA file" + raise SyntaxError(msg) + + # image mode + if imagetype in (3, 11): + self._mode = "L" + if depth == 1: + self._mode = "1" # ??? + elif depth == 16: + self._mode = "LA" + elif imagetype in (1, 9): + self._mode = "P" if colormaptype else "L" + elif imagetype in (2, 10): + self._mode = "RGB" if depth == 24 else "RGBA" + else: + msg = "unknown TGA mode" + raise SyntaxError(msg) + + # orientation + orientation = flags & 0x30 + self._flip_horizontally = orientation in [0x10, 0x30] + if orientation in [0x20, 0x30]: + orientation = 1 + elif orientation in [0, 0x10]: + orientation = -1 + else: + msg = "unknown TGA orientation" + raise SyntaxError(msg) + + self.info["orientation"] = orientation + + if imagetype & 8: + self.info["compression"] = "tga_rle" + + if id_len: + self.info["id_section"] = self.fp.read(id_len) + + if colormaptype: + # read palette + start, size, mapdepth = i16(s, 3), i16(s, 5), s[7] + if mapdepth == 16: + self.palette = ImagePalette.raw( + "BGRA;15Z", bytes(2 * start) + self.fp.read(2 * size) + ) + self.palette.mode = "RGBA" + elif mapdepth == 24: + self.palette = ImagePalette.raw( + "BGR", bytes(3 * start) + self.fp.read(3 * size) + ) + elif mapdepth == 32: + self.palette = ImagePalette.raw( + "BGRA", bytes(4 * start) + self.fp.read(4 * size) + ) + else: + msg = "unknown TGA map depth" + raise SyntaxError(msg) + + # setup tile descriptor + try: + rawmode = MODES[(imagetype & 7, depth)] + if imagetype & 8: + # compressed + self.tile = [ + ImageFile._Tile( + "tga_rle", + (0, 0) + self.size, + self.fp.tell(), + (rawmode, orientation, depth), + ) + ] + else: + self.tile = [ + ImageFile._Tile( + "raw", + (0, 0) + self.size, + self.fp.tell(), + (rawmode, 0, orientation), + ) + ] + except KeyError: + pass # cannot decode + + def load_end(self) -> None: + if self.mode == "RGBA": + assert self.fp is not None + self.fp.seek(-26, os.SEEK_END) + footer = self.fp.read(26) + if footer.endswith(b"TRUEVISION-XFILE.\x00"): + # version 2 + extension_offset = i32(footer) + if extension_offset: + self.fp.seek(extension_offset + 494) + attributes_type = self.fp.read(1) + if attributes_type == b"\x00": + # No alpha + self.im.fillband(3, 255) + + if self._flip_horizontally: + self.im = self.im.transpose(Image.Transpose.FLIP_LEFT_RIGHT) + + +# +# -------------------------------------------------------------------- +# Write TGA file + + +SAVE = { + "1": ("1", 1, 0, 3), + "L": ("L", 8, 0, 3), + "LA": ("LA", 16, 0, 3), + "P": ("P", 8, 1, 1), + "RGB": ("BGR", 24, 0, 2), + "RGBA": ("BGRA", 32, 0, 2), +} + + +def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: + try: + rawmode, bits, colormaptype, imagetype = SAVE[im.mode] + except KeyError as e: + msg = f"cannot write mode {im.mode} as TGA" + raise OSError(msg) from e + + if "rle" in im.encoderinfo: + rle = im.encoderinfo["rle"] + else: + compression = im.encoderinfo.get("compression", im.info.get("compression")) + rle = compression == "tga_rle" + if rle: + imagetype += 8 + + id_section = im.encoderinfo.get("id_section", im.info.get("id_section", "")) + id_len = len(id_section) + if id_len > 255: + id_len = 255 + id_section = id_section[:255] + warnings.warn("id_section has been trimmed to 255 characters") + + if colormaptype: + palette = im.im.getpalette("RGB", "BGR") + colormaplength, colormapentry = len(palette) // 3, 24 + else: + colormaplength, colormapentry = 0, 0 + + if im.mode in ("LA", "RGBA"): + flags = 8 + else: + flags = 0 + + orientation = im.encoderinfo.get("orientation", im.info.get("orientation", -1)) + if orientation > 0: + flags = flags | 0x20 + + fp.write( + o8(id_len) + + o8(colormaptype) + + o8(imagetype) + + o16(0) # colormapfirst + + o16(colormaplength) + + o8(colormapentry) + + o16(0) + + o16(0) + + o16(im.size[0]) + + o16(im.size[1]) + + o8(bits) + + o8(flags) + ) + + if id_section: + fp.write(id_section) + + if colormaptype: + fp.write(palette) + + if rle: + ImageFile._save( + im, + fp, + [ImageFile._Tile("tga_rle", (0, 0) + im.size, 0, (rawmode, orientation))], + ) + else: + ImageFile._save( + im, + fp, + [ImageFile._Tile("raw", (0, 0) + im.size, 0, (rawmode, 0, orientation))], + ) + + # write targa version 2 footer + fp.write(b"\000" * 8 + b"TRUEVISION-XFILE." + b"\000") + + +# +# -------------------------------------------------------------------- +# Registry + + +Image.register_open(TgaImageFile.format, TgaImageFile) +Image.register_save(TgaImageFile.format, _save) + +Image.register_extensions(TgaImageFile.format, [".tga", ".icb", ".vda", ".vst"]) + +Image.register_mime(TgaImageFile.format, "image/x-tga") diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/TiffImagePlugin.py b/presentation/.venv/lib/python3.12/site-packages/PIL/TiffImagePlugin.py new file mode 100644 index 0000000..5094faa --- /dev/null +++ b/presentation/.venv/lib/python3.12/site-packages/PIL/TiffImagePlugin.py @@ -0,0 +1,2353 @@ +# +# The Python Imaging Library. +# $Id$ +# +# TIFF file handling +# +# TIFF is a flexible, if somewhat aged, image file format originally +# defined by Aldus. Although TIFF supports a wide variety of pixel +# layouts and compression methods, the name doesn't really stand for +# "thousands of incompatible file formats," it just feels that way. +# +# To read TIFF data from a stream, the stream must be seekable. For +# progressive decoding, make sure to use TIFF files where the tag +# directory is placed first in the file. +# +# History: +# 1995-09-01 fl Created +# 1996-05-04 fl Handle JPEGTABLES tag +# 1996-05-18 fl Fixed COLORMAP support +# 1997-01-05 fl Fixed PREDICTOR support +# 1997-08-27 fl Added support for rational tags (from Perry Stoll) +# 1998-01-10 fl Fixed seek/tell (from Jan Blom) +# 1998-07-15 fl Use private names for internal variables +# 1999-06-13 fl Rewritten for PIL 1.0 (1.0) +# 2000-10-11 fl Additional fixes for Python 2.0 (1.1) +# 2001-04-17 fl Fixed rewind support (seek to frame 0) (1.2) +# 2001-05-12 fl Added write support for more tags (from Greg Couch) (1.3) +# 2001-12-18 fl Added workaround for broken Matrox library +# 2002-01-18 fl Don't mess up if photometric tag is missing (D. Alan Stewart) +# 2003-05-19 fl Check FILLORDER tag +# 2003-09-26 fl Added RGBa support +# 2004-02-24 fl Added DPI support; fixed rational write support +# 2005-02-07 fl Added workaround for broken Corel Draw 10 files +# 2006-01-09 fl Added support for float/double tags (from Russell Nelson) +# +# Copyright (c) 1997-2006 by Secret Labs AB. All rights reserved. +# Copyright (c) 1995-1997 by Fredrik Lundh +# +# See the README file for information on usage and redistribution. +# +from __future__ import annotations + +import io +import itertools +import logging +import math +import os +import struct +import warnings +from collections.abc import Callable, MutableMapping +from fractions import Fraction +from numbers import Number, Rational +from typing import IO, Any, cast + +from . import ExifTags, Image, ImageFile, ImageOps, ImagePalette, TiffTags +from ._binary import i16be as i16 +from ._binary import i32be as i32 +from ._binary import o8 +from ._util import DeferredError, is_path +from .TiffTags import TYPES + +TYPE_CHECKING = False +if TYPE_CHECKING: + from collections.abc import Iterator + from typing import NoReturn + + from ._typing import Buffer, IntegralLike, StrOrBytesPath + +logger = logging.getLogger(__name__) + +# Set these to true to force use of libtiff for reading or writing. +READ_LIBTIFF = False +WRITE_LIBTIFF = False +STRIP_SIZE = 65536 + +II = b"II" # little-endian (Intel style) +MM = b"MM" # big-endian (Motorola style) + +# +# -------------------------------------------------------------------- +# Read TIFF files + +# a few tag names, just to make the code below a bit more readable +OSUBFILETYPE = 255 +IMAGEWIDTH = 256 +IMAGELENGTH = 257 +BITSPERSAMPLE = 258 +COMPRESSION = 259 +PHOTOMETRIC_INTERPRETATION = 262 +FILLORDER = 266 +IMAGEDESCRIPTION = 270 +STRIPOFFSETS = 273 +SAMPLESPERPIXEL = 277 +ROWSPERSTRIP = 278 +STRIPBYTECOUNTS = 279 +X_RESOLUTION = 282 +Y_RESOLUTION = 283 +PLANAR_CONFIGURATION = 284 +RESOLUTION_UNIT = 296 +TRANSFERFUNCTION = 301 +SOFTWARE = 305 +DATE_TIME = 306 +ARTIST = 315 +PREDICTOR = 317 +COLORMAP = 320 +TILEWIDTH = 322 +TILELENGTH = 323 +TILEOFFSETS = 324 +TILEBYTECOUNTS = 325 +SUBIFD = 330 +EXTRASAMPLES = 338 +SAMPLEFORMAT = 339 +JPEGTABLES = 347 +YCBCRSUBSAMPLING = 530 +REFERENCEBLACKWHITE = 532 +COPYRIGHT = 33432 +IPTC_NAA_CHUNK = 33723 # newsphoto properties +PHOTOSHOP_CHUNK = 34377 # photoshop properties +ICCPROFILE = 34675 +EXIFIFD = 34665 +XMP = 700 +JPEGQUALITY = 65537 # pseudo-tag by libtiff + +# https://github.com/imagej/ImageJA/blob/master/src/main/java/ij/io/TiffDecoder.java +IMAGEJ_META_DATA_BYTE_COUNTS = 50838 +IMAGEJ_META_DATA = 50839 + +COMPRESSION_INFO = { + # Compression => pil compression name + 1: "raw", + 2: "tiff_ccitt", + 3: "group3", + 4: "group4", + 5: "tiff_lzw", + 6: "tiff_jpeg", # obsolete + 7: "jpeg", + 8: "tiff_adobe_deflate", + 32771: "tiff_raw_16", # 16-bit padding + 32773: "packbits", + 32809: "tiff_thunderscan", + 32946: "tiff_deflate", + 34676: "tiff_sgilog", + 34677: "tiff_sgilog24", + 34925: "lzma", + 50000: "zstd", + 50001: "webp", +} + +COMPRESSION_INFO_REV = {v: k for k, v in COMPRESSION_INFO.items()} + +OPEN_INFO = { + # (ByteOrder, PhotoInterpretation, SampleFormat, FillOrder, BitsPerSample, + # ExtraSamples) => mode, rawmode + (II, 0, (1,), 1, (1,), ()): ("1", "1;I"), + (MM, 0, (1,), 1, (1,), ()): ("1", "1;I"), + (II, 0, (1,), 2, (1,), ()): ("1", "1;IR"), + (MM, 0, (1,), 2, (1,), ()): ("1", "1;IR"), + (II, 1, (1,), 1, (1,), ()): ("1", "1"), + (MM, 1, (1,), 1, (1,), ()): ("1", "1"), + (II, 1, (1,), 2, (1,), ()): ("1", "1;R"), + (MM, 1, (1,), 2, (1,), ()): ("1", "1;R"), + (II, 0, (1,), 1, (2,), ()): ("L", "L;2I"), + (MM, 0, (1,), 1, (2,), ()): ("L", "L;2I"), + (II, 0, (1,), 2, (2,), ()): ("L", "L;2IR"), + (MM, 0, (1,), 2, (2,), ()): ("L", "L;2IR"), + (II, 1, (1,), 1, (2,), ()): ("L", "L;2"), + (MM, 1, (1,), 1, (2,), ()): ("L", "L;2"), + (II, 1, (1,), 2, (2,), ()): ("L", "L;2R"), + (MM, 1, (1,), 2, (2,), ()): ("L", "L;2R"), + (II, 0, (1,), 1, (4,), ()): ("L", "L;4I"), + (MM, 0, (1,), 1, (4,), ()): ("L", "L;4I"), + (II, 0, (1,), 2, (4,), ()): ("L", "L;4IR"), + (MM, 0, (1,), 2, (4,), ()): ("L", "L;4IR"), + (II, 1, (1,), 1, (4,), ()): ("L", "L;4"), + (MM, 1, (1,), 1, (4,), ()): ("L", "L;4"), + (II, 1, (1,), 2, (4,), ()): ("L", "L;4R"), + (MM, 1, (1,), 2, (4,), ()): ("L", "L;4R"), + (II, 0, (1,), 1, (8,), ()): ("L", "L;I"), + (MM, 0, (1,), 1, (8,), ()): ("L", "L;I"), + (II, 0, (1,), 2, (8,), ()): ("L", "L;IR"), + (MM, 0, (1,), 2, (8,), ()): ("L", "L;IR"), + (II, 1, (1,), 1, (8,), ()): ("L", "L"), + (MM, 1, (1,), 1, (8,), ()): ("L", "L"), + (II, 1, (2,), 1, (8,), ()): ("L", "L"), + (MM, 1, (2,), 1, (8,), ()): ("L", "L"), + (II, 1, (1,), 2, (8,), ()): ("L", "L;R"), + (MM, 1, (1,), 2, (8,), ()): ("L", "L;R"), + (II, 1, (1,), 1, (12,), ()): ("I;16", "I;12"), + (II, 0, (1,), 1, (16,), ()): ("I;16", "I;16"), + (II, 1, (1,), 1, (16,), ()): ("I;16", "I;16"), + (MM, 1, (1,), 1, (16,), ()): ("I;16B", "I;16B"), + (II, 1, (1,), 2, (16,), ()): ("I;16", "I;16R"), + (II, 1, (2,), 1, (16,), ()): ("I", "I;16S"), + (MM, 1, (2,), 1, (16,), ()): ("I", "I;16BS"), + (II, 0, (3,), 1, (32,), ()): ("F", "F;32F"), + (MM, 0, (3,), 1, (32,), ()): ("F", "F;32BF"), + (II, 1, (1,), 1, (32,), ()): ("I", "I;32N"), + (II, 1, (2,), 1, (32,), ()): ("I", "I;32S"), + (MM, 1, (2,), 1, (32,), ()): ("I", "I;32BS"), + (II, 1, (3,), 1, (32,), ()): ("F", "F;32F"), + (MM, 1, (3,), 1, (32,), ()): ("F", "F;32BF"), + (II, 1, (1,), 1, (8, 8), (2,)): ("LA", "LA"), + (MM, 1, (1,), 1, (8, 8), (2,)): ("LA", "LA"), + (II, 2, (1,), 1, (8, 8, 8), ()): ("RGB", "RGB"), + (MM, 2, (1,), 1, (8, 8, 8), ()): ("RGB", "RGB"), + (II, 2, (1,), 2, (8, 8, 8), ()): ("RGB", "RGB;R"), + (MM, 2, (1,), 2, (8, 8, 8), ()): ("RGB", "RGB;R"), + (II, 2, (1,), 1, (8, 8, 8, 8), ()): ("RGBA", "RGBA"), # missing ExtraSamples + (MM, 2, (1,), 1, (8, 8, 8, 8), ()): ("RGBA", "RGBA"), # missing ExtraSamples + (II, 2, (1,), 1, (8, 8, 8, 8), (0,)): ("RGB", "RGBX"), + (MM, 2, (1,), 1, (8, 8, 8, 8), (0,)): ("RGB", "RGBX"), + (II, 2, (1,), 1, (8, 8, 8, 8, 8), (0, 0)): ("RGB", "RGBXX"), + (MM, 2, (1,), 1, (8, 8, 8, 8, 8), (0, 0)): ("RGB", "RGBXX"), + (II, 2, (1,), 1, (8, 8, 8, 8, 8, 8), (0, 0, 0)): ("RGB", "RGBXXX"), + (MM, 2, (1,), 1, (8, 8, 8, 8, 8, 8), (0, 0, 0)): ("RGB", "RGBXXX"), + (II, 2, (1,), 1, (8, 8, 8, 8), (1,)): ("RGBA", "RGBa"), + (MM, 2, (1,), 1, (8, 8, 8, 8), (1,)): ("RGBA", "RGBa"), + (II, 2, (1,), 1, (8, 8, 8, 8, 8), (1, 0)): ("RGBA", "RGBaX"), + (MM, 2, (1,), 1, (8, 8, 8, 8, 8), (1, 0)): ("RGBA", "RGBaX"), + (II, 2, (1,), 1, (8, 8, 8, 8, 8, 8), (1, 0, 0)): ("RGBA", "RGBaXX"), + (MM, 2, (1,), 1, (8, 8, 8, 8, 8, 8), (1, 0, 0)): ("RGBA", "RGBaXX"), + (II, 2, (1,), 1, (8, 8, 8, 8), (2,)): ("RGBA", "RGBA"), + (MM, 2, (1,), 1, (8, 8, 8, 8), (2,)): ("RGBA", "RGBA"), + (II, 2, (1,), 1, (8, 8, 8, 8, 8), (2, 0)): ("RGBA", "RGBAX"), + (MM, 2, (1,), 1, (8, 8, 8, 8, 8), (2, 0)): ("RGBA", "RGBAX"), + (II, 2, (1,), 1, (8, 8, 8, 8, 8, 8), (2, 0, 0)): ("RGBA", "RGBAXX"), + (MM, 2, (1,), 1, (8, 8, 8, 8, 8, 8), (2, 0, 0)): ("RGBA", "RGBAXX"), + (II, 2, (1,), 1, (8, 8, 8, 8), (999,)): ("RGBA", "RGBA"), # Corel Draw 10 + (MM, 2, (1,), 1, (8, 8, 8, 8), (999,)): ("RGBA", "RGBA"), # Corel Draw 10 + (II, 2, (1,), 1, (16, 16, 16), ()): ("RGB", "RGB;16L"), + (MM, 2, (1,), 1, (16, 16, 16), ()): ("RGB", "RGB;16B"), + (II, 2, (1,), 1, (16, 16, 16, 16), ()): ("RGBA", "RGBA;16L"), + (MM, 2, (1,), 1, (16, 16, 16, 16), ()): ("RGBA", "RGBA;16B"), + (II, 2, (1,), 1, (16, 16, 16, 16), (0,)): ("RGB", "RGBX;16L"), + (MM, 2, (1,), 1, (16, 16, 16, 16), (0,)): ("RGB", "RGBX;16B"), + (II, 2, (1,), 1, (16, 16, 16, 16), (1,)): ("RGBA", "RGBa;16L"), + (MM, 2, (1,), 1, (16, 16, 16, 16), (1,)): ("RGBA", "RGBa;16B"), + (II, 2, (1,), 1, (16, 16, 16, 16), (2,)): ("RGBA", "RGBA;16L"), + (MM, 2, (1,), 1, (16, 16, 16, 16), (2,)): ("RGBA", "RGBA;16B"), + (II, 3, (1,), 1, (1,), ()): ("P", "P;1"), + (MM, 3, (1,), 1, (1,), ()): ("P", "P;1"), + (II, 3, (1,), 2, (1,), ()): ("P", "P;1R"), + (MM, 3, (1,), 2, (1,), ()): ("P", "P;1R"), + (II, 3, (1,), 1, (2,), ()): ("P", "P;2"), + (MM, 3, (1,), 1, (2,), ()): ("P", "P;2"), + (II, 3, (1,), 2, (2,), ()): ("P", "P;2R"), + (MM, 3, (1,), 2, (2,), ()): ("P", "P;2R"), + (II, 3, (1,), 1, (4,), ()): ("P", "P;4"), + (MM, 3, (1,), 1, (4,), ()): ("P", "P;4"), + (II, 3, (1,), 2, (4,), ()): ("P", "P;4R"), + (MM, 3, (1,), 2, (4,), ()): ("P", "P;4R"), + (II, 3, (1,), 1, (8,), ()): ("P", "P"), + (MM, 3, (1,), 1, (8,), ()): ("P", "P"), + (II, 3, (1,), 1, (8, 8), (0,)): ("P", "PX"), + (MM, 3, (1,), 1, (8, 8), (0,)): ("P", "PX"), + (II, 3, (1,), 1, (8, 8), (2,)): ("PA", "PA"), + (MM, 3, (1,), 1, (8, 8), (2,)): ("PA", "PA"), + (II, 3, (1,), 2, (8,), ()): ("P", "P;R"), + (MM, 3, (1,), 2, (8,), ()): ("P", "P;R"), + (II, 5, (1,), 1, (8, 8, 8, 8), ()): ("CMYK", "CMYK"), + (MM, 5, (1,), 1, (8, 8, 8, 8), ()): ("CMYK", "CMYK"), + (II, 5, (1,), 1, (8, 8, 8, 8, 8), (0,)): ("CMYK", "CMYKX"), + (MM, 5, (1,), 1, (8, 8, 8, 8, 8), (0,)): ("CMYK", "CMYKX"), + (II, 5, (1,), 1, (8, 8, 8, 8, 8, 8), (0, 0)): ("CMYK", "CMYKXX"), + (MM, 5, (1,), 1, (8, 8, 8, 8, 8, 8), (0, 0)): ("CMYK", "CMYKXX"), + (II, 5, (1,), 1, (16, 16, 16, 16), ()): ("CMYK", "CMYK;16L"), + (MM, 5, (1,), 1, (16, 16, 16, 16), ()): ("CMYK", "CMYK;16B"), + (II, 6, (1,), 1, (8,), ()): ("L", "L"), + (MM, 6, (1,), 1, (8,), ()): ("L", "L"), + # JPEG compressed images handled by LibTiff and auto-converted to RGBX + # Minimal Baseline TIFF requires YCbCr images to have 3 SamplesPerPixel + (II, 6, (1,), 1, (8, 8, 8), ()): ("RGB", "RGBX"), + (MM, 6, (1,), 1, (8, 8, 8), ()): ("RGB", "RGBX"), + (II, 8, (1,), 1, (8, 8, 8), ()): ("LAB", "LAB"), + (MM, 8, (1,), 1, (8, 8, 8), ()): ("LAB", "LAB"), +} + +MAX_SAMPLESPERPIXEL = max(len(key_tp[4]) for key_tp in OPEN_INFO) + +PREFIXES = [ + b"MM\x00\x2a", # Valid TIFF header with big-endian byte order + b"II\x2a\x00", # Valid TIFF header with little-endian byte order + b"MM\x2a\x00", # Invalid TIFF header, assume big-endian + b"II\x00\x2a", # Invalid TIFF header, assume little-endian + b"MM\x00\x2b", # BigTIFF with big-endian byte order + b"II\x2b\x00", # BigTIFF with little-endian byte order +] + + +def _accept(prefix: bytes) -> bool: + return prefix.startswith(tuple(PREFIXES)) + + +def _limit_rational( + val: float | Fraction | IFDRational, max_val: int +) -> tuple[IntegralLike, IntegralLike]: + inv = abs(val) > 1 + n_d = IFDRational(1 / val if inv else val).limit_rational(max_val) + return n_d[::-1] if inv else n_d + + +def _limit_signed_rational( + val: IFDRational, max_val: int, min_val: int +) -> tuple[IntegralLike, IntegralLike]: + frac = Fraction(val) + n_d: tuple[IntegralLike, IntegralLike] = frac.numerator, frac.denominator + + if min(float(i) for i in n_d) < min_val: + n_d = _limit_rational(val, abs(min_val)) + + n_d_float = tuple(float(i) for i in n_d) + if max(n_d_float) > max_val: + n_d = _limit_rational(n_d_float[0] / n_d_float[1], max_val) + + return n_d + + +## +# Wrapper for TIFF IFDs. + +_load_dispatch = {} +_write_dispatch = {} + + +def _delegate(op: str) -> Any: + def delegate( + self: IFDRational, *args: tuple[float, ...] + ) -> bool | float | Fraction: + return getattr(self._val, op)(*args) + + return delegate + + +class IFDRational(Rational): + """Implements a rational class where 0/0 is a legal value to match + the in the wild use of exif rationals. + + e.g., DigitalZoomRatio - 0.00/0.00 indicates that no digital zoom was used + """ + + """ If the denominator is 0, store this as a float('nan'), otherwise store + as a fractions.Fraction(). Delegate as appropriate + + """ + + __slots__ = ("_numerator", "_denominator", "_val") + + def __init__( + self, value: float | Fraction | IFDRational, denominator: int = 1 + ) -> None: + """ + :param value: either an integer numerator, a + float/rational/other number, or an IFDRational + :param denominator: Optional integer denominator + """ + self._val: Fraction | float + if isinstance(value, IFDRational): + self._numerator = value.numerator + self._denominator = value.denominator + self._val = value._val + return + + if isinstance(value, Fraction): + self._numerator = value.numerator + self._denominator = value.denominator + else: + if TYPE_CHECKING: + self._numerator = cast(IntegralLike, value) + else: + self._numerator = value + self._denominator = denominator + + if denominator == 0: + self._val = float("nan") + elif denominator == 1: + self._val = Fraction(value) + elif int(value) == value: + self._val = Fraction(int(value), denominator) + else: + self._val = Fraction(value / denominator) + + @property + def numerator(self) -> IntegralLike: + return self._numerator + + @property + def denominator(self) -> int: + return self._denominator + + def limit_rational(self, max_denominator: int) -> tuple[IntegralLike, int]: + """ + + :param max_denominator: Integer, the maximum denominator value + :returns: Tuple of (numerator, denominator) + """ + + if self.denominator == 0: + return self.numerator, self.denominator + + assert isinstance(self._val, Fraction) + f = self._val.limit_denominator(max_denominator) + return f.numerator, f.denominator + + def __repr__(self) -> str: + return str(float(self._val)) + + def __hash__(self) -> int: # type: ignore[override] + return self._val.__hash__() + + def __eq__(self, other: object) -> bool: + val = self._val + if isinstance(other, IFDRational): + other = other._val + if isinstance(other, float): + val = float(val) + return val == other + + def __getstate__(self) -> list[float | Fraction | IntegralLike]: + return [self._val, self._numerator, self._denominator] + + def __setstate__(self, state: list[float | Fraction | IntegralLike]) -> None: + IFDRational.__init__(self, 0) + _val, _numerator, _denominator = state + assert isinstance(_val, (float, Fraction)) + self._val = _val + if TYPE_CHECKING: + self._numerator = cast(IntegralLike, _numerator) + else: + self._numerator = _numerator + assert isinstance(_denominator, int) + self._denominator = _denominator + + """ a = ['add','radd', 'sub', 'rsub', 'mul', 'rmul', + 'truediv', 'rtruediv', 'floordiv', 'rfloordiv', + 'mod','rmod', 'pow','rpow', 'pos', 'neg', + 'abs', 'trunc', 'lt', 'gt', 'le', 'ge', 'bool', + 'ceil', 'floor', 'round'] + print("\n".join("__%s__ = _delegate('__%s__')" % (s,s) for s in a)) + """ + + __add__ = _delegate("__add__") + __radd__ = _delegate("__radd__") + __sub__ = _delegate("__sub__") + __rsub__ = _delegate("__rsub__") + __mul__ = _delegate("__mul__") + __rmul__ = _delegate("__rmul__") + __truediv__ = _delegate("__truediv__") + __rtruediv__ = _delegate("__rtruediv__") + __floordiv__ = _delegate("__floordiv__") + __rfloordiv__ = _delegate("__rfloordiv__") + __mod__ = _delegate("__mod__") + __rmod__ = _delegate("__rmod__") + __pow__ = _delegate("__pow__") + __rpow__ = _delegate("__rpow__") + __pos__ = _delegate("__pos__") + __neg__ = _delegate("__neg__") + __abs__ = _delegate("__abs__") + __trunc__ = _delegate("__trunc__") + __lt__ = _delegate("__lt__") + __gt__ = _delegate("__gt__") + __le__ = _delegate("__le__") + __ge__ = _delegate("__ge__") + __bool__ = _delegate("__bool__") + __ceil__ = _delegate("__ceil__") + __floor__ = _delegate("__floor__") + __round__ = _delegate("__round__") + # Python >= 3.11 + if hasattr(Fraction, "__int__"): + __int__ = _delegate("__int__") + + +_LoaderFunc = Callable[["ImageFileDirectory_v2", bytes, bool], Any] + + +def _register_loader(idx: int, size: int) -> Callable[[_LoaderFunc], _LoaderFunc]: + def decorator(func: _LoaderFunc) -> _LoaderFunc: + from .TiffTags import TYPES + + if func.__name__.startswith("load_"): + TYPES[idx] = func.__name__[5:].replace("_", " ") + _load_dispatch[idx] = size, func # noqa: F821 + return func + + return decorator + + +def _register_writer(idx: int) -> Callable[[Callable[..., Any]], Callable[..., Any]]: + def decorator(func: Callable[..., Any]) -> Callable[..., Any]: + _write_dispatch[idx] = func # noqa: F821 + return func + + return decorator + + +def _register_basic(idx_fmt_name: tuple[int, str, str]) -> None: + from .TiffTags import TYPES + + idx, fmt, name = idx_fmt_name + TYPES[idx] = name + size = struct.calcsize(f"={fmt}") + + def basic_handler( + self: ImageFileDirectory_v2, data: bytes, legacy_api: bool = True + ) -> tuple[Any, ...]: + return self._unpack(f"{len(data) // size}{fmt}", data) + + _load_dispatch[idx] = size, basic_handler # noqa: F821 + _write_dispatch[idx] = lambda self, *values: ( # noqa: F821 + b"".join(self._pack(fmt, value) for value in values) + ) + + +if TYPE_CHECKING: + _IFDv2Base = MutableMapping[int, Any] +else: + _IFDv2Base = MutableMapping + + +class ImageFileDirectory_v2(_IFDv2Base): + """This class represents a TIFF tag directory. To speed things up, we + don't decode tags unless they're asked for. + + Exposes a dictionary interface of the tags in the directory:: + + ifd = ImageFileDirectory_v2() + ifd[key] = 'Some Data' + ifd.tagtype[key] = TiffTags.ASCII + print(ifd[key]) + 'Some Data' + + Individual values are returned as the strings or numbers, sequences are + returned as tuples of the values. + + The tiff metadata type of each item is stored in a dictionary of + tag types in + :attr:`~PIL.TiffImagePlugin.ImageFileDirectory_v2.tagtype`. The types + are read from a tiff file, guessed from the type added, or added + manually. + + Data Structures: + + * ``self.tagtype = {}`` + + * Key: numerical TIFF tag number + * Value: integer corresponding to the data type from + :py:data:`.TiffTags.TYPES` + + .. versionadded:: 3.0.0 + + 'Internal' data structures: + + * ``self._tags_v2 = {}`` + + * Key: numerical TIFF tag number + * Value: decoded data, as tuple for multiple values + + * ``self._tagdata = {}`` + + * Key: numerical TIFF tag number + * Value: undecoded byte string from file + + * ``self._tags_v1 = {}`` + + * Key: numerical TIFF tag number + * Value: decoded data in the v1 format + + Tags will be found in the private attributes ``self._tagdata``, and in + ``self._tags_v2`` once decoded. + + ``self.legacy_api`` is a value for internal use, and shouldn't be changed + from outside code. In cooperation with + :py:class:`~PIL.TiffImagePlugin.ImageFileDirectory_v1`, if ``legacy_api`` + is true, then decoded tags will be populated into both ``_tags_v1`` and + ``_tags_v2``. ``_tags_v2`` will be used if this IFD is used in the TIFF + save routine. Tags should be read from ``_tags_v1`` if + ``legacy_api == true``. + + """ + + _load_dispatch: dict[int, tuple[int, _LoaderFunc]] = {} + _write_dispatch: dict[int, Callable[..., Any]] = {} + + def __init__( + self, + ifh: bytes = b"II\x2a\x00\x00\x00\x00\x00", + prefix: bytes | None = None, + group: int | None = None, + ) -> None: + """Initialize an ImageFileDirectory. + + To construct an ImageFileDirectory from a real file, pass the 8-byte + magic header to the constructor. To only set the endianness, pass it + as the 'prefix' keyword argument. + + :param ifh: One of the accepted magic headers (cf. PREFIXES); also sets + endianness. + :param prefix: Override the endianness of the file. + """ + if not _accept(ifh): + msg = f"not a TIFF file (header {repr(ifh)} not valid)" + raise SyntaxError(msg) + self._prefix = prefix if prefix is not None else ifh[:2] + if self._prefix == MM: + self._endian = ">" + elif self._prefix == II: + self._endian = "<" + else: + msg = "not a TIFF IFD" + raise SyntaxError(msg) + self._bigtiff = ifh[2] == 43 + self.group = group + self.tagtype: dict[int, int] = {} + """ Dictionary of tag types """ + self.reset() + self.next = ( + self._unpack("Q", ifh[8:])[0] + if self._bigtiff + else self._unpack("L", ifh[4:])[0] + ) + self._legacy_api = False + + prefix = property(lambda self: self._prefix) + offset = property(lambda self: self._offset) + + @property + def legacy_api(self) -> bool: + return self._legacy_api + + @legacy_api.setter + def legacy_api(self, value: bool) -> NoReturn: + msg = "Not allowing setting of legacy api" + raise Exception(msg) + + def reset(self) -> None: + self._tags_v1: dict[int, Any] = {} # will remain empty if legacy_api is false + self._tags_v2: dict[int, Any] = {} # main tag storage + self._tagdata: dict[int, bytes] = {} + self.tagtype = {} # added 2008-06-05 by Florian Hoech + self._next = None + self._offset: int | None = None + + def __str__(self) -> str: + return str(dict(self)) + + def named(self) -> dict[str, Any]: + """ + :returns: dict of name|key: value + + Returns the complete tag dictionary, with named tags where possible. + """ + return { + TiffTags.lookup(code, self.group).name: value + for code, value in self.items() + } + + def __len__(self) -> int: + return len(set(self._tagdata) | set(self._tags_v2)) + + def __getitem__(self, tag: int) -> Any: + if tag not in self._tags_v2: # unpack on the fly + data = self._tagdata[tag] + typ = self.tagtype[tag] + size, handler = self._load_dispatch[typ] + self[tag] = handler(self, data, self.legacy_api) # check type + val = self._tags_v2[tag] + if self.legacy_api and not isinstance(val, (tuple, bytes)): + val = (val,) + return val + + def __contains__(self, tag: object) -> bool: + return tag in self._tags_v2 or tag in self._tagdata + + def __setitem__(self, tag: int, value: Any) -> None: + self._setitem(tag, value, self.legacy_api) + + def _setitem(self, tag: int, value: Any, legacy_api: bool) -> None: + basetypes = (Number, bytes, str) + + info = TiffTags.lookup(tag, self.group) + values = [value] if isinstance(value, basetypes) else value + + if tag not in self.tagtype: + if info.type: + self.tagtype[tag] = info.type + else: + self.tagtype[tag] = TiffTags.UNDEFINED + if all(isinstance(v, IFDRational) for v in values): + for v in values: + assert isinstance(v, IFDRational) + if v < 0: + self.tagtype[tag] = TiffTags.SIGNED_RATIONAL + break + else: + self.tagtype[tag] = TiffTags.RATIONAL + elif all(isinstance(v, int) for v in values): + short = True + signed_short = True + long = True + for v in values: + assert isinstance(v, int) + if short and not (0 <= v < 2**16): + short = False + if signed_short and not (-(2**15) < v < 2**15): + signed_short = False + if long and v < 0: + long = False + if short: + self.tagtype[tag] = TiffTags.SHORT + elif signed_short: + self.tagtype[tag] = TiffTags.SIGNED_SHORT + elif long: + self.tagtype[tag] = TiffTags.LONG + else: + self.tagtype[tag] = TiffTags.SIGNED_LONG + elif all(isinstance(v, float) for v in values): + self.tagtype[tag] = TiffTags.DOUBLE + elif all(isinstance(v, str) for v in values): + self.tagtype[tag] = TiffTags.ASCII + elif all(isinstance(v, bytes) for v in values): + self.tagtype[tag] = TiffTags.BYTE + + if self.tagtype[tag] == TiffTags.UNDEFINED: + values = [ + v.encode("ascii", "replace") if isinstance(v, str) else v + for v in values + ] + elif self.tagtype[tag] == TiffTags.RATIONAL: + values = [float(v) if isinstance(v, int) else v for v in values] + + is_ifd = self.tagtype[tag] == TiffTags.LONG and isinstance(values, dict) + if not is_ifd: + values = tuple( + info.cvt_enum(value) if isinstance(value, str) else value + for value in values + ) + + dest = self._tags_v1 if legacy_api else self._tags_v2 + + # Three branches: + # Spec'd length == 1, Actual length 1, store as element + # Spec'd length == 1, Actual > 1, Warn and truncate. Formerly barfed. + # No Spec, Actual length 1, Formerly (<4.2) returned a 1 element tuple. + # Don't mess with the legacy api, since it's frozen. + if not is_ifd and ( + (info.length == 1) + or self.tagtype[tag] == TiffTags.BYTE + or (info.length is None and len(values) == 1 and not legacy_api) + ): + # Don't mess with the legacy api, since it's frozen. + if legacy_api and self.tagtype[tag] in [ + TiffTags.RATIONAL, + TiffTags.SIGNED_RATIONAL, + ]: # rationals + values = (values,) + try: + (dest[tag],) = values + except ValueError: + # We've got a builtin tag with 1 expected entry + warnings.warn( + f"Metadata Warning, tag {tag} had too many entries: " + f"{len(values)}, expected 1" + ) + dest[tag] = values[0] + + else: + # Spec'd length > 1 or undefined + # Unspec'd, and length > 1 + dest[tag] = values + + def __delitem__(self, tag: int) -> None: + self._tags_v2.pop(tag, None) + self._tags_v1.pop(tag, None) + self._tagdata.pop(tag, None) + + def __iter__(self) -> Iterator[int]: + return iter(set(self._tagdata) | set(self._tags_v2)) + + def _unpack(self, fmt: str, data: bytes) -> tuple[Any, ...]: + return struct.unpack(self._endian + fmt, data) + + def _pack(self, fmt: str, *values: Any) -> bytes: + return struct.pack(self._endian + fmt, *values) + + list( + map( + _register_basic, + [ + (TiffTags.SHORT, "H", "short"), + (TiffTags.LONG, "L", "long"), + (TiffTags.SIGNED_BYTE, "b", "signed byte"), + (TiffTags.SIGNED_SHORT, "h", "signed short"), + (TiffTags.SIGNED_LONG, "l", "signed long"), + (TiffTags.FLOAT, "f", "float"), + (TiffTags.DOUBLE, "d", "double"), + (TiffTags.IFD, "L", "long"), + (TiffTags.LONG8, "Q", "long8"), + ], + ) + ) + + @_register_loader(1, 1) # Basic type, except for the legacy API. + def load_byte(self, data: bytes, legacy_api: bool = True) -> bytes: + return data + + @_register_writer(1) # Basic type, except for the legacy API. + def write_byte(self, data: bytes | int | IFDRational) -> bytes: + if isinstance(data, IFDRational): + data = int(data) + if isinstance(data, int): + data = bytes((data,)) + return data + + @_register_loader(2, 1) + def load_string(self, data: bytes, legacy_api: bool = True) -> str: + if data.endswith(b"\0"): + data = data[:-1] + return data.decode("latin-1", "replace") + + @_register_writer(2) + def write_string(self, value: str | bytes | int) -> bytes: + # remerge of https://github.com/python-pillow/Pillow/pull/1416 + if isinstance(value, int): + value = str(value) + if not isinstance(value, bytes): + value = value.encode("ascii", "replace") + return value + b"\0" + + @_register_loader(5, 8) + def load_rational( + self, data: bytes, legacy_api: bool = True + ) -> tuple[tuple[int, int] | IFDRational, ...]: + vals = self._unpack(f"{len(data) // 4}L", data) + + def combine(a: int, b: int) -> tuple[int, int] | IFDRational: + return (a, b) if legacy_api else IFDRational(a, b) + + return tuple(combine(num, denom) for num, denom in zip(vals[::2], vals[1::2])) + + @_register_writer(5) + def write_rational(self, *values: IFDRational) -> bytes: + return b"".join( + self._pack("2L", *_limit_rational(frac, 2**32 - 1)) for frac in values + ) + + @_register_loader(7, 1) + def load_undefined(self, data: bytes, legacy_api: bool = True) -> bytes: + return data + + @_register_writer(7) + def write_undefined(self, value: bytes | int | IFDRational) -> bytes: + if isinstance(value, IFDRational): + value = int(value) + if isinstance(value, int): + value = str(value).encode("ascii", "replace") + return value + + @_register_loader(10, 8) + def load_signed_rational( + self, data: bytes, legacy_api: bool = True + ) -> tuple[tuple[int, int] | IFDRational, ...]: + vals = self._unpack(f"{len(data) // 4}l", data) + + def combine(a: int, b: int) -> tuple[int, int] | IFDRational: + return (a, b) if legacy_api else IFDRational(a, b) + + return tuple(combine(num, denom) for num, denom in zip(vals[::2], vals[1::2])) + + @_register_writer(10) + def write_signed_rational(self, *values: IFDRational) -> bytes: + return b"".join( + self._pack("2l", *_limit_signed_rational(frac, 2**31 - 1, -(2**31))) + for frac in values + ) + + def _ensure_read(self, fp: IO[bytes], size: int) -> bytes: + ret = fp.read(size) + if len(ret) != size: + msg = ( + "Corrupt EXIF data. " + f"Expecting to read {size} bytes but only got {len(ret)}. " + ) + raise OSError(msg) + return ret + + def load(self, fp: IO[bytes]) -> None: + self.reset() + self._offset = fp.tell() + + try: + tag_count = ( + self._unpack("Q", self._ensure_read(fp, 8)) + if self._bigtiff + else self._unpack("H", self._ensure_read(fp, 2)) + )[0] + for i in range(tag_count): + tag, typ, count, data = ( + self._unpack("HHQ8s", self._ensure_read(fp, 20)) + if self._bigtiff + else self._unpack("HHL4s", self._ensure_read(fp, 12)) + ) + + tagname = TiffTags.lookup(tag, self.group).name + typname = TYPES.get(typ, "unknown") + msg = f"tag: {tagname} ({tag}) - type: {typname} ({typ})" + + try: + unit_size, handler = self._load_dispatch[typ] + except KeyError: + logger.debug("%s - unsupported type %s", msg, typ) + continue # ignore unsupported type + size = count * unit_size + if size > (8 if self._bigtiff else 4): + here = fp.tell() + (offset,) = self._unpack("Q" if self._bigtiff else "L", data) + msg += f" Tag Location: {here} - Data Location: {offset}" + fp.seek(offset) + data = ImageFile._safe_read(fp, size) + fp.seek(here) + else: + data = data[:size] + + if len(data) != size: + warnings.warn( + "Possibly corrupt EXIF data. " + f"Expecting to read {size} bytes but only got {len(data)}." + f" Skipping tag {tag}" + ) + logger.debug(msg) + continue + + if not data: + logger.debug(msg) + continue + + self._tagdata[tag] = data + self.tagtype[tag] = typ + + msg += " - value: " + msg += f"<table: {size} bytes>" if size > 32 else repr(data) + + logger.debug(msg) + + (self.next,) = ( + self._unpack("Q", self._ensure_read(fp, 8)) + if self._bigtiff + else self._unpack("L", self._ensure_read(fp, 4)) + ) + except OSError as msg: + warnings.warn(str(msg)) + return + + def _get_ifh(self) -> bytes: + ifh = self._prefix + self._pack("H", 43 if self._bigtiff else 42) + if self._bigtiff: + ifh += self._pack("HH", 8, 0) + ifh += self._pack("Q", 16) if self._bigtiff else self._pack("L", 8) + + return ifh + + def tobytes(self, offset: int = 0) -> bytes: + # FIXME What about tagdata? + result = self._pack("Q" if self._bigtiff else "H", len(self._tags_v2)) + + entries: list[tuple[int, int, int, bytes, bytes]] = [] + + fmt = "Q" if self._bigtiff else "L" + fmt_size = 8 if self._bigtiff else 4 + offset += ( + len(result) + len(self._tags_v2) * (20 if self._bigtiff else 12) + fmt_size + ) + stripoffsets = None + + # pass 1: convert tags to binary format + # always write tags in ascending order + for tag, value in sorted(self._tags_v2.items()): + if tag == STRIPOFFSETS: + stripoffsets = len(entries) + typ = self.tagtype[tag] + logger.debug("Tag %s, Type: %s, Value: %s", tag, typ, repr(value)) + is_ifd = typ == TiffTags.LONG and isinstance(value, dict) + if is_ifd: + ifd = ImageFileDirectory_v2(self._get_ifh(), group=tag) + values = self._tags_v2[tag] + for ifd_tag, ifd_value in values.items(): + ifd[ifd_tag] = ifd_value + data = ifd.tobytes(offset) + else: + values = value if isinstance(value, tuple) else (value,) + data = self._write_dispatch[typ](self, *values) + + tagname = TiffTags.lookup(tag, self.group).name + typname = "ifd" if is_ifd else TYPES.get(typ, "unknown") + msg = f"save: {tagname} ({tag}) - type: {typname} ({typ}) - value: " + msg += f"<table: {len(data)} bytes>" if len(data) >= 16 else str(values) + logger.debug(msg) + + # count is sum of lengths for string and arbitrary data + if is_ifd: + count = 1 + elif typ in [TiffTags.BYTE, TiffTags.ASCII, TiffTags.UNDEFINED]: + count = len(data) + else: + count = len(values) + # figure out if data fits into the entry + if len(data) <= fmt_size: + entries.append((tag, typ, count, data.ljust(fmt_size, b"\0"), b"")) + else: + entries.append((tag, typ, count, self._pack(fmt, offset), data)) + offset += (len(data) + 1) // 2 * 2 # pad to word + + # update strip offset data to point beyond auxiliary data + if stripoffsets is not None: + tag, typ, count, value, data = entries[stripoffsets] + if data: + size, handler = self._load_dispatch[typ] + values = [val + offset for val in handler(self, data, self.legacy_api)] + data = self._write_dispatch[typ](self, *values) + else: + value = self._pack(fmt, self._unpack(fmt, value)[0] + offset) + entries[stripoffsets] = tag, typ, count, value, data + + # pass 2: write entries to file + for tag, typ, count, value, data in entries: + logger.debug("%s %s %s %s %s", tag, typ, count, repr(value), repr(data)) + result += self._pack( + "HHQ8s" if self._bigtiff else "HHL4s", tag, typ, count, value + ) + + # -- overwrite here for multi-page -- + result += self._pack(fmt, 0) # end of entries + + # pass 3: write auxiliary data to file + for tag, typ, count, value, data in entries: + result += data + if len(data) & 1: + result += b"\0" + + return result + + def save(self, fp: IO[bytes]) -> int: + if fp.tell() == 0: # skip TIFF header on subsequent pages + fp.write(self._get_ifh()) + + offset = fp.tell() + result = self.tobytes(offset) + fp.write(result) + return offset + len(result) + + +ImageFileDirectory_v2._load_dispatch = _load_dispatch +ImageFileDirectory_v2._write_dispatch = _write_dispatch +for idx, name in TYPES.items(): + name = name.replace(" ", "_") + setattr(ImageFileDirectory_v2, f"load_{name}", _load_dispatch[idx][1]) + setattr(ImageFileDirectory_v2, f"write_{name}", _write_dispatch[idx]) +del _load_dispatch, _write_dispatch, idx, name + + +# Legacy ImageFileDirectory support. +class ImageFileDirectory_v1(ImageFileDirectory_v2): + """This class represents the **legacy** interface to a TIFF tag directory. + + Exposes a dictionary interface of the tags in the directory:: + + ifd = ImageFileDirectory_v1() + ifd[key] = 'Some Data' + ifd.tagtype[key] = TiffTags.ASCII + print(ifd[key]) + ('Some Data',) + + Also contains a dictionary of tag types as read from the tiff image file, + :attr:`~PIL.TiffImagePlugin.ImageFileDirectory_v1.tagtype`. + + Values are returned as a tuple. + + .. deprecated:: 3.0.0 + """ + + def __init__(self, *args: Any, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + self._legacy_api = True + + tags = property(lambda self: self._tags_v1) + tagdata = property(lambda self: self._tagdata) + + # defined in ImageFileDirectory_v2 + tagtype: dict[int, int] + """Dictionary of tag types""" + + @classmethod + def from_v2(cls, original: ImageFileDirectory_v2) -> ImageFileDirectory_v1: + """Returns an + :py:class:`~PIL.TiffImagePlugin.ImageFileDirectory_v1` + instance with the same data as is contained in the original + :py:class:`~PIL.TiffImagePlugin.ImageFileDirectory_v2` + instance. + + :returns: :py:class:`~PIL.TiffImagePlugin.ImageFileDirectory_v1` + + """ + + ifd = cls(prefix=original.prefix) + ifd._tagdata = original._tagdata + ifd.tagtype = original.tagtype + ifd.next = original.next # an indicator for multipage tiffs + return ifd + + def to_v2(self) -> ImageFileDirectory_v2: + """Returns an + :py:class:`~PIL.TiffImagePlugin.ImageFileDirectory_v2` + instance with the same data as is contained in the original + :py:class:`~PIL.TiffImagePlugin.ImageFileDirectory_v1` + instance. + + :returns: :py:class:`~PIL.TiffImagePlugin.ImageFileDirectory_v2` + + """ + + ifd = ImageFileDirectory_v2(prefix=self.prefix) + ifd._tagdata = dict(self._tagdata) + ifd.tagtype = dict(self.tagtype) + ifd._tags_v2 = dict(self._tags_v2) + return ifd + + def __contains__(self, tag: object) -> bool: + return tag in self._tags_v1 or tag in self._tagdata + + def __len__(self) -> int: + return len(set(self._tagdata) | set(self._tags_v1)) + + def __iter__(self) -> Iterator[int]: + return iter(set(self._tagdata) | set(self._tags_v1)) + + def __setitem__(self, tag: int, value: Any) -> None: + for legacy_api in (False, True): + self._setitem(tag, value, legacy_api) + + def __getitem__(self, tag: int) -> Any: + if tag not in self._tags_v1: # unpack on the fly + data = self._tagdata[tag] + typ = self.tagtype[tag] + size, handler = self._load_dispatch[typ] + for legacy in (False, True): + self._setitem(tag, handler(self, data, legacy), legacy) + val = self._tags_v1[tag] + if not isinstance(val, (tuple, bytes)): + val = (val,) + return val + + +# undone -- switch this pointer +ImageFileDirectory = ImageFileDirectory_v1 + + +## +# Image plugin for TIFF files. + + +class TiffImageFile(ImageFile.ImageFile): + format = "TIFF" + format_description = "Adobe TIFF" + _close_exclusive_fp_after_loading = False + + def __init__( + self, + fp: StrOrBytesPath | IO[bytes], + filename: str | bytes | None = None, + ) -> None: + self.tag_v2: ImageFileDirectory_v2 + """ Image file directory (tag dictionary) """ + + self.tag: ImageFileDirectory_v1 + """ Legacy tag entries """ + + super().__init__(fp, filename) + + def _open(self) -> None: + """Open the first image in a TIFF file""" + + # Header + assert self.fp is not None + ifh = self.fp.read(8) + if ifh[2] == 43: + ifh += self.fp.read(8) + + self.tag_v2 = ImageFileDirectory_v2(ifh) + + # setup frame pointers + self.__first = self.__next = self.tag_v2.next + self.__frame = -1 + self._fp = self.fp + self._frame_pos: list[int] = [] + self._n_frames: int | None = None + + logger.debug("*** TiffImageFile._open ***") + logger.debug("- __first: %s", self.__first) + logger.debug("- ifh: %s", repr(ifh)) # Use repr to avoid str(bytes) + + # and load the first frame + self._seek(0) + + @property + def n_frames(self) -> int: + current_n_frames = self._n_frames + if current_n_frames is None: + current = self.tell() + self._seek(len(self._frame_pos)) + while self._n_frames is None: + self._seek(self.tell() + 1) + self.seek(current) + assert self._n_frames is not None + return self._n_frames + + def seek(self, frame: int) -> None: + """Select a given frame as current image""" + if not self._seek_check(frame): + return + self._seek(frame) + if self._im is not None and ( + self.im.size != self._tile_size + or self.im.mode != self.mode + or self.readonly + ): + self._im = None + + def _seek(self, frame: int) -> None: + if isinstance(self._fp, DeferredError): + raise self._fp.ex + self.fp = self._fp + + while len(self._frame_pos) <= frame: + if not self.__next: + msg = "no more images in TIFF file" + raise EOFError(msg) + logger.debug( + "Seeking to frame %s, on frame %s, __next %s, location: %s", + frame, + self.__frame, + self.__next, + self.fp.tell(), + ) + if self.__next >= 2**63: + msg = "Unable to seek to frame" + raise ValueError(msg) + self.fp.seek(self.__next) + self._frame_pos.append(self.__next) + logger.debug("Loading tags, location: %s", self.fp.tell()) + self.tag_v2.load(self.fp) + if self.tag_v2.next in self._frame_pos: + # This IFD has already been processed + # Declare this to be the end of the image + self.__next = 0 + else: + self.__next = self.tag_v2.next + if self.__next == 0: + self._n_frames = frame + 1 + if len(self._frame_pos) == 1: + self.is_animated = self.__next != 0 + self.__frame += 1 + self.fp.seek(self._frame_pos[frame]) + self.tag_v2.load(self.fp) + if XMP in self.tag_v2: + xmp = self.tag_v2[XMP] + if isinstance(xmp, tuple) and len(xmp) == 1: + xmp = xmp[0] + self.info["xmp"] = xmp + elif "xmp" in self.info: + del self.info["xmp"] + self._reload_exif() + # fill the legacy tag/ifd entries + self.tag = self.ifd = ImageFileDirectory_v1.from_v2(self.tag_v2) + self.__frame = frame + self._setup() + + def tell(self) -> int: + """Return the current frame number""" + return self.__frame + + def get_photoshop_blocks(self) -> dict[int, dict[str, bytes]]: + """ + Returns a dictionary of Photoshop "Image Resource Blocks". + The keys are the image resource ID. For more information, see + https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#50577409_pgfId-1037727 + + :returns: Photoshop "Image Resource Blocks" in a dictionary. + """ + blocks = {} + val = self.tag_v2.get(ExifTags.Base.ImageResources) + if val: + while val.startswith(b"8BIM") and len(val) >= 12: + id = i16(val[4:6]) + n = math.ceil((val[6] + 1) / 2) * 2 + try: + size = i32(val[6 + n : 10 + n]) + except struct.error: + break + data = val[10 + n : 10 + n + size] + blocks[id] = {"data": data} + + val = val[math.ceil((10 + n + size) / 2) * 2 :] + return blocks + + def load(self) -> Image.core.PixelAccess | None: + if self.tile and self.use_load_libtiff: + return self._load_libtiff() + return super().load() + + def load_prepare(self) -> None: + if self._im is None: + Image._decompression_bomb_check(self._tile_size) + self.im = Image.core.new(self.mode, self._tile_size) + ImageFile.ImageFile.load_prepare(self) + + def load_end(self) -> None: + # allow closing if we're on the first frame, there's no next + # This is the ImageFile.load path only, libtiff specific below. + if not self.is_animated: + self._close_exclusive_fp_after_loading = True + + # load IFD data from fp before it is closed + exif = self.getexif() + for key in TiffTags.TAGS_V2_GROUPS: + if key not in exif: + continue + exif.get_ifd(key) + + ImageOps.exif_transpose(self, in_place=True) + if ExifTags.Base.Orientation in self.tag_v2: + del self.tag_v2[ExifTags.Base.Orientation] + + def _load_libtiff(self) -> Image.core.PixelAccess | None: + """Overload method triggered when we detect a compressed tiff + Calls out to libtiff""" + + Image.Image.load(self) + + self.load_prepare() + + if not len(self.tile) == 1: + msg = "Not exactly one tile" + raise OSError(msg) + + # (self._compression, (extents tuple), + # 0, (rawmode, self._compression, fp)) + extents = self.tile[0][1] + args = self.tile[0][3] + + # To be nice on memory footprint, if there's a + # file descriptor, use that instead of reading + # into a string in python. + assert self.fp is not None + try: + fp = hasattr(self.fp, "fileno") and self.fp.fileno() + # flush the file descriptor, prevents error on pypy 2.4+ + # should also eliminate the need for fp.tell + # in _seek + if hasattr(self.fp, "flush"): + self.fp.flush() + except OSError: + # io.BytesIO have a fileno, but returns an OSError if + # it doesn't use a file descriptor. + fp = False + + if fp: + assert isinstance(args, tuple) + args_list = list(args) + args_list[2] = fp + args = tuple(args_list) + + decoder = Image._getdecoder(self.mode, "libtiff", args, self.decoderconfig) + try: + decoder.setimage(self.im, extents) + except ValueError as e: + msg = "Couldn't set the image" + raise OSError(msg) from e + + close_self_fp = self._exclusive_fp and not self.is_animated + if hasattr(self.fp, "getvalue"): + # We've got a stringio like thing passed in. Yay for all in memory. + # The decoder needs the entire file in one shot, so there's not + # a lot we can do here other than give it the entire file. + # unless we could do something like get the address of the + # underlying string for stringio. + # + # Rearranging for supporting byteio items, since they have a fileno + # that returns an OSError if there's no underlying fp. Easier to + # deal with here by reordering. + logger.debug("have getvalue. just sending in a string from getvalue") + n, err = decoder.decode(self.fp.getvalue()) + elif fp: + # we've got a actual file on disk, pass in the fp. + logger.debug("have fileno, calling fileno version of the decoder.") + if not close_self_fp: + self.fp.seek(0) + # Save and restore the file position, because libtiff will move it + # outside of the Python runtime, and that will confuse + # io.BufferedReader and possible others. + # NOTE: This must use os.lseek(), and not fp.tell()/fp.seek(), + # because the buffer read head already may not equal the actual + # file position, and fp.seek() may just adjust it's internal + # pointer and not actually seek the OS file handle. + pos = os.lseek(fp, 0, os.SEEK_CUR) + # 4 bytes, otherwise the trace might error out + n, err = decoder.decode(b"fpfp") + os.lseek(fp, pos, os.SEEK_SET) + else: + # we have something else. + logger.debug("don't have fileno or getvalue. just reading") + self.fp.seek(0) + # UNDONE -- so much for that buffer size thing. + n, err = decoder.decode(self.fp.read()) + + self.tile = [] + self.readonly = 0 + + self.load_end() + + if close_self_fp: + self.fp.close() + self.fp = None # might be shared + + if err < 0: + msg = f"decoder error {err}" + raise OSError(msg) + + return Image.Image.load(self) + + def _setup(self) -> None: + """Setup this image object based on current tags""" + + if 0xBC01 in self.tag_v2: + msg = "Windows Media Photo files not yet supported" + raise OSError(msg) + + # extract relevant tags + self._compression = COMPRESSION_INFO[self.tag_v2.get(COMPRESSION, 1)] + self._planar_configuration = self.tag_v2.get(PLANAR_CONFIGURATION, 1) + + # photometric is a required tag, but not everyone is reading + # the specification + photo = self.tag_v2.get(PHOTOMETRIC_INTERPRETATION, 0) + + # old style jpeg compression images most certainly are YCbCr + if self._compression == "tiff_jpeg": + photo = 6 + + fillorder = self.tag_v2.get(FILLORDER, 1) + + logger.debug("*** Summary ***") + logger.debug("- compression: %s", self._compression) + logger.debug("- photometric_interpretation: %s", photo) + logger.debug("- planar_configuration: %s", self._planar_configuration) + logger.debug("- fill_order: %s", fillorder) + logger.debug("- YCbCr subsampling: %s", self.tag_v2.get(YCBCRSUBSAMPLING)) + + # size + try: + xsize = self.tag_v2[IMAGEWIDTH] + ysize = self.tag_v2[IMAGELENGTH] + except KeyError as e: + msg = "Missing dimensions" + raise TypeError(msg) from e + if not isinstance(xsize, int) or not isinstance(ysize, int): + msg = "Invalid dimensions" + raise ValueError(msg) + self._tile_size = xsize, ysize + orientation = self.tag_v2.get(ExifTags.Base.Orientation) + if orientation in (5, 6, 7, 8): + self._size = ysize, xsize + else: + self._size = xsize, ysize + + logger.debug("- size: %s", self.size) + + sample_format = self.tag_v2.get(SAMPLEFORMAT, (1,)) + if len(sample_format) > 1 and max(sample_format) == min(sample_format): + # SAMPLEFORMAT is properly per band, so an RGB image will + # be (1,1,1). But, we don't support per band pixel types, + # and anything more than one band is a uint8. So, just + # take the first element. Revisit this if adding support + # for more exotic images. + sample_format = (sample_format[0],) + + bps_tuple = self.tag_v2.get(BITSPERSAMPLE, (1,)) + extra_tuple = self.tag_v2.get(EXTRASAMPLES, ()) + samples_per_pixel = self.tag_v2.get( + SAMPLESPERPIXEL, + 3 if self._compression == "tiff_jpeg" and photo in (2, 6) else 1, + ) + if photo in (2, 6, 8): # RGB, YCbCr, LAB + bps_count = 3 + elif photo == 5: # CMYK + bps_count = 4 + else: + bps_count = 1 + if self._planar_configuration == 2 and extra_tuple and max(extra_tuple) == 0: + # If components are stored separately, + # then unspecified extra components at the end can be ignored + bps_tuple = bps_tuple[: -len(extra_tuple)] + samples_per_pixel -= len(extra_tuple) + extra_tuple = () + bps_count += len(extra_tuple) + bps_actual_count = len(bps_tuple) + + if samples_per_pixel > MAX_SAMPLESPERPIXEL: + # DOS check, samples_per_pixel can be a Long, and we extend the tuple below + logger.error( + "More samples per pixel than can be decoded: %s", samples_per_pixel + ) + msg = "Invalid value for samples per pixel" + raise SyntaxError(msg) + + if samples_per_pixel < bps_actual_count: + # If a file has more values in bps_tuple than expected, + # remove the excess. + bps_tuple = bps_tuple[:samples_per_pixel] + elif samples_per_pixel > bps_actual_count and bps_actual_count == 1: + # If a file has only one value in bps_tuple, when it should have more, + # presume it is the same number of bits for all of the samples. + bps_tuple = bps_tuple * samples_per_pixel + + if len(bps_tuple) != samples_per_pixel: + msg = "unknown data organization" + raise SyntaxError(msg) + + # mode: check photometric interpretation and bits per pixel + key = ( + self.tag_v2.prefix, + photo, + sample_format, + fillorder, + bps_tuple, + extra_tuple, + ) + logger.debug("format key: %s", key) + try: + self._mode, rawmode = OPEN_INFO[key] + except KeyError as e: + logger.debug("- unsupported format") + msg = "unknown pixel mode" + raise SyntaxError(msg) from e + + logger.debug("- raw mode: %s", rawmode) + logger.debug("- pil mode: %s", self.mode) + + self.info["compression"] = self._compression + + xres = self.tag_v2.get(X_RESOLUTION, 1) + yres = self.tag_v2.get(Y_RESOLUTION, 1) + + if xres and yres: + resunit = self.tag_v2.get(RESOLUTION_UNIT) + if resunit == 2: # dots per inch + self.info["dpi"] = (xres, yres) + elif resunit == 3: # dots per centimeter. convert to dpi + self.info["dpi"] = (xres * 2.54, yres * 2.54) + elif resunit is None: # used to default to 1, but now 2) + self.info["dpi"] = (xres, yres) + # For backward compatibility, + # we also preserve the old behavior + self.info["resolution"] = xres, yres + else: # No absolute unit of measurement + self.info["resolution"] = xres, yres + + # build tile descriptors + x = y = layer = 0 + self.tile = [] + self.use_load_libtiff = READ_LIBTIFF or self._compression != "raw" + if self.use_load_libtiff: + # Decoder expects entire file as one tile. + # There's a buffer size limit in load (64k) + # so large g4 images will fail if we use that + # function. + # + # Setup the one tile for the whole image, then + # use the _load_libtiff function. + + # libtiff handles the fillmode for us, so 1;IR should + # actually be 1;I. Including the R double reverses the + # bits, so stripes of the image are reversed. See + # https://github.com/python-pillow/Pillow/issues/279 + if fillorder == 2: + # Replace fillorder with fillorder=1 + key = key[:3] + (1,) + key[4:] + logger.debug("format key: %s", key) + # this should always work, since all the + # fillorder==2 modes have a corresponding + # fillorder=1 mode + self._mode, rawmode = OPEN_INFO[key] + # YCbCr images with new jpeg compression with pixels in one plane + # unpacked straight into RGB values + if ( + photo == 6 + and self._compression == "jpeg" + and self._planar_configuration == 1 + ): + rawmode = "RGB" + # libtiff always returns the bytes in native order. + # we're expecting image byte order. So, if the rawmode + # contains I;16, we need to convert from native to image + # byte order. + elif rawmode == "I;16": + rawmode = "I;16N" + elif rawmode.endswith((";16B", ";16L")): + rawmode = rawmode[:-1] + "N" + + # Offset in the tile tuple is 0, we go from 0,0 to + # w,h, and we only do this once -- eds + a = (rawmode, self._compression, False, self.tag_v2.offset) + self.tile.append(ImageFile._Tile("libtiff", (0, 0, xsize, ysize), 0, a)) + + elif STRIPOFFSETS in self.tag_v2 or TILEOFFSETS in self.tag_v2: + # striped image + if STRIPOFFSETS in self.tag_v2: + offsets = self.tag_v2[STRIPOFFSETS] + h = self.tag_v2.get(ROWSPERSTRIP, ysize) + w = xsize + else: + # tiled image + offsets = self.tag_v2[TILEOFFSETS] + tilewidth = self.tag_v2.get(TILEWIDTH) + h = self.tag_v2.get(TILELENGTH) + if not isinstance(tilewidth, int) or not isinstance(h, int): + msg = "Invalid tile dimensions" + raise ValueError(msg) + w = tilewidth + + if w == xsize and h == ysize and self._planar_configuration != 2: + # Every tile covers the image. Only use the last offset + offsets = offsets[-1:] + + for offset in offsets: + if x + w > xsize: + stride = w * sum(bps_tuple) / 8 # bytes per line + else: + stride = 0 + + tile_rawmode = rawmode + if self._planar_configuration == 2: + # each band on it's own layer + tile_rawmode = rawmode[layer] + # adjust stride width accordingly + stride /= bps_count + + args = (tile_rawmode, int(stride), 1) + self.tile.append( + ImageFile._Tile( + self._compression, + (x, y, min(x + w, xsize), min(y + h, ysize)), + offset, + args, + ) + ) + x += w + if x >= xsize: + x, y = 0, y + h + if y >= ysize: + y = 0 + layer += 1 + else: + logger.debug("- unsupported data organization") + msg = "unknown data organization" + raise SyntaxError(msg) + + # Fix up info. + if ICCPROFILE in self.tag_v2: + self.info["icc_profile"] = self.tag_v2[ICCPROFILE] + + # fixup palette descriptor + + if self.mode in ["P", "PA"]: + palette = [o8(b // 256) for b in self.tag_v2[COLORMAP]] + self.palette = ImagePalette.raw("RGB;L", b"".join(palette)) + + +# +# -------------------------------------------------------------------- +# Write TIFF files + +# little endian is default except for image modes with +# explicit big endian byte-order + +SAVE_INFO = { + # mode => rawmode, byteorder, photometrics, + # sampleformat, bitspersample, extra + "1": ("1", II, 1, 1, (1,), None), + "L": ("L", II, 1, 1, (8,), None), + "LA": ("LA", II, 1, 1, (8, 8), 2), + "P": ("P", II, 3, 1, (8,), None), + "PA": ("PA", II, 3, 1, (8, 8), 2), + "I": ("I;32S", II, 1, 2, (32,), None), + "I;16": ("I;16", II, 1, 1, (16,), None), + "I;16L": ("I;16L", II, 1, 1, (16,), None), + "F": ("F;32F", II, 1, 3, (32,), None), + "RGB": ("RGB", II, 2, 1, (8, 8, 8), None), + "RGBX": ("RGBX", II, 2, 1, (8, 8, 8, 8), 0), + "RGBA": ("RGBA", II, 2, 1, (8, 8, 8, 8), 2), + "CMYK": ("CMYK", II, 5, 1, (8, 8, 8, 8), None), + "YCbCr": ("YCbCr", II, 6, 1, (8, 8, 8), None), + "LAB": ("LAB", II, 8, 1, (8, 8, 8), None), + "I;16B": ("I;16B", MM, 1, 1, (16,), None), +} + + +def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: + try: + rawmode, prefix, photo, format, bits, extra = SAVE_INFO[im.mode] + except KeyError as e: + msg = f"cannot write mode {im.mode} as TIFF" + raise OSError(msg) from e + + encoderinfo = im.encoderinfo + encoderconfig = im.encoderconfig + + ifd = ImageFileDirectory_v2(prefix=prefix) + if encoderinfo.get("big_tiff"): + ifd._bigtiff = True + + try: + compression = encoderinfo["compression"] + except KeyError: + compression = im.info.get("compression") + if isinstance(compression, int): + # compression value may be from BMP. Ignore it + compression = None + if compression is None: + compression = "raw" + elif compression == "tiff_jpeg": + # OJPEG is obsolete, so use new-style JPEG compression instead + compression = "jpeg" + elif compression == "tiff_deflate": + compression = "tiff_adobe_deflate" + + libtiff = WRITE_LIBTIFF or compression != "raw" + + # required for color libtiff images + ifd[PLANAR_CONFIGURATION] = 1 + + ifd[IMAGEWIDTH] = im.size[0] + ifd[IMAGELENGTH] = im.size[1] + + # write any arbitrary tags passed in as an ImageFileDirectory + if "tiffinfo" in encoderinfo: + info = encoderinfo["tiffinfo"] + elif "exif" in encoderinfo: + info = encoderinfo["exif"] + if isinstance(info, bytes): + exif = Image.Exif() + exif.load(info) + info = exif + else: + info = {} + logger.debug("Tiffinfo Keys: %s", list(info)) + if isinstance(info, ImageFileDirectory_v1): + info = info.to_v2() + for key in info: + if isinstance(info, Image.Exif) and key in TiffTags.TAGS_V2_GROUPS: + ifd[key] = info.get_ifd(key) + else: + ifd[key] = info.get(key) + try: + ifd.tagtype[key] = info.tagtype[key] + except Exception: + pass # might not be an IFD. Might not have populated type + + legacy_ifd = {} + if hasattr(im, "tag"): + legacy_ifd = im.tag.to_v2() + + supplied_tags = {**legacy_ifd, **getattr(im, "tag_v2", {})} + if supplied_tags.get(PLANAR_CONFIGURATION) == 2 and EXTRASAMPLES in supplied_tags: + # If the image used separate component planes, + # then EXTRASAMPLES should be ignored when saving contiguously + if SAMPLESPERPIXEL in supplied_tags: + supplied_tags[SAMPLESPERPIXEL] -= len(supplied_tags[EXTRASAMPLES]) + del supplied_tags[EXTRASAMPLES] + for tag in ( + # IFD offset that may not be correct in the saved image + EXIFIFD, + # Determined by the image format and should not be copied from legacy_ifd. + SAMPLEFORMAT, + ): + if tag in supplied_tags: + del supplied_tags[tag] + + # additions written by Greg Couch, gregc@cgl.ucsf.edu + # inspired by image-sig posting from Kevin Cazabon, kcazabon@home.com + if hasattr(im, "tag_v2"): + # preserve tags from original TIFF image file + for key in ( + RESOLUTION_UNIT, + X_RESOLUTION, + Y_RESOLUTION, + IPTC_NAA_CHUNK, + PHOTOSHOP_CHUNK, + XMP, + ): + if key in im.tag_v2: + if key == IPTC_NAA_CHUNK and im.tag_v2.tagtype[key] not in ( + TiffTags.BYTE, + TiffTags.UNDEFINED, + ): + del supplied_tags[key] + else: + ifd[key] = im.tag_v2[key] + ifd.tagtype[key] = im.tag_v2.tagtype[key] + + # preserve ICC profile (should also work when saving other formats + # which support profiles as TIFF) -- 2008-06-06 Florian Hoech + icc = encoderinfo.get("icc_profile", im.info.get("icc_profile")) + if icc: + ifd[ICCPROFILE] = icc + + for key, name in [ + (IMAGEDESCRIPTION, "description"), + (X_RESOLUTION, "resolution"), + (Y_RESOLUTION, "resolution"), + (X_RESOLUTION, "x_resolution"), + (Y_RESOLUTION, "y_resolution"), + (RESOLUTION_UNIT, "resolution_unit"), + (SOFTWARE, "software"), + (DATE_TIME, "date_time"), + (ARTIST, "artist"), + (COPYRIGHT, "copyright"), + ]: + if name in encoderinfo: + ifd[key] = encoderinfo[name] + + dpi = encoderinfo.get("dpi") + if dpi: + ifd[RESOLUTION_UNIT] = 2 + ifd[X_RESOLUTION] = dpi[0] + ifd[Y_RESOLUTION] = dpi[1] + + if bits != (1,): + ifd[BITSPERSAMPLE] = bits + if len(bits) != 1: + ifd[SAMPLESPERPIXEL] = len(bits) + if extra is not None: + ifd[EXTRASAMPLES] = extra + if format != 1: + ifd[SAMPLEFORMAT] = format + + if PHOTOMETRIC_INTERPRETATION not in ifd: + ifd[PHOTOMETRIC_INTERPRETATION] = photo + elif im.mode in ("1", "L") and ifd[PHOTOMETRIC_INTERPRETATION] == 0: + if im.mode == "1": + inverted_im = im.copy() + px = inverted_im.load() + if px is not None: + for y in range(inverted_im.height): + for x in range(inverted_im.width): + px[x, y] = 0 if px[x, y] == 255 else 255 + im = inverted_im + else: + im = ImageOps.invert(im) + + if im.mode in ["P", "PA"]: + lut = im.im.getpalette("RGB", "RGB;L") + colormap = [] + colors = len(lut) // 3 + for i in range(3): + colormap += [v * 256 for v in lut[colors * i : colors * (i + 1)]] + colormap += [0] * (256 - colors) + ifd[COLORMAP] = colormap + # data orientation + w, h = ifd[IMAGEWIDTH], ifd[IMAGELENGTH] + stride = len(bits) * ((w * bits[0] + 7) // 8) + if ROWSPERSTRIP not in ifd: + # aim for given strip size (64 KB by default) when using libtiff writer + if libtiff: + im_strip_size = encoderinfo.get("strip_size", STRIP_SIZE) + rows_per_strip = 1 if stride == 0 else min(im_strip_size // stride, h) + # JPEG encoder expects multiple of 8 rows + if compression == "jpeg": + rows_per_strip = min(((rows_per_strip + 7) // 8) * 8, h) + else: + rows_per_strip = h + if rows_per_strip == 0: + rows_per_strip = 1 + ifd[ROWSPERSTRIP] = rows_per_strip + strip_byte_counts = 1 if stride == 0 else stride * ifd[ROWSPERSTRIP] + strips_per_image = (h + ifd[ROWSPERSTRIP] - 1) // ifd[ROWSPERSTRIP] + if strip_byte_counts >= 2**16: + ifd.tagtype[STRIPBYTECOUNTS] = TiffTags.LONG + ifd[STRIPBYTECOUNTS] = (strip_byte_counts,) * (strips_per_image - 1) + ( + stride * h - strip_byte_counts * (strips_per_image - 1), + ) + ifd[STRIPOFFSETS] = tuple( + range(0, strip_byte_counts * strips_per_image, strip_byte_counts) + ) # this is adjusted by IFD writer + # no compression by default: + ifd[COMPRESSION] = COMPRESSION_INFO_REV.get(compression, 1) + + if im.mode == "YCbCr": + for tag, default_value in { + YCBCRSUBSAMPLING: (1, 1), + REFERENCEBLACKWHITE: (0, 255, 128, 255, 128, 255), + }.items(): + ifd.setdefault(tag, default_value) + + blocklist = [TILEWIDTH, TILELENGTH, TILEOFFSETS, TILEBYTECOUNTS] + if libtiff: + if "quality" in encoderinfo: + quality = encoderinfo["quality"] + if not isinstance(quality, int) or quality < 0 or quality > 100: + msg = "Invalid quality setting" + raise ValueError(msg) + if compression != "jpeg": + msg = "quality setting only supported for 'jpeg' compression" + raise ValueError(msg) + ifd[JPEGQUALITY] = quality + + logger.debug("Saving using libtiff encoder") + logger.debug("Items: %s", sorted(ifd.items())) + _fp = 0 + if hasattr(fp, "fileno"): + try: + fp.seek(0) + _fp = fp.fileno() + except io.UnsupportedOperation: + pass + + # optional types for non core tags + types = {} + # STRIPOFFSETS and STRIPBYTECOUNTS are added by the library + # based on the data in the strip. + # OSUBFILETYPE is deprecated. + # The other tags expect arrays with a certain length (fixed or depending on + # BITSPERSAMPLE, etc), passing arrays with a different length will result in + # segfaults. Block these tags until we add extra validation. + # SUBIFD may also cause a segfault. + blocklist += [ + OSUBFILETYPE, + REFERENCEBLACKWHITE, + STRIPBYTECOUNTS, + STRIPOFFSETS, + TRANSFERFUNCTION, + SUBIFD, + ] + + # bits per sample is a single short in the tiff directory, not a list. + atts: dict[int, Any] = {BITSPERSAMPLE: bits[0]} + # Merge the ones that we have with (optional) more bits from + # the original file, e.g x,y resolution so that we can + # save(load('')) == original file. + for tag, value in itertools.chain(ifd.items(), supplied_tags.items()): + # Libtiff can only process certain core items without adding + # them to the custom dictionary. + # Custom items are supported for int, float, unicode, string and byte + # values. Other types and tuples require a tagtype. + if tag not in TiffTags.LIBTIFF_CORE: + if tag in TiffTags.TAGS_V2_GROUPS: + types[tag] = TiffTags.LONG8 + elif tag in ifd.tagtype: + types[tag] = ifd.tagtype[tag] + elif isinstance(value, (int, float, str, bytes)) or ( + isinstance(value, tuple) + and all(isinstance(v, (int, float, IFDRational)) for v in value) + ): + type = TiffTags.lookup(tag).type + if type: + types[tag] = type + if tag not in atts and tag not in blocklist: + if isinstance(value, str): + atts[tag] = value.encode("ascii", "replace") + b"\0" + elif isinstance(value, IFDRational): + atts[tag] = float(value) + else: + atts[tag] = value + + if SAMPLEFORMAT in atts and len(atts[SAMPLEFORMAT]) == 1: + atts[SAMPLEFORMAT] = atts[SAMPLEFORMAT][0] + + logger.debug("Converted items: %s", sorted(atts.items())) + + # libtiff always expects the bytes in native order. + # we're storing image byte order. So, if the rawmode + # contains I;16, we need to convert from native to image + # byte order. + if im.mode in ("I;16", "I;16B", "I;16L"): + rawmode = "I;16N" + + # Pass tags as sorted list so that the tags are set in a fixed order. + # This is required by libtiff for some tags. For example, the JPEGQUALITY + # pseudo tag requires that the COMPRESS tag was already set. + tags = list(atts.items()) + tags.sort() + a = (rawmode, compression, _fp, filename, tags, types) + encoder = Image._getencoder(im.mode, "libtiff", a, encoderconfig) + encoder.setimage(im.im, (0, 0) + im.size) + while True: + errcode, data = encoder.encode(ImageFile.MAXBLOCK)[1:] + if not _fp: + fp.write(data) + if errcode: + break + if errcode < 0: + msg = f"encoder error {errcode} when writing image file" + raise OSError(msg) + + else: + for tag in blocklist: + del ifd[tag] + offset = ifd.save(fp) + + ImageFile._save( + im, + fp, + [ImageFile._Tile("raw", (0, 0) + im.size, offset, (rawmode, stride, 1))], + ) + + # -- helper for multi-page save -- + if "_debug_multipage" in encoderinfo: + # just to access o32 and o16 (using correct byte order) + setattr(im, "_debug_multipage", ifd) + + +class AppendingTiffWriter(io.BytesIO): + fieldSizes = [ + 0, # None + 1, # byte + 1, # ascii + 2, # short + 4, # long + 8, # rational + 1, # sbyte + 1, # undefined + 2, # sshort + 4, # slong + 8, # srational + 4, # float + 8, # double + 4, # ifd + 2, # unicode + 4, # complex + 8, # long8 + ] + + Tags = { + 273, # StripOffsets + 288, # FreeOffsets + 324, # TileOffsets + 519, # JPEGQTables + 520, # JPEGDCTables + 521, # JPEGACTables + } + + def __init__(self, fn: StrOrBytesPath | IO[bytes], new: bool = False) -> None: + self.f: IO[bytes] + if is_path(fn): + self.name = fn + self.close_fp = True + try: + self.f = open(fn, "w+b" if new else "r+b") + except OSError: + self.f = open(fn, "w+b") + else: + self.f = cast(IO[bytes], fn) + self.close_fp = False + self.beginning = self.f.tell() + self.setup() + + def setup(self) -> None: + # Reset everything. + self.f.seek(self.beginning, os.SEEK_SET) + + self.whereToWriteNewIFDOffset: int | None = None + self.offsetOfNewPage = 0 + + self.IIMM = iimm = self.f.read(4) + self._bigtiff = b"\x2b" in iimm + if not iimm: + # empty file - first page + self.isFirst = True + return + + self.isFirst = False + if iimm not in PREFIXES: + msg = "Invalid TIFF file header" + raise RuntimeError(msg) + + self.setEndian("<" if iimm.startswith(II) else ">") + + if self._bigtiff: + self.f.seek(4, os.SEEK_CUR) + self.skipIFDs() + self.goToEnd() + + def finalize(self) -> None: + if self.isFirst: + return + + # fix offsets + self.f.seek(self.offsetOfNewPage) + + iimm = self.f.read(4) + if not iimm: + # Make it easy to finish a frame without committing to a new one. + return + + if iimm != self.IIMM: + msg = "IIMM of new page doesn't match IIMM of first page" + raise RuntimeError(msg) + + if self._bigtiff: + self.f.seek(4, os.SEEK_CUR) + ifd_offset = self._read(8 if self._bigtiff else 4) + ifd_offset += self.offsetOfNewPage + assert self.whereToWriteNewIFDOffset is not None + self.f.seek(self.whereToWriteNewIFDOffset) + self._write(ifd_offset, 8 if self._bigtiff else 4) + self.f.seek(ifd_offset) + self.fixIFD() + + def newFrame(self) -> None: + # Call this to finish a frame. + self.finalize() + self.setup() + + def __enter__(self) -> AppendingTiffWriter: + return self + + def __exit__(self, *args: object) -> None: + if self.close_fp: + self.close() + + def tell(self) -> int: + return self.f.tell() - self.offsetOfNewPage + + def seek(self, offset: int, whence: int = io.SEEK_SET) -> int: + """ + :param offset: Distance to seek. + :param whence: Whether the distance is relative to the start, + end or current position. + :returns: The resulting position, relative to the start. + """ + if whence == os.SEEK_SET: + offset += self.offsetOfNewPage + + self.f.seek(offset, whence) + return self.tell() + + def goToEnd(self) -> None: + self.f.seek(0, os.SEEK_END) + pos = self.f.tell() + + # pad to 16 byte boundary + pad_bytes = 16 - pos % 16 + if 0 < pad_bytes < 16: + self.f.write(bytes(pad_bytes)) + self.offsetOfNewPage = self.f.tell() + + def setEndian(self, endian: str) -> None: + self.endian = endian + self.longFmt = f"{self.endian}L" + self.shortFmt = f"{self.endian}H" + self.tagFormat = f"{self.endian}HH" + ("Q" if self._bigtiff else "L") + + def skipIFDs(self) -> None: + while True: + ifd_offset = self._read(8 if self._bigtiff else 4) + if ifd_offset == 0: + self.whereToWriteNewIFDOffset = self.f.tell() - ( + 8 if self._bigtiff else 4 + ) + break + + self.f.seek(ifd_offset) + num_tags = self._read(8 if self._bigtiff else 2) + self.f.seek(num_tags * (20 if self._bigtiff else 12), os.SEEK_CUR) + + def write(self, data: Buffer, /) -> int: + return self.f.write(data) + + def _fmt(self, field_size: int) -> str: + try: + return {2: "H", 4: "L", 8: "Q"}[field_size] + except KeyError: + msg = "offset is not supported" + raise RuntimeError(msg) + + def _read(self, field_size: int) -> int: + (value,) = struct.unpack( + self.endian + self._fmt(field_size), self.f.read(field_size) + ) + return value + + def readShort(self) -> int: + return self._read(2) + + def readLong(self) -> int: + return self._read(4) + + @staticmethod + def _verify_bytes_written(bytes_written: int | None, expected: int) -> None: + if bytes_written is not None and bytes_written != expected: + msg = f"wrote only {bytes_written} bytes but wanted {expected}" + raise RuntimeError(msg) + + def _rewriteLast( + self, value: int, field_size: int, new_field_size: int = 0 + ) -> None: + self.f.seek(-field_size, os.SEEK_CUR) + if not new_field_size: + new_field_size = field_size + bytes_written = self.f.write( + struct.pack(self.endian + self._fmt(new_field_size), value) + ) + self._verify_bytes_written(bytes_written, new_field_size) + + def rewriteLastShortToLong(self, value: int) -> None: + self._rewriteLast(value, 2, 4) + + def rewriteLastShort(self, value: int) -> None: + return self._rewriteLast(value, 2) + + def rewriteLastLong(self, value: int) -> None: + return self._rewriteLast(value, 4) + + def _write(self, value: int, field_size: int) -> None: + bytes_written = self.f.write( + struct.pack(self.endian + self._fmt(field_size), value) + ) + self._verify_bytes_written(bytes_written, field_size) + + def writeShort(self, value: int) -> None: + self._write(value, 2) + + def writeLong(self, value: int) -> None: + self._write(value, 4) + + def close(self) -> None: + self.finalize() + if self.close_fp: + self.f.close() + + def fixIFD(self) -> None: + num_tags = self._read(8 if self._bigtiff else 2) + + for i in range(num_tags): + tag, field_type, count = struct.unpack( + self.tagFormat, self.f.read(12 if self._bigtiff else 8) + ) + + field_size = self.fieldSizes[field_type] + total_size = field_size * count + fmt_size = 8 if self._bigtiff else 4 + is_local = total_size <= fmt_size + if not is_local: + offset = self._read(fmt_size) + self.offsetOfNewPage + self._rewriteLast(offset, fmt_size) + + if tag in self.Tags: + cur_pos = self.f.tell() + + logger.debug( + "fixIFD: %s (%d) - type: %s (%d) - type size: %d - count: %d", + TiffTags.lookup(tag).name, + tag, + TYPES.get(field_type, "unknown"), + field_type, + field_size, + count, + ) + + if is_local: + self._fixOffsets(count, field_size) + self.f.seek(cur_pos + fmt_size) + else: + self.f.seek(offset) + self._fixOffsets(count, field_size) + self.f.seek(cur_pos) + + elif is_local: + # skip the locally stored value that is not an offset + self.f.seek(fmt_size, os.SEEK_CUR) + + def _fixOffsets(self, count: int, field_size: int) -> None: + for i in range(count): + offset = self._read(field_size) + offset += self.offsetOfNewPage + + new_field_size = 0 + if self._bigtiff and field_size in (2, 4) and offset >= 2**32: + # offset is now too large - we must convert long to long8 + new_field_size = 8 + elif field_size == 2 and offset >= 2**16: + # offset is now too large - we must convert short to long + new_field_size = 4 + if new_field_size: + if count != 1: + msg = "not implemented" + raise RuntimeError(msg) # XXX TODO + + # simple case - the offset is just one and therefore it is + # local (not referenced with another offset) + self._rewriteLast(offset, field_size, new_field_size) + # Move back past the new offset, past 'count', and before 'field_type' + rewind = -new_field_size - 4 - 2 + self.f.seek(rewind, os.SEEK_CUR) + self.writeShort(new_field_size) # rewrite the type + self.f.seek(2 - rewind, os.SEEK_CUR) + else: + self._rewriteLast(offset, field_size) + + def fixOffsets( + self, count: int, isShort: bool = False, isLong: bool = False + ) -> None: + if isShort: + field_size = 2 + elif isLong: + field_size = 4 + else: + field_size = 0 + return self._fixOffsets(count, field_size) + + +def _save_all(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: + append_images = list(im.encoderinfo.get("append_images", [])) + if not hasattr(im, "n_frames") and not append_images: + return _save(im, fp, filename) + + cur_idx = im.tell() + try: + with AppendingTiffWriter(fp) as tf: + for ims in [im] + append_images: + encoderinfo = ims._attach_default_encoderinfo(im) + if not hasattr(ims, "encoderconfig"): + ims.encoderconfig = () + nfr = getattr(ims, "n_frames", 1) + + for idx in range(nfr): + ims.seek(idx) + ims.load() + _save(ims, tf, filename) + tf.newFrame() + ims.encoderinfo = encoderinfo + finally: + im.seek(cur_idx) + + +# +# -------------------------------------------------------------------- +# Register + +Image.register_open(TiffImageFile.format, TiffImageFile, _accept) +Image.register_save(TiffImageFile.format, _save) +Image.register_save_all(TiffImageFile.format, _save_all) + +Image.register_extensions(TiffImageFile.format, [".tif", ".tiff"]) + +Image.register_mime(TiffImageFile.format, "image/tiff") diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/TiffTags.py b/presentation/.venv/lib/python3.12/site-packages/PIL/TiffTags.py new file mode 100644 index 0000000..613a3b7 --- /dev/null +++ b/presentation/.venv/lib/python3.12/site-packages/PIL/TiffTags.py @@ -0,0 +1,566 @@ +# +# The Python Imaging Library. +# $Id$ +# +# TIFF tags +# +# This module provides clear-text names for various well-known +# TIFF tags. the TIFF codec works just fine without it. +# +# Copyright (c) Secret Labs AB 1999. +# +# See the README file for information on usage and redistribution. +# + +## +# This module provides constants and clear-text names for various +# well-known TIFF tags. +## +from __future__ import annotations + +from typing import NamedTuple + + +class _TagInfo(NamedTuple): + value: int | None + name: str + type: int | None + length: int | None + enum: dict[str, int] + + +class TagInfo(_TagInfo): + __slots__: list[str] = [] + + def __new__( + cls, + value: int | None = None, + name: str = "unknown", + type: int | None = None, + length: int | None = None, + enum: dict[str, int] | None = None, + ) -> TagInfo: + return super().__new__(cls, value, name, type, length, enum or {}) + + def cvt_enum(self, value: str) -> int | str: + # Using get will call hash(value), which can be expensive + # for some types (e.g. Fraction). Since self.enum is rarely + # used, it's usually better to test it first. + return self.enum.get(value, value) if self.enum else value + + +def lookup(tag: int, group: int | None = None) -> TagInfo: + """ + :param tag: Integer tag number + :param group: Which :py:data:`~PIL.TiffTags.TAGS_V2_GROUPS` to look in + + .. versionadded:: 8.3.0 + + :returns: Taginfo namedtuple, From the ``TAGS_V2`` info if possible, + otherwise just populating the value and name from ``TAGS``. + If the tag is not recognized, "unknown" is returned for the name + + """ + + if group is not None: + info = TAGS_V2_GROUPS[group].get(tag) if group in TAGS_V2_GROUPS else None + else: + info = TAGS_V2.get(tag) + return info or TagInfo(tag, TAGS.get(tag, "unknown")) + + +## +# Map tag numbers to tag info. +# +# id: (Name, Type, Length[, enum_values]) +# +# The length here differs from the length in the tiff spec. For +# numbers, the tiff spec is for the number of fields returned. We +# agree here. For string-like types, the tiff spec uses the length of +# field in bytes. In Pillow, we are using the number of expected +# fields, in general 1 for string-like types. + + +BYTE = 1 +ASCII = 2 +SHORT = 3 +LONG = 4 +RATIONAL = 5 +SIGNED_BYTE = 6 +UNDEFINED = 7 +SIGNED_SHORT = 8 +SIGNED_LONG = 9 +SIGNED_RATIONAL = 10 +FLOAT = 11 +DOUBLE = 12 +IFD = 13 +LONG8 = 16 + +_tags_v2: dict[int, tuple[str, int, int] | tuple[str, int, int, dict[str, int]]] = { + 254: ("NewSubfileType", LONG, 1), + 255: ("SubfileType", SHORT, 1), + 256: ("ImageWidth", LONG, 1), + 257: ("ImageLength", LONG, 1), + 258: ("BitsPerSample", SHORT, 0), + 259: ( + "Compression", + SHORT, + 1, + { + "Uncompressed": 1, + "CCITT 1d": 2, + "Group 3 Fax": 3, + "Group 4 Fax": 4, + "LZW": 5, + "JPEG": 6, + "PackBits": 32773, + }, + ), + 262: ( + "PhotometricInterpretation", + SHORT, + 1, + { + "WhiteIsZero": 0, + "BlackIsZero": 1, + "RGB": 2, + "RGB Palette": 3, + "Transparency Mask": 4, + "CMYK": 5, + "YCbCr": 6, + "CieLAB": 8, + "CFA": 32803, # TIFF/EP, Adobe DNG + "LinearRaw": 32892, # Adobe DNG + }, + ), + 263: ("Threshholding", SHORT, 1), + 264: ("CellWidth", SHORT, 1), + 265: ("CellLength", SHORT, 1), + 266: ("FillOrder", SHORT, 1), + 269: ("DocumentName", ASCII, 1), + 270: ("ImageDescription", ASCII, 1), + 271: ("Make", ASCII, 1), + 272: ("Model", ASCII, 1), + 273: ("StripOffsets", LONG, 0), + 274: ("Orientation", SHORT, 1), + 277: ("SamplesPerPixel", SHORT, 1), + 278: ("RowsPerStrip", LONG, 1), + 279: ("StripByteCounts", LONG, 0), + 280: ("MinSampleValue", SHORT, 0), + 281: ("MaxSampleValue", SHORT, 0), + 282: ("XResolution", RATIONAL, 1), + 283: ("YResolution", RATIONAL, 1), + 284: ("PlanarConfiguration", SHORT, 1, {"Contiguous": 1, "Separate": 2}), + 285: ("PageName", ASCII, 1), + 286: ("XPosition", RATIONAL, 1), + 287: ("YPosition", RATIONAL, 1), + 288: ("FreeOffsets", LONG, 1), + 289: ("FreeByteCounts", LONG, 1), + 290: ("GrayResponseUnit", SHORT, 1), + 291: ("GrayResponseCurve", SHORT, 0), + 292: ("T4Options", LONG, 1), + 293: ("T6Options", LONG, 1), + 296: ("ResolutionUnit", SHORT, 1, {"none": 1, "inch": 2, "cm": 3}), + 297: ("PageNumber", SHORT, 2), + 301: ("TransferFunction", SHORT, 0), + 305: ("Software", ASCII, 1), + 306: ("DateTime", ASCII, 1), + 315: ("Artist", ASCII, 1), + 316: ("HostComputer", ASCII, 1), + 317: ("Predictor", SHORT, 1, {"none": 1, "Horizontal Differencing": 2}), + 318: ("WhitePoint", RATIONAL, 2), + 319: ("PrimaryChromaticities", RATIONAL, 6), + 320: ("ColorMap", SHORT, 0), + 321: ("HalftoneHints", SHORT, 2), + 322: ("TileWidth", LONG, 1), + 323: ("TileLength", LONG, 1), + 324: ("TileOffsets", LONG, 0), + 325: ("TileByteCounts", LONG, 0), + 330: ("SubIFDs", LONG, 0), + 332: ("InkSet", SHORT, 1), + 333: ("InkNames", ASCII, 1), + 334: ("NumberOfInks", SHORT, 1), + 336: ("DotRange", SHORT, 0), + 337: ("TargetPrinter", ASCII, 1), + 338: ("ExtraSamples", SHORT, 0), + 339: ("SampleFormat", SHORT, 0), + 340: ("SMinSampleValue", DOUBLE, 0), + 341: ("SMaxSampleValue", DOUBLE, 0), + 342: ("TransferRange", SHORT, 6), + 347: ("JPEGTables", UNDEFINED, 1), + # obsolete JPEG tags + 512: ("JPEGProc", SHORT, 1), + 513: ("JPEGInterchangeFormat", LONG, 1), + 514: ("JPEGInterchangeFormatLength", LONG, 1), + 515: ("JPEGRestartInterval", SHORT, 1), + 517: ("JPEGLosslessPredictors", SHORT, 0), + 518: ("JPEGPointTransforms", SHORT, 0), + 519: ("JPEGQTables", LONG, 0), + 520: ("JPEGDCTables", LONG, 0), + 521: ("JPEGACTables", LONG, 0), + 529: ("YCbCrCoefficients", RATIONAL, 3), + 530: ("YCbCrSubSampling", SHORT, 2), + 531: ("YCbCrPositioning", SHORT, 1), + 532: ("ReferenceBlackWhite", RATIONAL, 6), + 700: ("XMP", BYTE, 0), + # Four private SGI tags + 32995: ("Matteing", SHORT, 1), + 32996: ("DataType", SHORT, 0), + 32997: ("ImageDepth", LONG, 1), + 32998: ("TileDepth", LONG, 1), + 33432: ("Copyright", ASCII, 1), + 33723: ("IptcNaaInfo", UNDEFINED, 1), + 34377: ("PhotoshopInfo", BYTE, 0), + # FIXME add more tags here + 34665: ("ExifIFD", LONG, 1), + 34675: ("ICCProfile", UNDEFINED, 1), + 34853: ("GPSInfoIFD", LONG, 1), + 36864: ("ExifVersion", UNDEFINED, 1), + 37724: ("ImageSourceData", UNDEFINED, 1), + 40965: ("InteroperabilityIFD", LONG, 1), + 41730: ("CFAPattern", UNDEFINED, 1), + # MPInfo + 45056: ("MPFVersion", UNDEFINED, 1), + 45057: ("NumberOfImages", LONG, 1), + 45058: ("MPEntry", UNDEFINED, 1), + 45059: ("ImageUIDList", UNDEFINED, 0), # UNDONE, check + 45060: ("TotalFrames", LONG, 1), + 45313: ("MPIndividualNum", LONG, 1), + 45569: ("PanOrientation", LONG, 1), + 45570: ("PanOverlap_H", RATIONAL, 1), + 45571: ("PanOverlap_V", RATIONAL, 1), + 45572: ("BaseViewpointNum", LONG, 1), + 45573: ("ConvergenceAngle", SIGNED_RATIONAL, 1), + 45574: ("BaselineLength", RATIONAL, 1), + 45575: ("VerticalDivergence", SIGNED_RATIONAL, 1), + 45576: ("AxisDistance_X", SIGNED_RATIONAL, 1), + 45577: ("AxisDistance_Y", SIGNED_RATIONAL, 1), + 45578: ("AxisDistance_Z", SIGNED_RATIONAL, 1), + 45579: ("YawAngle", SIGNED_RATIONAL, 1), + 45580: ("PitchAngle", SIGNED_RATIONAL, 1), + 45581: ("RollAngle", SIGNED_RATIONAL, 1), + 40960: ("FlashPixVersion", UNDEFINED, 1), + 50741: ("MakerNoteSafety", SHORT, 1, {"Unsafe": 0, "Safe": 1}), + 50780: ("BestQualityScale", RATIONAL, 1), + 50838: ("ImageJMetaDataByteCounts", LONG, 0), # Can be more than one + 50839: ("ImageJMetaData", UNDEFINED, 1), # see Issue #2006 +} +_tags_v2_groups = { + # ExifIFD + 34665: { + 36864: ("ExifVersion", UNDEFINED, 1), + 40960: ("FlashPixVersion", UNDEFINED, 1), + 40965: ("InteroperabilityIFD", LONG, 1), + 41730: ("CFAPattern", UNDEFINED, 1), + }, + # GPSInfoIFD + 34853: { + 0: ("GPSVersionID", BYTE, 4), + 1: ("GPSLatitudeRef", ASCII, 2), + 2: ("GPSLatitude", RATIONAL, 3), + 3: ("GPSLongitudeRef", ASCII, 2), + 4: ("GPSLongitude", RATIONAL, 3), + 5: ("GPSAltitudeRef", BYTE, 1), + 6: ("GPSAltitude", RATIONAL, 1), + 7: ("GPSTimeStamp", RATIONAL, 3), + 8: ("GPSSatellites", ASCII, 0), + 9: ("GPSStatus", ASCII, 2), + 10: ("GPSMeasureMode", ASCII, 2), + 11: ("GPSDOP", RATIONAL, 1), + 12: ("GPSSpeedRef", ASCII, 2), + 13: ("GPSSpeed", RATIONAL, 1), + 14: ("GPSTrackRef", ASCII, 2), + 15: ("GPSTrack", RATIONAL, 1), + 16: ("GPSImgDirectionRef", ASCII, 2), + 17: ("GPSImgDirection", RATIONAL, 1), + 18: ("GPSMapDatum", ASCII, 0), + 19: ("GPSDestLatitudeRef", ASCII, 2), + 20: ("GPSDestLatitude", RATIONAL, 3), + 21: ("GPSDestLongitudeRef", ASCII, 2), + 22: ("GPSDestLongitude", RATIONAL, 3), + 23: ("GPSDestBearingRef", ASCII, 2), + 24: ("GPSDestBearing", RATIONAL, 1), + 25: ("GPSDestDistanceRef", ASCII, 2), + 26: ("GPSDestDistance", RATIONAL, 1), + 27: ("GPSProcessingMethod", UNDEFINED, 0), + 28: ("GPSAreaInformation", UNDEFINED, 0), + 29: ("GPSDateStamp", ASCII, 11), + 30: ("GPSDifferential", SHORT, 1), + }, + # InteroperabilityIFD + 40965: {1: ("InteropIndex", ASCII, 1), 2: ("InteropVersion", UNDEFINED, 1)}, +} + +# Legacy Tags structure +# these tags aren't included above, but were in the previous versions +TAGS: dict[int | tuple[int, int], str] = { + 347: "JPEGTables", + 700: "XMP", + # Additional Exif Info + 32932: "Wang Annotation", + 33434: "ExposureTime", + 33437: "FNumber", + 33445: "MD FileTag", + 33446: "MD ScalePixel", + 33447: "MD ColorTable", + 33448: "MD LabName", + 33449: "MD SampleInfo", + 33450: "MD PrepDate", + 33451: "MD PrepTime", + 33452: "MD FileUnits", + 33550: "ModelPixelScaleTag", + 33723: "IptcNaaInfo", + 33918: "INGR Packet Data Tag", + 33919: "INGR Flag Registers", + 33920: "IrasB Transformation Matrix", + 33922: "ModelTiepointTag", + 34264: "ModelTransformationTag", + 34377: "PhotoshopInfo", + 34735: "GeoKeyDirectoryTag", + 34736: "GeoDoubleParamsTag", + 34737: "GeoAsciiParamsTag", + 34850: "ExposureProgram", + 34852: "SpectralSensitivity", + 34855: "ISOSpeedRatings", + 34856: "OECF", + 34864: "SensitivityType", + 34865: "StandardOutputSensitivity", + 34866: "RecommendedExposureIndex", + 34867: "ISOSpeed", + 34868: "ISOSpeedLatitudeyyy", + 34869: "ISOSpeedLatitudezzz", + 34908: "HylaFAX FaxRecvParams", + 34909: "HylaFAX FaxSubAddress", + 34910: "HylaFAX FaxRecvTime", + 36864: "ExifVersion", + 36867: "DateTimeOriginal", + 36868: "DateTimeDigitized", + 37121: "ComponentsConfiguration", + 37122: "CompressedBitsPerPixel", + 37724: "ImageSourceData", + 37377: "ShutterSpeedValue", + 37378: "ApertureValue", + 37379: "BrightnessValue", + 37380: "ExposureBiasValue", + 37381: "MaxApertureValue", + 37382: "SubjectDistance", + 37383: "MeteringMode", + 37384: "LightSource", + 37385: "Flash", + 37386: "FocalLength", + 37396: "SubjectArea", + 37500: "MakerNote", + 37510: "UserComment", + 37520: "SubSec", + 37521: "SubSecTimeOriginal", + 37522: "SubsecTimeDigitized", + 40960: "FlashPixVersion", + 40961: "ColorSpace", + 40962: "PixelXDimension", + 40963: "PixelYDimension", + 40964: "RelatedSoundFile", + 40965: "InteroperabilityIFD", + 41483: "FlashEnergy", + 41484: "SpatialFrequencyResponse", + 41486: "FocalPlaneXResolution", + 41487: "FocalPlaneYResolution", + 41488: "FocalPlaneResolutionUnit", + 41492: "SubjectLocation", + 41493: "ExposureIndex", + 41495: "SensingMethod", + 41728: "FileSource", + 41729: "SceneType", + 41730: "CFAPattern", + 41985: "CustomRendered", + 41986: "ExposureMode", + 41987: "WhiteBalance", + 41988: "DigitalZoomRatio", + 41989: "FocalLengthIn35mmFilm", + 41990: "SceneCaptureType", + 41991: "GainControl", + 41992: "Contrast", + 41993: "Saturation", + 41994: "Sharpness", + 41995: "DeviceSettingDescription", + 41996: "SubjectDistanceRange", + 42016: "ImageUniqueID", + 42032: "CameraOwnerName", + 42033: "BodySerialNumber", + 42034: "LensSpecification", + 42035: "LensMake", + 42036: "LensModel", + 42037: "LensSerialNumber", + 42112: "GDAL_METADATA", + 42113: "GDAL_NODATA", + 42240: "Gamma", + 50215: "Oce Scanjob Description", + 50216: "Oce Application Selector", + 50217: "Oce Identification Number", + 50218: "Oce ImageLogic Characteristics", + # Adobe DNG + 50706: "DNGVersion", + 50707: "DNGBackwardVersion", + 50708: "UniqueCameraModel", + 50709: "LocalizedCameraModel", + 50710: "CFAPlaneColor", + 50711: "CFALayout", + 50712: "LinearizationTable", + 50713: "BlackLevelRepeatDim", + 50714: "BlackLevel", + 50715: "BlackLevelDeltaH", + 50716: "BlackLevelDeltaV", + 50717: "WhiteLevel", + 50718: "DefaultScale", + 50719: "DefaultCropOrigin", + 50720: "DefaultCropSize", + 50721: "ColorMatrix1", + 50722: "ColorMatrix2", + 50723: "CameraCalibration1", + 50724: "CameraCalibration2", + 50725: "ReductionMatrix1", + 50726: "ReductionMatrix2", + 50727: "AnalogBalance", + 50728: "AsShotNeutral", + 50729: "AsShotWhiteXY", + 50730: "BaselineExposure", + 50731: "BaselineNoise", + 50732: "BaselineSharpness", + 50733: "BayerGreenSplit", + 50734: "LinearResponseLimit", + 50735: "CameraSerialNumber", + 50736: "LensInfo", + 50737: "ChromaBlurRadius", + 50738: "AntiAliasStrength", + 50740: "DNGPrivateData", + 50778: "CalibrationIlluminant1", + 50779: "CalibrationIlluminant2", + 50784: "Alias Layer Metadata", +} + +TAGS_V2: dict[int, TagInfo] = {} +TAGS_V2_GROUPS: dict[int, dict[int, TagInfo]] = {} + + +def _populate() -> None: + for k, v in _tags_v2.items(): + # Populate legacy structure. + TAGS[k] = v[0] + if len(v) == 4: + for sk, sv in v[3].items(): + TAGS[(k, sv)] = sk + + TAGS_V2[k] = TagInfo(k, *v) + + for group, tags in _tags_v2_groups.items(): + TAGS_V2_GROUPS[group] = {k: TagInfo(k, *v) for k, v in tags.items()} + + +_populate() +## +# Map type numbers to type names -- defined in ImageFileDirectory. + +TYPES: dict[int, str] = {} + +# +# These tags are handled by default in libtiff, without +# adding to the custom dictionary. From tif_dir.c, searching for +# case TIFFTAG in the _TIFFVSetField function: +# Line: item. +# 148: case TIFFTAG_SUBFILETYPE: +# 151: case TIFFTAG_IMAGEWIDTH: +# 154: case TIFFTAG_IMAGELENGTH: +# 157: case TIFFTAG_BITSPERSAMPLE: +# 181: case TIFFTAG_COMPRESSION: +# 202: case TIFFTAG_PHOTOMETRIC: +# 205: case TIFFTAG_THRESHHOLDING: +# 208: case TIFFTAG_FILLORDER: +# 214: case TIFFTAG_ORIENTATION: +# 221: case TIFFTAG_SAMPLESPERPIXEL: +# 228: case TIFFTAG_ROWSPERSTRIP: +# 238: case TIFFTAG_MINSAMPLEVALUE: +# 241: case TIFFTAG_MAXSAMPLEVALUE: +# 244: case TIFFTAG_SMINSAMPLEVALUE: +# 247: case TIFFTAG_SMAXSAMPLEVALUE: +# 250: case TIFFTAG_XRESOLUTION: +# 256: case TIFFTAG_YRESOLUTION: +# 262: case TIFFTAG_PLANARCONFIG: +# 268: case TIFFTAG_XPOSITION: +# 271: case TIFFTAG_YPOSITION: +# 274: case TIFFTAG_RESOLUTIONUNIT: +# 280: case TIFFTAG_PAGENUMBER: +# 284: case TIFFTAG_HALFTONEHINTS: +# 288: case TIFFTAG_COLORMAP: +# 294: case TIFFTAG_EXTRASAMPLES: +# 298: case TIFFTAG_MATTEING: +# 305: case TIFFTAG_TILEWIDTH: +# 316: case TIFFTAG_TILELENGTH: +# 327: case TIFFTAG_TILEDEPTH: +# 333: case TIFFTAG_DATATYPE: +# 344: case TIFFTAG_SAMPLEFORMAT: +# 361: case TIFFTAG_IMAGEDEPTH: +# 364: case TIFFTAG_SUBIFD: +# 376: case TIFFTAG_YCBCRPOSITIONING: +# 379: case TIFFTAG_YCBCRSUBSAMPLING: +# 383: case TIFFTAG_TRANSFERFUNCTION: +# 389: case TIFFTAG_REFERENCEBLACKWHITE: +# 393: case TIFFTAG_INKNAMES: + +# Following pseudo-tags are also handled by default in libtiff: +# TIFFTAG_JPEGQUALITY 65537 + +# some of these are not in our TAGS_V2 dict and were included from tiff.h + +# This list also exists in encode.c +LIBTIFF_CORE = { + 255, + 256, + 257, + 258, + 259, + 262, + 263, + 266, + 274, + 277, + 278, + 280, + 281, + 340, + 341, + 282, + 283, + 284, + 286, + 287, + 296, + 297, + 321, + 320, + 338, + 32995, + 322, + 323, + 32998, + 32996, + 339, + 32997, + 330, + 531, + 530, + 301, + 532, + 333, + # as above + 269, # this has been in our tests forever, and works + 65537, +} + +LIBTIFF_CORE.remove(255) # We don't have support for subfiletypes +LIBTIFF_CORE.remove(322) # We don't have support for writing tiled images with libtiff +LIBTIFF_CORE.remove(323) # Tiled images + +# Note to advanced users: There may be combinations of these +# parameters and values that when added properly, will work and +# produce valid tiff images that may work in your application. +# It is safe to add and remove tags from this set from Pillow's point +# of view so long as you test against libtiff. diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/WalImageFile.py b/presentation/.venv/lib/python3.12/site-packages/PIL/WalImageFile.py new file mode 100644 index 0000000..07bbf74 --- /dev/null +++ b/presentation/.venv/lib/python3.12/site-packages/PIL/WalImageFile.py @@ -0,0 +1,129 @@ +# +# The Python Imaging Library. +# $Id$ +# +# WAL file handling +# +# History: +# 2003-04-23 fl created +# +# Copyright (c) 2003 by Fredrik Lundh. +# +# See the README file for information on usage and redistribution. +# + +""" +This reader is based on the specification available from: +https://www.flipcode.com/archives/Quake_2_BSP_File_Format.shtml +and has been tested with a few sample files found using google. + +.. note:: + This format cannot be automatically recognized, so the reader + is not registered for use with :py:func:`PIL.Image.open()`. + To open a WAL file, use the :py:func:`PIL.WalImageFile.open()` function instead. +""" + +from __future__ import annotations + +from typing import IO + +from . import Image, ImageFile +from ._binary import i32le as i32 +from ._typing import StrOrBytesPath + + +class WalImageFile(ImageFile.ImageFile): + format = "WAL" + format_description = "Quake2 Texture" + + def _open(self) -> None: + self._mode = "P" + + # read header fields + assert self.fp is not None + header = self.fp.read(32 + 24 + 32 + 12) + self._size = i32(header, 32), i32(header, 36) + Image._decompression_bomb_check(self.size) + + # load pixel data + offset = i32(header, 40) + self.fp.seek(offset) + + # strings are null-terminated + self.info["name"] = header[:32].split(b"\0", 1)[0] + if next_name := header[56 : 56 + 32].split(b"\0", 1)[0]: + self.info["next_name"] = next_name + + def load(self) -> Image.core.PixelAccess | None: + if self._im is None: + assert self.fp is not None + self.im = Image.core.new(self.mode, self.size) + self.frombytes(self.fp.read(self.size[0] * self.size[1])) + self.putpalette(quake2palette) + return Image.Image.load(self) + + +def open(filename: StrOrBytesPath | IO[bytes]) -> WalImageFile: + """ + Load texture from a Quake2 WAL texture file. + + By default, a Quake2 standard palette is attached to the texture. + To override the palette, use the :py:func:`PIL.Image.Image.putpalette()` method. + + :param filename: WAL file name, or an opened file handle. + :returns: An image instance. + """ + return WalImageFile(filename) + + +quake2palette = ( + # default palette taken from piffo 0.93 by Hans Häggström + b"\x01\x01\x01\x0b\x0b\x0b\x12\x12\x12\x17\x17\x17\x1b\x1b\x1b\x1e" + b"\x1e\x1e\x22\x22\x22\x26\x26\x26\x29\x29\x29\x2c\x2c\x2c\x2f\x2f" + b"\x2f\x32\x32\x32\x35\x35\x35\x37\x37\x37\x3a\x3a\x3a\x3c\x3c\x3c" + b"\x24\x1e\x13\x22\x1c\x12\x20\x1b\x12\x1f\x1a\x10\x1d\x19\x10\x1b" + b"\x17\x0f\x1a\x16\x0f\x18\x14\x0d\x17\x13\x0d\x16\x12\x0d\x14\x10" + b"\x0b\x13\x0f\x0b\x10\x0d\x0a\x0f\x0b\x0a\x0d\x0b\x07\x0b\x0a\x07" + b"\x23\x23\x26\x22\x22\x25\x22\x20\x23\x21\x1f\x22\x20\x1e\x20\x1f" + b"\x1d\x1e\x1d\x1b\x1c\x1b\x1a\x1a\x1a\x19\x19\x18\x17\x17\x17\x16" + b"\x16\x14\x14\x14\x13\x13\x13\x10\x10\x10\x0f\x0f\x0f\x0d\x0d\x0d" + b"\x2d\x28\x20\x29\x24\x1c\x27\x22\x1a\x25\x1f\x17\x38\x2e\x1e\x31" + b"\x29\x1a\x2c\x25\x17\x26\x20\x14\x3c\x30\x14\x37\x2c\x13\x33\x28" + b"\x12\x2d\x24\x10\x28\x1f\x0f\x22\x1a\x0b\x1b\x14\x0a\x13\x0f\x07" + b"\x31\x1a\x16\x30\x17\x13\x2e\x16\x10\x2c\x14\x0d\x2a\x12\x0b\x27" + b"\x0f\x0a\x25\x0f\x07\x21\x0d\x01\x1e\x0b\x01\x1c\x0b\x01\x1a\x0b" + b"\x01\x18\x0a\x01\x16\x0a\x01\x13\x0a\x01\x10\x07\x01\x0d\x07\x01" + b"\x29\x23\x1e\x27\x21\x1c\x26\x20\x1b\x25\x1f\x1a\x23\x1d\x19\x21" + b"\x1c\x18\x20\x1b\x17\x1e\x19\x16\x1c\x18\x14\x1b\x17\x13\x19\x14" + b"\x10\x17\x13\x0f\x14\x10\x0d\x12\x0f\x0b\x0f\x0b\x0a\x0b\x0a\x07" + b"\x26\x1a\x0f\x23\x19\x0f\x20\x17\x0f\x1c\x16\x0f\x19\x13\x0d\x14" + b"\x10\x0b\x10\x0d\x0a\x0b\x0a\x07\x33\x22\x1f\x35\x29\x26\x37\x2f" + b"\x2d\x39\x35\x34\x37\x39\x3a\x33\x37\x39\x30\x34\x36\x2b\x31\x34" + b"\x27\x2e\x31\x22\x2b\x2f\x1d\x28\x2c\x17\x25\x2a\x0f\x20\x26\x0d" + b"\x1e\x25\x0b\x1c\x22\x0a\x1b\x20\x07\x19\x1e\x07\x17\x1b\x07\x14" + b"\x18\x01\x12\x16\x01\x0f\x12\x01\x0b\x0d\x01\x07\x0a\x01\x01\x01" + b"\x2c\x21\x21\x2a\x1f\x1f\x29\x1d\x1d\x27\x1c\x1c\x26\x1a\x1a\x24" + b"\x18\x18\x22\x17\x17\x21\x16\x16\x1e\x13\x13\x1b\x12\x12\x18\x10" + b"\x10\x16\x0d\x0d\x12\x0b\x0b\x0d\x0a\x0a\x0a\x07\x07\x01\x01\x01" + b"\x2e\x30\x29\x2d\x2e\x27\x2b\x2c\x26\x2a\x2a\x24\x28\x29\x23\x27" + b"\x27\x21\x26\x26\x1f\x24\x24\x1d\x22\x22\x1c\x1f\x1f\x1a\x1c\x1c" + b"\x18\x19\x19\x16\x17\x17\x13\x13\x13\x10\x0f\x0f\x0d\x0b\x0b\x0a" + b"\x30\x1e\x1b\x2d\x1c\x19\x2c\x1a\x17\x2a\x19\x14\x28\x17\x13\x26" + b"\x16\x10\x24\x13\x0f\x21\x12\x0d\x1f\x10\x0b\x1c\x0f\x0a\x19\x0d" + b"\x0a\x16\x0b\x07\x12\x0a\x07\x0f\x07\x01\x0a\x01\x01\x01\x01\x01" + b"\x28\x29\x38\x26\x27\x36\x25\x26\x34\x24\x24\x31\x22\x22\x2f\x20" + b"\x21\x2d\x1e\x1f\x2a\x1d\x1d\x27\x1b\x1b\x25\x19\x19\x21\x17\x17" + b"\x1e\x14\x14\x1b\x13\x12\x17\x10\x0f\x13\x0d\x0b\x0f\x0a\x07\x07" + b"\x2f\x32\x29\x2d\x30\x26\x2b\x2e\x24\x29\x2c\x21\x27\x2a\x1e\x25" + b"\x28\x1c\x23\x26\x1a\x21\x25\x18\x1e\x22\x14\x1b\x1f\x10\x19\x1c" + b"\x0d\x17\x1a\x0a\x13\x17\x07\x10\x13\x01\x0d\x0f\x01\x0a\x0b\x01" + b"\x01\x3f\x01\x13\x3c\x0b\x1b\x39\x10\x20\x35\x14\x23\x31\x17\x23" + b"\x2d\x18\x23\x29\x18\x3f\x3f\x3f\x3f\x3f\x39\x3f\x3f\x31\x3f\x3f" + b"\x2a\x3f\x3f\x20\x3f\x3f\x14\x3f\x3c\x12\x3f\x39\x0f\x3f\x35\x0b" + b"\x3f\x32\x07\x3f\x2d\x01\x3d\x2a\x01\x3b\x26\x01\x39\x21\x01\x37" + b"\x1d\x01\x34\x1a\x01\x32\x16\x01\x2f\x12\x01\x2d\x0f\x01\x2a\x0b" + b"\x01\x27\x07\x01\x23\x01\x01\x1d\x01\x01\x17\x01\x01\x10\x01\x01" + b"\x3d\x01\x01\x19\x19\x3f\x3f\x01\x01\x01\x01\x3f\x16\x16\x13\x10" + b"\x10\x0f\x0d\x0d\x0b\x3c\x2e\x2a\x36\x27\x20\x30\x21\x18\x29\x1b" + b"\x10\x3c\x39\x37\x37\x32\x2f\x31\x2c\x28\x2b\x26\x21\x30\x22\x20" +) diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/WebPImagePlugin.py b/presentation/.venv/lib/python3.12/site-packages/PIL/WebPImagePlugin.py new file mode 100644 index 0000000..e20e40d --- /dev/null +++ b/presentation/.venv/lib/python3.12/site-packages/PIL/WebPImagePlugin.py @@ -0,0 +1,317 @@ +from __future__ import annotations + +from io import BytesIO + +from . import Image, ImageFile + +try: + from . import _webp + + SUPPORTED = True +except ImportError: + SUPPORTED = False + +TYPE_CHECKING = False +if TYPE_CHECKING: + from typing import IO, Any + +_VP8_MODES_BY_IDENTIFIER = { + b"VP8 ": "RGB", + b"VP8X": "RGBA", + b"VP8L": "RGBA", # lossless +} + + +def _accept(prefix: bytes) -> bool | str: + is_riff_file_format = prefix.startswith(b"RIFF") + is_webp_file = prefix[8:12] == b"WEBP" + is_valid_vp8_mode = prefix[12:16] in _VP8_MODES_BY_IDENTIFIER + + if is_riff_file_format and is_webp_file and is_valid_vp8_mode: + if not SUPPORTED: + return ( + "image file could not be identified because WEBP support not installed" + ) + return True + return False + + +class WebPImageFile(ImageFile.ImageFile): + format = "WEBP" + format_description = "WebP image" + __loaded = 0 + __logical_frame = 0 + + def _open(self) -> None: + # Use the newer AnimDecoder API to parse the (possibly) animated file, + # and access muxed chunks like ICC/EXIF/XMP. + assert self.fp is not None + self._decoder = _webp.WebPAnimDecoder(self.fp.read()) + + # Get info from decoder + self._size, self.info["loop"], bgcolor, self.n_frames, self.rawmode = ( + self._decoder.get_info() + ) + self.info["background"] = ( + (bgcolor >> 16) & 0xFF, # R + (bgcolor >> 8) & 0xFF, # G + bgcolor & 0xFF, # B + (bgcolor >> 24) & 0xFF, # A + ) + self.is_animated = self.n_frames > 1 + self._mode = "RGB" if self.rawmode == "RGBX" else self.rawmode + + # Attempt to read ICC / EXIF / XMP chunks from file + for key, chunk_name in { + "icc_profile": "ICCP", + "exif": "EXIF", + "xmp": "XMP ", + }.items(): + if value := self._decoder.get_chunk(chunk_name): + self.info[key] = value + + # Initialize seek state + self._reset(reset=False) + + def _getexif(self) -> dict[int, Any] | None: + if "exif" not in self.info: + return None + return self.getexif()._get_merged_dict() + + def seek(self, frame: int) -> None: + if not self._seek_check(frame): + return + + # Set logical frame to requested position + self.__logical_frame = frame + + def _reset(self, reset: bool = True) -> None: + if reset: + self._decoder.reset() + self.__physical_frame = 0 + self.__loaded = -1 + self.__timestamp = 0 + + def _get_next(self) -> tuple[bytes, int, int]: + # Get next frame + ret = self._decoder.get_next() + self.__physical_frame += 1 + + # Check if an error occurred + if ret is None: + self._reset() # Reset just to be safe + self.seek(0) + msg = "failed to decode next frame in WebP file" + raise EOFError(msg) + + # Compute duration + data, timestamp = ret + duration = timestamp - self.__timestamp + self.__timestamp = timestamp + + # libwebp gives frame end, adjust to start of frame + timestamp -= duration + return data, timestamp, duration + + def _seek(self, frame: int) -> None: + if self.__physical_frame == frame: + return # Nothing to do + if frame < self.__physical_frame: + self._reset() # Rewind to beginning + while self.__physical_frame < frame: + self._get_next() # Advance to the requested frame + + def load(self) -> Image.core.PixelAccess | None: + if self.__loaded != self.__logical_frame: + self._seek(self.__logical_frame) + + # We need to load the image data for this frame + data, self.info["timestamp"], self.info["duration"] = self._get_next() + self.__loaded = self.__logical_frame + + # Set tile + if self.fp and self._exclusive_fp: + self.fp.close() + self.fp = BytesIO(data) + self.tile = [ImageFile._Tile("raw", (0, 0) + self.size, 0, self.rawmode)] + + return super().load() + + def load_seek(self, pos: int) -> None: + pass + + def tell(self) -> int: + return self.__logical_frame + + +def _convert_frame(im: Image.Image) -> Image.Image: + # Make sure image mode is supported + if im.mode not in ("RGBX", "RGBA", "RGB"): + im = im.convert("RGBA" if im.has_transparency_data else "RGB") + return im + + +def _save_all(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: + encoderinfo = im.encoderinfo.copy() + append_images = list(encoderinfo.get("append_images", [])) + + # If total frame count is 1, then save using the legacy API, which + # will preserve non-alpha modes + total = 0 + for ims in [im] + append_images: + total += getattr(ims, "n_frames", 1) + if total == 1: + _save(im, fp, filename) + return + + background: int | tuple[int, ...] = (0, 0, 0, 0) + if "background" in encoderinfo: + background = encoderinfo["background"] + elif "background" in im.info: + background = im.info["background"] + if isinstance(background, int): + # GifImagePlugin stores a global color table index in + # info["background"]. So it must be converted to an RGBA value + palette = im.getpalette() + if palette: + r, g, b = palette[background * 3 : (background + 1) * 3] + background = (r, g, b, 255) + else: + background = (background, background, background, 255) + + duration = im.encoderinfo.get("duration", im.info.get("duration", 0)) + loop = im.encoderinfo.get("loop", 0) + minimize_size = im.encoderinfo.get("minimize_size", False) + kmin = im.encoderinfo.get("kmin", None) + kmax = im.encoderinfo.get("kmax", None) + allow_mixed = im.encoderinfo.get("allow_mixed", False) + verbose = False + lossless = im.encoderinfo.get("lossless", False) + quality = im.encoderinfo.get("quality", 80) + alpha_quality = im.encoderinfo.get("alpha_quality", 100) + method = im.encoderinfo.get("method", 0) + icc_profile = im.encoderinfo.get("icc_profile") or "" + exif = im.encoderinfo.get("exif", "") + if isinstance(exif, Image.Exif): + exif = exif.tobytes() + xmp = im.encoderinfo.get("xmp", "") + if allow_mixed: + lossless = False + + # Sensible keyframe defaults are from gif2webp.c script + if kmin is None: + kmin = 9 if lossless else 3 + if kmax is None: + kmax = 17 if lossless else 5 + + # Validate background color + if ( + not isinstance(background, (list, tuple)) + or len(background) != 4 + or not all(0 <= v < 256 for v in background) + ): + msg = f"Background color is not an RGBA tuple clamped to (0-255): {background}" + raise OSError(msg) + + # Convert to packed uint + bg_r, bg_g, bg_b, bg_a = background + background = (bg_a << 24) | (bg_r << 16) | (bg_g << 8) | (bg_b << 0) + + # Setup the WebP animation encoder + enc = _webp.WebPAnimEncoder( + im.size, + background, + loop, + minimize_size, + kmin, + kmax, + allow_mixed, + verbose, + ) + + # Add each frame + frame_idx = 0 + timestamp = 0 + cur_idx = im.tell() + try: + for ims in [im] + append_images: + # Get number of frames in this image + nfr = getattr(ims, "n_frames", 1) + + for idx in range(nfr): + ims.seek(idx) + + frame = _convert_frame(ims) + + # Append the frame to the animation encoder + enc.add( + frame.getim(), + round(timestamp), + lossless, + quality, + alpha_quality, + method, + ) + + # Update timestamp and frame index + if isinstance(duration, (list, tuple)): + timestamp += duration[frame_idx] + else: + timestamp += duration + frame_idx += 1 + + finally: + im.seek(cur_idx) + + # Force encoder to flush frames + enc.add(None, round(timestamp), lossless, quality, alpha_quality, 0) + + # Get the final output from the encoder + data = enc.assemble(icc_profile, exif, xmp) + if data is None: + msg = "cannot write file as WebP (encoder returned None)" + raise OSError(msg) + + fp.write(data) + + +def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: + lossless = im.encoderinfo.get("lossless", False) + quality = im.encoderinfo.get("quality", 80) + alpha_quality = im.encoderinfo.get("alpha_quality", 100) + icc_profile = im.encoderinfo.get("icc_profile") or "" + exif = im.encoderinfo.get("exif", b"") + if isinstance(exif, Image.Exif): + exif = exif.tobytes() + if exif.startswith(b"Exif\x00\x00"): + exif = exif[6:] + xmp = im.encoderinfo.get("xmp", "") + method = im.encoderinfo.get("method", 4) + exact = 1 if im.encoderinfo.get("exact") else 0 + + im = _convert_frame(im) + + data = _webp.WebPEncode( + im.getim(), + lossless, + float(quality), + float(alpha_quality), + icc_profile, + method, + exact, + exif, + xmp, + ) + if data is None: + msg = "cannot write file as WebP (encoder returned None)" + raise OSError(msg) + + fp.write(data) + + +Image.register_open(WebPImageFile.format, WebPImageFile, _accept) +if SUPPORTED: + Image.register_save(WebPImageFile.format, _save) + Image.register_save_all(WebPImageFile.format, _save_all) + Image.register_extension(WebPImageFile.format, ".webp") + Image.register_mime(WebPImageFile.format, "image/webp") diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/WmfImagePlugin.py b/presentation/.venv/lib/python3.12/site-packages/PIL/WmfImagePlugin.py new file mode 100644 index 0000000..f5e2447 --- /dev/null +++ b/presentation/.venv/lib/python3.12/site-packages/PIL/WmfImagePlugin.py @@ -0,0 +1,183 @@ +# +# The Python Imaging Library +# $Id$ +# +# WMF stub codec +# +# history: +# 1996-12-14 fl Created +# 2004-02-22 fl Turned into a stub driver +# 2004-02-23 fl Added EMF support +# +# Copyright (c) Secret Labs AB 1997-2004. All rights reserved. +# Copyright (c) Fredrik Lundh 1996. +# +# See the README file for information on usage and redistribution. +# +# WMF/EMF reference documentation: +# https://winprotocoldoc.blob.core.windows.net/productionwindowsarchives/MS-WMF/[MS-WMF].pdf +# http://wvware.sourceforge.net/caolan/index.html +# http://wvware.sourceforge.net/caolan/ora-wmf.html +from __future__ import annotations + +from typing import IO + +from . import Image, ImageFile +from ._binary import i16le as word +from ._binary import si16le as short +from ._binary import si32le as _long + +_handler = None + + +def register_handler(handler: ImageFile.StubHandler | None) -> None: + """ + Install application-specific WMF image handler. + + :param handler: Handler object. + """ + global _handler + _handler = handler + + +if hasattr(Image.core, "drawwmf"): + # install default handler (windows only) + + class WmfHandler(ImageFile.StubHandler): + def open(self, im: ImageFile.StubImageFile) -> None: + self.bbox = im.info["wmf_bbox"] + + def load(self, im: ImageFile.StubImageFile) -> Image.Image: + assert im.fp is not None + im.fp.seek(0) # rewind + return Image.frombytes( + "RGB", + im.size, + Image.core.drawwmf(im.fp.read(), im.size, self.bbox), + "raw", + "BGR", + (im.size[0] * 3 + 3) & -4, + -1, + ) + + register_handler(WmfHandler()) + +# +# -------------------------------------------------------------------- +# Read WMF file + + +def _accept(prefix: bytes) -> bool: + return prefix.startswith((b"\xd7\xcd\xc6\x9a\x00\x00", b"\x01\x00\x00\x00")) + + +## +# Image plugin for Windows metafiles. + + +class WmfStubImageFile(ImageFile.StubImageFile): + format = "WMF" + format_description = "Windows Metafile" + + def _open(self) -> None: + # check placeable header + assert self.fp is not None + s = self.fp.read(44) + + if s.startswith(b"\xd7\xcd\xc6\x9a\x00\x00"): + # placeable windows metafile + + # get units per inch + inch = word(s, 14) + if inch == 0: + msg = "Invalid inch" + raise ValueError(msg) + self._inch: tuple[float, float] = inch, inch + + # get bounding box + x0 = short(s, 6) + y0 = short(s, 8) + x1 = short(s, 10) + y1 = short(s, 12) + + # normalize size to 72 dots per inch + self.info["dpi"] = 72 + size = ( + (x1 - x0) * self.info["dpi"] // inch, + (y1 - y0) * self.info["dpi"] // inch, + ) + + self.info["wmf_bbox"] = x0, y0, x1, y1 + + # sanity check (standard metafile header) + if s[22:26] != b"\x01\x00\t\x00": + msg = "Unsupported WMF file format" + raise SyntaxError(msg) + + elif s.startswith(b"\x01\x00\x00\x00") and s[40:44] == b" EMF": + # enhanced metafile + + # get bounding box + x0 = _long(s, 8) + y0 = _long(s, 12) + x1 = _long(s, 16) + y1 = _long(s, 20) + + # get frame (in 0.01 millimeter units) + frame = _long(s, 24), _long(s, 28), _long(s, 32), _long(s, 36) + + size = x1 - x0, y1 - y0 + + # calculate dots per inch from bbox and frame + xdpi = 2540.0 * (x1 - x0) / (frame[2] - frame[0]) + ydpi = 2540.0 * (y1 - y0) / (frame[3] - frame[1]) + + self.info["wmf_bbox"] = x0, y0, x1, y1 + + if xdpi == ydpi: + self.info["dpi"] = xdpi + else: + self.info["dpi"] = xdpi, ydpi + self._inch = xdpi, ydpi + + else: + msg = "Unsupported file format" + raise SyntaxError(msg) + + self._mode = "RGB" + self._size = size + + def _load(self) -> ImageFile.StubHandler | None: + return _handler + + def load( + self, dpi: float | tuple[float, float] | None = None + ) -> Image.core.PixelAccess | None: + if dpi is not None: + self.info["dpi"] = dpi + x0, y0, x1, y1 = self.info["wmf_bbox"] + if not isinstance(dpi, tuple): + dpi = dpi, dpi + self._size = ( + int((x1 - x0) * dpi[0] / self._inch[0]), + int((y1 - y0) * dpi[1] / self._inch[1]), + ) + return super().load() + + +def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: + if _handler is None or not hasattr(_handler, "save"): + msg = "WMF save handler not installed" + raise OSError(msg) + _handler.save(im, fp, filename) + + +# +# -------------------------------------------------------------------- +# Registry stuff + + +Image.register_open(WmfStubImageFile.format, WmfStubImageFile, _accept) +Image.register_save(WmfStubImageFile.format, _save) + +Image.register_extensions(WmfStubImageFile.format, [".wmf", ".emf"]) diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/XVThumbImagePlugin.py b/presentation/.venv/lib/python3.12/site-packages/PIL/XVThumbImagePlugin.py new file mode 100644 index 0000000..192c041 --- /dev/null +++ b/presentation/.venv/lib/python3.12/site-packages/PIL/XVThumbImagePlugin.py @@ -0,0 +1,83 @@ +# +# The Python Imaging Library. +# $Id$ +# +# XV Thumbnail file handler by Charles E. "Gene" Cash +# (gcash@magicnet.net) +# +# see xvcolor.c and xvbrowse.c in the sources to John Bradley's XV, +# available from ftp://ftp.cis.upenn.edu/pub/xv/ +# +# history: +# 98-08-15 cec created (b/w only) +# 98-12-09 cec added color palette +# 98-12-28 fl added to PIL (with only a few very minor modifications) +# +# To do: +# FIXME: make save work (this requires quantization support) +# +from __future__ import annotations + +from . import Image, ImageFile, ImagePalette +from ._binary import o8 + +_MAGIC = b"P7 332" + +# standard color palette for thumbnails (RGB332) +PALETTE = b"" +for r in range(8): + for g in range(8): + for b in range(4): + PALETTE = PALETTE + ( + o8((r * 255) // 7) + o8((g * 255) // 7) + o8((b * 255) // 3) + ) + + +def _accept(prefix: bytes) -> bool: + return prefix.startswith(_MAGIC) + + +## +# Image plugin for XV thumbnail images. + + +class XVThumbImageFile(ImageFile.ImageFile): + format = "XVThumb" + format_description = "XV thumbnail image" + + def _open(self) -> None: + # check magic + assert self.fp is not None + + if not _accept(self.fp.read(6)): + msg = "not an XV thumbnail file" + raise SyntaxError(msg) + + # Skip to beginning of next line + self.fp.readline() + + # skip info comments + while True: + s = self.fp.readline() + if not s: + msg = "Unexpected EOF reading XV thumbnail file" + raise SyntaxError(msg) + if s[0] != 35: # ie. when not a comment: '#' + break + + # parse header line (already read) + w, h = s.strip().split(maxsplit=2)[:2] + + self._mode = "P" + self._size = int(w), int(h) + + self.palette = ImagePalette.raw("RGB", PALETTE) + + self.tile = [ + ImageFile._Tile("raw", (0, 0) + self.size, self.fp.tell(), self.mode) + ] + + +# -------------------------------------------------------------------- + +Image.register_open(XVThumbImageFile.format, XVThumbImageFile, _accept) diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/XbmImagePlugin.py b/presentation/.venv/lib/python3.12/site-packages/PIL/XbmImagePlugin.py new file mode 100644 index 0000000..1e57aa1 --- /dev/null +++ b/presentation/.venv/lib/python3.12/site-packages/PIL/XbmImagePlugin.py @@ -0,0 +1,98 @@ +# +# The Python Imaging Library. +# $Id$ +# +# XBM File handling +# +# History: +# 1995-09-08 fl Created +# 1996-11-01 fl Added save support +# 1997-07-07 fl Made header parser more tolerant +# 1997-07-22 fl Fixed yet another parser bug +# 2001-02-17 fl Use 're' instead of 'regex' (Python 2.1) (0.4) +# 2001-05-13 fl Added hotspot handling (based on code from Bernhard Herzog) +# 2004-02-24 fl Allow some whitespace before first #define +# +# Copyright (c) 1997-2004 by Secret Labs AB +# Copyright (c) 1996-1997 by Fredrik Lundh +# +# See the README file for information on usage and redistribution. +# +from __future__ import annotations + +import re +from typing import IO + +from . import Image, ImageFile + +# XBM header +xbm_head = re.compile( + rb"\s*#define[ \t]+.*_width[ \t]+(?P<width>[0-9]+)[\r\n]+" + b"#define[ \t]+.*_height[ \t]+(?P<height>[0-9]+)[\r\n]+" + b"(?P<hotspot>" + b"#define[ \t]+[^_]*_x_hot[ \t]+(?P<xhot>[0-9]+)[\r\n]+" + b"#define[ \t]+[^_]*_y_hot[ \t]+(?P<yhot>[0-9]+)[\r\n]+" + b")?" + rb"[\000-\377]*_bits\[]" +) + + +def _accept(prefix: bytes) -> bool: + return prefix.lstrip().startswith(b"#define") + + +## +# Image plugin for X11 bitmaps. + + +class XbmImageFile(ImageFile.ImageFile): + format = "XBM" + format_description = "X11 Bitmap" + + def _open(self) -> None: + assert self.fp is not None + + m = xbm_head.match(self.fp.read(512)) + + if not m: + msg = "not a XBM file" + raise SyntaxError(msg) + + xsize = int(m.group("width")) + ysize = int(m.group("height")) + + if m.group("hotspot"): + self.info["hotspot"] = (int(m.group("xhot")), int(m.group("yhot"))) + + self._mode = "1" + self._size = xsize, ysize + + self.tile = [ImageFile._Tile("xbm", (0, 0) + self.size, m.end())] + + +def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: + if im.mode != "1": + msg = f"cannot write mode {im.mode} as XBM" + raise OSError(msg) + + fp.write(f"#define im_width {im.size[0]}\n".encode("ascii")) + fp.write(f"#define im_height {im.size[1]}\n".encode("ascii")) + + hotspot = im.encoderinfo.get("hotspot") + if hotspot: + fp.write(f"#define im_x_hot {hotspot[0]}\n".encode("ascii")) + fp.write(f"#define im_y_hot {hotspot[1]}\n".encode("ascii")) + + fp.write(b"static char im_bits[] = {\n") + + ImageFile._save(im, fp, [ImageFile._Tile("xbm", (0, 0) + im.size)]) + + fp.write(b"};\n") + + +Image.register_open(XbmImageFile.format, XbmImageFile, _accept) +Image.register_save(XbmImageFile.format, _save) + +Image.register_extension(XbmImageFile.format, ".xbm") + +Image.register_mime(XbmImageFile.format, "image/xbm") diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/XpmImagePlugin.py b/presentation/.venv/lib/python3.12/site-packages/PIL/XpmImagePlugin.py new file mode 100644 index 0000000..3be240f --- /dev/null +++ b/presentation/.venv/lib/python3.12/site-packages/PIL/XpmImagePlugin.py @@ -0,0 +1,157 @@ +# +# The Python Imaging Library. +# $Id$ +# +# XPM File handling +# +# History: +# 1996-12-29 fl Created +# 2001-02-17 fl Use 're' instead of 'regex' (Python 2.1) (0.7) +# +# Copyright (c) Secret Labs AB 1997-2001. +# Copyright (c) Fredrik Lundh 1996-2001. +# +# See the README file for information on usage and redistribution. +# +from __future__ import annotations + +import re + +from . import Image, ImageFile, ImagePalette +from ._binary import o8 + +# XPM header +xpm_head = re.compile(b'"([0-9]*) ([0-9]*) ([0-9]*) ([0-9]*)') + + +def _accept(prefix: bytes) -> bool: + return prefix.startswith(b"/* XPM */") + + +## +# Image plugin for X11 pixel maps. + + +class XpmImageFile(ImageFile.ImageFile): + format = "XPM" + format_description = "X11 Pixel Map" + + def _open(self) -> None: + assert self.fp is not None + if not _accept(self.fp.read(9)): + msg = "not an XPM file" + raise SyntaxError(msg) + + # skip forward to next string + while True: + line = self.fp.readline() + if not line: + msg = "broken XPM file" + raise SyntaxError(msg) + m = xpm_head.match(line) + if m: + break + + self._size = int(m.group(1)), int(m.group(2)) + + palette_length = int(m.group(3)) + bpp = int(m.group(4)) + + # + # load palette description + + palette = {} + + for _ in range(palette_length): + line = self.fp.readline().rstrip() + + c = line[1 : bpp + 1] + s = line[bpp + 1 : -2].split() + + for i in range(0, len(s), 2): + if s[i] == b"c": + # process colour key + rgb = s[i + 1] + if rgb == b"None": + self.info["transparency"] = c + elif rgb.startswith(b"#"): + rgb_int = int(rgb[1:], 16) + palette[c] = ( + o8((rgb_int >> 16) & 255) + + o8((rgb_int >> 8) & 255) + + o8(rgb_int & 255) + ) + else: + # unknown colour + msg = "cannot read this XPM file" + raise ValueError(msg) + break + + else: + # missing colour key + msg = "cannot read this XPM file" + raise ValueError(msg) + + args: tuple[int, dict[bytes, bytes] | tuple[bytes, ...]] + if palette_length > 256: + self._mode = "RGB" + args = (bpp, palette) + else: + self._mode = "P" + self.palette = ImagePalette.raw("RGB", b"".join(palette.values())) + args = (bpp, tuple(palette.keys())) + + self.tile = [ImageFile._Tile("xpm", (0, 0) + self.size, self.fp.tell(), args)] + + def load_read(self, read_bytes: int) -> bytes: + # + # load all image data in one chunk + + xsize, ysize = self.size + + assert self.fp is not None + s = [self.fp.readline()[1 : xsize + 1].ljust(xsize) for i in range(ysize)] + + return b"".join(s) + + +class XpmDecoder(ImageFile.PyDecoder): + _pulls_fd = True + + def decode(self, buffer: bytes | Image.SupportsArrayInterface) -> tuple[int, int]: + assert self.fd is not None + + data = bytearray() + bpp, palette = self.args + dest_length = self.state.xsize * self.state.ysize + if self.mode == "RGB": + dest_length *= 3 + pixel_header = False + while len(data) < dest_length: + line = self.fd.readline() + if not line: + break + if line.rstrip() == b"/* pixels */" and not pixel_header: + pixel_header = True + continue + line = b'"'.join(line.split(b'"')[1:-1]) + for i in range(0, len(line), bpp): + key = line[i : i + bpp] + if self.mode == "RGB": + data += palette[key] + else: + data += o8(palette.index(key)) + self.set_as_raw(bytes(data)) + return -1, 0 + + +# +# Registry + + +Image.register_open(XpmImageFile.format, XpmImageFile, _accept) +Image.register_decoder("xpm", XpmDecoder) + +Image.register_extension(XpmImageFile.format, ".xpm") + +Image.register_mime(XpmImageFile.format, "image/xpm") diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/__init__.py b/presentation/.venv/lib/python3.12/site-packages/PIL/__init__.py new file mode 100644 index 0000000..faf3e76 --- /dev/null +++ b/presentation/.venv/lib/python3.12/site-packages/PIL/__init__.py @@ -0,0 +1,87 @@ +"""Pillow (Fork of the Python Imaging Library) + +Pillow is the friendly PIL fork by Jeffrey 'Alex' Clark and contributors. + https://github.com/python-pillow/Pillow/ + +Pillow is forked from PIL 1.1.7. + +PIL is the Python Imaging Library by Fredrik Lundh and contributors. +Copyright (c) 1999 by Secret Labs AB. + +Use PIL.__version__ for this Pillow version. + +;-) +""" + +from __future__ import annotations + +from . import _version + +# VERSION was removed in Pillow 6.0.0. +# PILLOW_VERSION was removed in Pillow 9.0.0. +# Use __version__ instead. +__version__ = _version.__version__ +del _version + + +_plugins = [ + "AvifImagePlugin", + "BlpImagePlugin", + "BmpImagePlugin", + "BufrStubImagePlugin", + "CurImagePlugin", + "DcxImagePlugin", + "DdsImagePlugin", + "EpsImagePlugin", + "FitsImagePlugin", + "FliImagePlugin", + "FpxImagePlugin", + "FtexImagePlugin", + "GbrImagePlugin", + "GifImagePlugin", + "GribStubImagePlugin", + "Hdf5StubImagePlugin", + "IcnsImagePlugin", + "IcoImagePlugin", + "ImImagePlugin", + "ImtImagePlugin", + "IptcImagePlugin", + "JpegImagePlugin", + "Jpeg2KImagePlugin", + "McIdasImagePlugin", + "MicImagePlugin", + "MpegImagePlugin", + "MpoImagePlugin", + "MspImagePlugin", + "PalmImagePlugin", + "PcdImagePlugin", + "PcxImagePlugin", + "PdfImagePlugin", + "PixarImagePlugin", + "PngImagePlugin", + "PpmImagePlugin", + "PsdImagePlugin", + "QoiImagePlugin", + "SgiImagePlugin", + "SpiderImagePlugin", + "SunImagePlugin", + "TgaImagePlugin", + "TiffImagePlugin", + "WebPImagePlugin", + "WmfImagePlugin", + "XbmImagePlugin", + "XpmImagePlugin", + "XVThumbImagePlugin", +] + + +class UnidentifiedImageError(OSError): + """ + Raised in :py:meth:`PIL.Image.open` if an image cannot be opened and identified. + + If a PNG image raises this error, setting :data:`.ImageFile.LOAD_TRUNCATED_IMAGES` + to true may allow the image to be opened after all. The setting will ignore missing + data and checksum failures. + """ + + pass diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/__main__.py b/presentation/.venv/lib/python3.12/site-packages/PIL/__main__.py new file mode 100644 index 0000000..043156e --- /dev/null +++ b/presentation/.venv/lib/python3.12/site-packages/PIL/__main__.py @@ -0,0 +1,7 @@ +from __future__ import annotations + +import sys + +from .features import pilinfo + +pilinfo(supported_formats="--report" not in sys.argv) diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/AvifImagePlugin.cpython-312.pyc b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/AvifImagePlugin.cpython-312.pyc new file mode 100644 index 0000000..ef99f14 Binary files /dev/null and b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/AvifImagePlugin.cpython-312.pyc differ diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/BdfFontFile.cpython-312.pyc b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/BdfFontFile.cpython-312.pyc new file mode 100644 index 0000000..8121dbf Binary files /dev/null and b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/BdfFontFile.cpython-312.pyc differ diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/BlpImagePlugin.cpython-312.pyc b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/BlpImagePlugin.cpython-312.pyc new file mode 100644 index 0000000..e4947a9 Binary files /dev/null and b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/BlpImagePlugin.cpython-312.pyc differ diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/BmpImagePlugin.cpython-312.pyc b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/BmpImagePlugin.cpython-312.pyc new file mode 100644 index 0000000..0a5bc5b Binary files /dev/null and b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/BmpImagePlugin.cpython-312.pyc differ diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/BufrStubImagePlugin.cpython-312.pyc b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/BufrStubImagePlugin.cpython-312.pyc new file mode 100644 index 0000000..a576438 Binary files /dev/null and b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/BufrStubImagePlugin.cpython-312.pyc differ diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/ContainerIO.cpython-312.pyc b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/ContainerIO.cpython-312.pyc new file mode 100644 index 0000000..6fe560a Binary files /dev/null and b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/ContainerIO.cpython-312.pyc differ diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/CurImagePlugin.cpython-312.pyc b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/CurImagePlugin.cpython-312.pyc new file mode 100644 index 0000000..5227279 Binary files /dev/null and b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/CurImagePlugin.cpython-312.pyc differ diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/DcxImagePlugin.cpython-312.pyc b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/DcxImagePlugin.cpython-312.pyc new file mode 100644 index 0000000..b8d238b Binary files /dev/null and b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/DcxImagePlugin.cpython-312.pyc differ diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/DdsImagePlugin.cpython-312.pyc b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/DdsImagePlugin.cpython-312.pyc new file mode 100644 index 0000000..d4bec9a Binary files /dev/null and b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/DdsImagePlugin.cpython-312.pyc differ diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/EpsImagePlugin.cpython-312.pyc b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/EpsImagePlugin.cpython-312.pyc new file mode 100644 index 0000000..3cb8d2b Binary files /dev/null and b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/EpsImagePlugin.cpython-312.pyc differ diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/ExifTags.cpython-312.pyc b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/ExifTags.cpython-312.pyc new file mode 100644 index 0000000..ad4eabe Binary files /dev/null and b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/ExifTags.cpython-312.pyc differ diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/FitsImagePlugin.cpython-312.pyc b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/FitsImagePlugin.cpython-312.pyc new file mode 100644 index 0000000..2724cd4 Binary files /dev/null and b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/FitsImagePlugin.cpython-312.pyc differ diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/FliImagePlugin.cpython-312.pyc b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/FliImagePlugin.cpython-312.pyc new file mode 100644 index 0000000..9d28a15 Binary files /dev/null and b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/FliImagePlugin.cpython-312.pyc differ diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/FontFile.cpython-312.pyc b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/FontFile.cpython-312.pyc new file mode 100644 index 0000000..f0dc0e7 Binary files /dev/null and b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/FontFile.cpython-312.pyc differ diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/FpxImagePlugin.cpython-312.pyc b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/FpxImagePlugin.cpython-312.pyc new file mode 100644 index 0000000..95088ad Binary files /dev/null and b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/FpxImagePlugin.cpython-312.pyc differ diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/FtexImagePlugin.cpython-312.pyc b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/FtexImagePlugin.cpython-312.pyc new file mode 100644 index 0000000..e5f1858 Binary files /dev/null and b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/FtexImagePlugin.cpython-312.pyc differ diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/GbrImagePlugin.cpython-312.pyc b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/GbrImagePlugin.cpython-312.pyc new file mode 100644 index 0000000..a6368cf Binary files /dev/null and b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/GbrImagePlugin.cpython-312.pyc differ diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/GdImageFile.cpython-312.pyc b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/GdImageFile.cpython-312.pyc new file mode 100644 index 0000000..7517ab3 Binary files /dev/null and b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/GdImageFile.cpython-312.pyc differ diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/GifImagePlugin.cpython-312.pyc b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/GifImagePlugin.cpython-312.pyc new file mode 100644 index 0000000..8df75e7 Binary files /dev/null and b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/GifImagePlugin.cpython-312.pyc differ diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/GimpGradientFile.cpython-312.pyc b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/GimpGradientFile.cpython-312.pyc new file mode 100644 index 0000000..afbe29f Binary files /dev/null and b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/GimpGradientFile.cpython-312.pyc differ diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/GimpPaletteFile.cpython-312.pyc b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/GimpPaletteFile.cpython-312.pyc new file mode 100644 index 0000000..1225c0b Binary files /dev/null and b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/GimpPaletteFile.cpython-312.pyc differ diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/GribStubImagePlugin.cpython-312.pyc b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/GribStubImagePlugin.cpython-312.pyc new file mode 100644 index 0000000..293fe89 Binary files /dev/null and b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/GribStubImagePlugin.cpython-312.pyc differ diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/Hdf5StubImagePlugin.cpython-312.pyc b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/Hdf5StubImagePlugin.cpython-312.pyc new file mode 100644 index 0000000..558f2e0 Binary files /dev/null and b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/Hdf5StubImagePlugin.cpython-312.pyc differ diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/IcnsImagePlugin.cpython-312.pyc b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/IcnsImagePlugin.cpython-312.pyc new file mode 100644 index 0000000..da2e813 Binary files /dev/null and b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/IcnsImagePlugin.cpython-312.pyc differ diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/IcoImagePlugin.cpython-312.pyc b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/IcoImagePlugin.cpython-312.pyc new file mode 100644 index 0000000..6b71db7 Binary files /dev/null and b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/IcoImagePlugin.cpython-312.pyc differ diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/ImImagePlugin.cpython-312.pyc b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/ImImagePlugin.cpython-312.pyc new file mode 100644 index 0000000..700f9e1 Binary files /dev/null and b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/ImImagePlugin.cpython-312.pyc differ diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/Image.cpython-312.pyc b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/Image.cpython-312.pyc new file mode 100644 index 0000000..420e927 Binary files /dev/null and b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/Image.cpython-312.pyc differ diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/ImageChops.cpython-312.pyc b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/ImageChops.cpython-312.pyc new file mode 100644 index 0000000..ff644bb Binary files /dev/null and b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/ImageChops.cpython-312.pyc differ diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/ImageCms.cpython-312.pyc b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/ImageCms.cpython-312.pyc new file mode 100644 index 0000000..9096567 Binary files /dev/null and b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/ImageCms.cpython-312.pyc differ diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/ImageColor.cpython-312.pyc b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/ImageColor.cpython-312.pyc new file mode 100644 index 0000000..408d64e Binary files /dev/null and b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/ImageColor.cpython-312.pyc differ diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/ImageDraw.cpython-312.pyc b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/ImageDraw.cpython-312.pyc new file mode 100644 index 0000000..48461a1 Binary files /dev/null and b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/ImageDraw.cpython-312.pyc differ diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/ImageDraw2.cpython-312.pyc b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/ImageDraw2.cpython-312.pyc new file mode 100644 index 0000000..51b6dc4 Binary files /dev/null and b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/ImageDraw2.cpython-312.pyc differ diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/ImageEnhance.cpython-312.pyc b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/ImageEnhance.cpython-312.pyc new file mode 100644 index 0000000..bb88cf9 Binary files /dev/null and b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/ImageEnhance.cpython-312.pyc differ diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/ImageFile.cpython-312.pyc b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/ImageFile.cpython-312.pyc new file mode 100644 index 0000000..b30d9e0 Binary files /dev/null and b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/ImageFile.cpython-312.pyc differ diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/ImageFilter.cpython-312.pyc b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/ImageFilter.cpython-312.pyc new file mode 100644 index 0000000..3f56f4b Binary files /dev/null and b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/ImageFilter.cpython-312.pyc differ diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/ImageFont.cpython-312.pyc b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/ImageFont.cpython-312.pyc new file mode 100644 index 0000000..e29421e Binary files /dev/null and b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/ImageFont.cpython-312.pyc differ diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/ImageGrab.cpython-312.pyc b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/ImageGrab.cpython-312.pyc new file mode 100644 index 0000000..d3a642f Binary files /dev/null and b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/ImageGrab.cpython-312.pyc differ diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/ImageMath.cpython-312.pyc b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/ImageMath.cpython-312.pyc new file mode 100644 index 0000000..c222c77 Binary files /dev/null and b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/ImageMath.cpython-312.pyc differ diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/ImageMode.cpython-312.pyc b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/ImageMode.cpython-312.pyc new file mode 100644 index 0000000..a7bb7ed Binary files /dev/null and b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/ImageMode.cpython-312.pyc differ diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/ImageMorph.cpython-312.pyc b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/ImageMorph.cpython-312.pyc new file mode 100644 index 0000000..e90749c Binary files /dev/null and b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/ImageMorph.cpython-312.pyc differ diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/ImageOps.cpython-312.pyc b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/ImageOps.cpython-312.pyc new file mode 100644 index 0000000..6a36d91 Binary files /dev/null and b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/ImageOps.cpython-312.pyc differ diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/ImagePalette.cpython-312.pyc b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/ImagePalette.cpython-312.pyc new file mode 100644 index 0000000..748304c Binary files /dev/null and b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/ImagePalette.cpython-312.pyc differ diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/ImagePath.cpython-312.pyc b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/ImagePath.cpython-312.pyc new file mode 100644 index 0000000..20b1a2f Binary files /dev/null and b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/ImagePath.cpython-312.pyc differ diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/ImageQt.cpython-312.pyc b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/ImageQt.cpython-312.pyc new file mode 100644 index 0000000..f179edd Binary files /dev/null and b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/ImageQt.cpython-312.pyc differ diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/ImageSequence.cpython-312.pyc b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/ImageSequence.cpython-312.pyc new file mode 100644 index 0000000..0e5642f Binary files /dev/null and b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/ImageSequence.cpython-312.pyc differ diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/ImageShow.cpython-312.pyc b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/ImageShow.cpython-312.pyc new file mode 100644 index 0000000..296b532 Binary files /dev/null and b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/ImageShow.cpython-312.pyc differ diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/ImageStat.cpython-312.pyc b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/ImageStat.cpython-312.pyc new file mode 100644 index 0000000..2718a63 Binary files /dev/null and b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/ImageStat.cpython-312.pyc differ diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/ImageText.cpython-312.pyc b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/ImageText.cpython-312.pyc new file mode 100644 index 0000000..9f1b8a6 Binary files /dev/null and b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/ImageText.cpython-312.pyc differ diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/ImageTk.cpython-312.pyc b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/ImageTk.cpython-312.pyc new file mode 100644 index 0000000..091b858 Binary files /dev/null and b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/ImageTk.cpython-312.pyc differ diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/ImageTransform.cpython-312.pyc b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/ImageTransform.cpython-312.pyc new file mode 100644 index 0000000..6794c61 Binary files /dev/null and b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/ImageTransform.cpython-312.pyc differ diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/ImageWin.cpython-312.pyc b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/ImageWin.cpython-312.pyc new file mode 100644 index 0000000..96ea6ff Binary files /dev/null and b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/ImageWin.cpython-312.pyc differ diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/ImtImagePlugin.cpython-312.pyc b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/ImtImagePlugin.cpython-312.pyc new file mode 100644 index 0000000..ec4e202 Binary files /dev/null and b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/ImtImagePlugin.cpython-312.pyc differ diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/IptcImagePlugin.cpython-312.pyc b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/IptcImagePlugin.cpython-312.pyc new file mode 100644 index 0000000..0c093ba Binary files /dev/null and b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/IptcImagePlugin.cpython-312.pyc differ diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/Jpeg2KImagePlugin.cpython-312.pyc b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/Jpeg2KImagePlugin.cpython-312.pyc new file mode 100644 index 0000000..91ab79b Binary files /dev/null and b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/Jpeg2KImagePlugin.cpython-312.pyc differ diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/JpegImagePlugin.cpython-312.pyc b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/JpegImagePlugin.cpython-312.pyc new file mode 100644 index 0000000..8efe778 Binary files /dev/null and b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/JpegImagePlugin.cpython-312.pyc differ diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/JpegPresets.cpython-312.pyc b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/JpegPresets.cpython-312.pyc new file mode 100644 index 0000000..b5a9159 Binary files /dev/null and b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/JpegPresets.cpython-312.pyc differ diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/McIdasImagePlugin.cpython-312.pyc b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/McIdasImagePlugin.cpython-312.pyc new file mode 100644 index 0000000..faf1143 Binary files /dev/null and b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/McIdasImagePlugin.cpython-312.pyc differ diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/MicImagePlugin.cpython-312.pyc b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/MicImagePlugin.cpython-312.pyc new file mode 100644 index 0000000..c0a3e56 Binary files /dev/null and b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/MicImagePlugin.cpython-312.pyc differ diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/MpegImagePlugin.cpython-312.pyc b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/MpegImagePlugin.cpython-312.pyc new file mode 100644 index 0000000..8eb4e5b Binary files /dev/null and b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/MpegImagePlugin.cpython-312.pyc differ diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/MpoImagePlugin.cpython-312.pyc b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/MpoImagePlugin.cpython-312.pyc new file mode 100644 index 0000000..36bb191 Binary files /dev/null and b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/MpoImagePlugin.cpython-312.pyc differ diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/MspImagePlugin.cpython-312.pyc b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/MspImagePlugin.cpython-312.pyc new file mode 100644 index 0000000..ad119d6 Binary files /dev/null and b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/MspImagePlugin.cpython-312.pyc differ diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/PSDraw.cpython-312.pyc b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/PSDraw.cpython-312.pyc new file mode 100644 index 0000000..f670d5c Binary files /dev/null and b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/PSDraw.cpython-312.pyc differ diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/PaletteFile.cpython-312.pyc b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/PaletteFile.cpython-312.pyc new file mode 100644 index 0000000..b9f1601 Binary files /dev/null and b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/PaletteFile.cpython-312.pyc differ diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/PalmImagePlugin.cpython-312.pyc b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/PalmImagePlugin.cpython-312.pyc new file mode 100644 index 0000000..14e8942 Binary files /dev/null and b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/PalmImagePlugin.cpython-312.pyc differ diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/PcdImagePlugin.cpython-312.pyc b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/PcdImagePlugin.cpython-312.pyc new file mode 100644 index 0000000..17568aa Binary files /dev/null and b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/PcdImagePlugin.cpython-312.pyc differ diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/PcfFontFile.cpython-312.pyc b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/PcfFontFile.cpython-312.pyc new file mode 100644 index 0000000..eca15b4 Binary files /dev/null and b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/PcfFontFile.cpython-312.pyc differ diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/PcxImagePlugin.cpython-312.pyc b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/PcxImagePlugin.cpython-312.pyc new file mode 100644 index 0000000..01369e3 Binary files /dev/null and b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/PcxImagePlugin.cpython-312.pyc differ diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/PdfImagePlugin.cpython-312.pyc b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/PdfImagePlugin.cpython-312.pyc new file mode 100644 index 0000000..088bbde Binary files /dev/null and b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/PdfImagePlugin.cpython-312.pyc differ diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/PdfParser.cpython-312.pyc b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/PdfParser.cpython-312.pyc new file mode 100644 index 0000000..d7fce5b Binary files /dev/null and b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/PdfParser.cpython-312.pyc differ diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/PixarImagePlugin.cpython-312.pyc b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/PixarImagePlugin.cpython-312.pyc new file mode 100644 index 0000000..36fae83 Binary files /dev/null and b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/PixarImagePlugin.cpython-312.pyc differ diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/PngImagePlugin.cpython-312.pyc b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/PngImagePlugin.cpython-312.pyc new file mode 100644 index 0000000..ca53dc7 Binary files /dev/null and b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/PngImagePlugin.cpython-312.pyc differ diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/PpmImagePlugin.cpython-312.pyc b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/PpmImagePlugin.cpython-312.pyc new file mode 100644 index 0000000..ebb052a Binary files /dev/null and b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/PpmImagePlugin.cpython-312.pyc differ diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/PsdImagePlugin.cpython-312.pyc b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/PsdImagePlugin.cpython-312.pyc new file mode 100644 index 0000000..f601df5 Binary files /dev/null and b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/PsdImagePlugin.cpython-312.pyc differ diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/QoiImagePlugin.cpython-312.pyc b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/QoiImagePlugin.cpython-312.pyc new file mode 100644 index 0000000..5bce0c5 Binary files /dev/null and b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/QoiImagePlugin.cpython-312.pyc differ diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/SgiImagePlugin.cpython-312.pyc b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/SgiImagePlugin.cpython-312.pyc new file mode 100644 index 0000000..695aca5 Binary files /dev/null and b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/SgiImagePlugin.cpython-312.pyc differ diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/SpiderImagePlugin.cpython-312.pyc b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/SpiderImagePlugin.cpython-312.pyc new file mode 100644 index 0000000..91725db Binary files /dev/null and b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/SpiderImagePlugin.cpython-312.pyc differ diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/SunImagePlugin.cpython-312.pyc b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/SunImagePlugin.cpython-312.pyc new file mode 100644 index 0000000..ec11792 Binary files /dev/null and b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/SunImagePlugin.cpython-312.pyc differ diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/TarIO.cpython-312.pyc b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/TarIO.cpython-312.pyc new file mode 100644 index 0000000..02b1bb6 Binary files /dev/null and b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/TarIO.cpython-312.pyc differ diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/TgaImagePlugin.cpython-312.pyc b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/TgaImagePlugin.cpython-312.pyc new file mode 100644 index 0000000..be3d3d2 Binary files /dev/null and b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/TgaImagePlugin.cpython-312.pyc differ diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/TiffImagePlugin.cpython-312.pyc b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/TiffImagePlugin.cpython-312.pyc new file mode 100644 index 0000000..220e97c Binary files /dev/null and b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/TiffImagePlugin.cpython-312.pyc differ diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/TiffTags.cpython-312.pyc b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/TiffTags.cpython-312.pyc new file mode 100644 index 0000000..74f1db1 Binary files /dev/null and b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/TiffTags.cpython-312.pyc differ diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/WalImageFile.cpython-312.pyc b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/WalImageFile.cpython-312.pyc new file mode 100644 index 0000000..d45d7d8 Binary files /dev/null and b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/WalImageFile.cpython-312.pyc differ diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/WebPImagePlugin.cpython-312.pyc b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/WebPImagePlugin.cpython-312.pyc new file mode 100644 index 0000000..677a30f Binary files /dev/null and b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/WebPImagePlugin.cpython-312.pyc differ diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/WmfImagePlugin.cpython-312.pyc b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/WmfImagePlugin.cpython-312.pyc new file mode 100644 index 0000000..35770d1 Binary files /dev/null and b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/WmfImagePlugin.cpython-312.pyc differ diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/XVThumbImagePlugin.cpython-312.pyc b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/XVThumbImagePlugin.cpython-312.pyc new file mode 100644 index 0000000..fe9a5b7 Binary files /dev/null and b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/XVThumbImagePlugin.cpython-312.pyc differ diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/XbmImagePlugin.cpython-312.pyc b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/XbmImagePlugin.cpython-312.pyc new file mode 100644 index 0000000..2ca444d Binary files /dev/null and b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/XbmImagePlugin.cpython-312.pyc differ diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/XpmImagePlugin.cpython-312.pyc b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/XpmImagePlugin.cpython-312.pyc new file mode 100644 index 0000000..44fdc0c Binary files /dev/null and b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/XpmImagePlugin.cpython-312.pyc differ diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/__init__.cpython-312.pyc b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..570f75e Binary files /dev/null and b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/__init__.cpython-312.pyc differ diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/__main__.cpython-312.pyc b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/__main__.cpython-312.pyc new file mode 100644 index 0000000..46416b2 Binary files /dev/null and b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/__main__.cpython-312.pyc differ diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/_binary.cpython-312.pyc b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/_binary.cpython-312.pyc new file mode 100644 index 0000000..36d1f3d Binary files /dev/null and b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/_binary.cpython-312.pyc differ diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/_deprecate.cpython-312.pyc b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/_deprecate.cpython-312.pyc new file mode 100644 index 0000000..c5e6083 Binary files /dev/null and b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/_deprecate.cpython-312.pyc differ diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/_tkinter_finder.cpython-312.pyc b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/_tkinter_finder.cpython-312.pyc new file mode 100644 index 0000000..2d74b76 Binary files /dev/null and b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/_tkinter_finder.cpython-312.pyc differ diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/_typing.cpython-312.pyc b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/_typing.cpython-312.pyc new file mode 100644 index 0000000..4ce9350 Binary files /dev/null and b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/_typing.cpython-312.pyc differ diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/_util.cpython-312.pyc b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/_util.cpython-312.pyc new file mode 100644 index 0000000..d245690 Binary files /dev/null and b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/_util.cpython-312.pyc differ diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/_version.cpython-312.pyc b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/_version.cpython-312.pyc new file mode 100644 index 0000000..308d36b Binary files /dev/null and b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/_version.cpython-312.pyc differ diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/features.cpython-312.pyc b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/features.cpython-312.pyc new file mode 100644 index 0000000..e076337 Binary files /dev/null and b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/features.cpython-312.pyc differ diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/report.cpython-312.pyc b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/report.cpython-312.pyc new file mode 100644 index 0000000..fdc853f Binary files /dev/null and b/presentation/.venv/lib/python3.12/site-packages/PIL/__pycache__/report.cpython-312.pyc differ diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/_avif.cpython-312-x86_64-linux-gnu.so b/presentation/.venv/lib/python3.12/site-packages/PIL/_avif.cpython-312-x86_64-linux-gnu.so new file mode 100755 index 0000000..6718d47 Binary files /dev/null and b/presentation/.venv/lib/python3.12/site-packages/PIL/_avif.cpython-312-x86_64-linux-gnu.so differ diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/_avif.pyi b/presentation/.venv/lib/python3.12/site-packages/PIL/_avif.pyi new file mode 100644 index 0000000..e27843e --- /dev/null +++ b/presentation/.venv/lib/python3.12/site-packages/PIL/_avif.pyi @@ -0,0 +1,3 @@ +from typing import Any + +def __getattr__(name: str) -> Any: ... diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/_binary.py b/presentation/.venv/lib/python3.12/site-packages/PIL/_binary.py new file mode 100644 index 0000000..d3236c1 --- /dev/null +++ b/presentation/.venv/lib/python3.12/site-packages/PIL/_binary.py @@ -0,0 +1,113 @@ +# +# The Python Imaging Library. +# $Id$ +# +# Binary input/output support routines. +# +# Copyright (c) 1997-2003 by Secret Labs AB +# Copyright (c) 1995-2003 by Fredrik Lundh +# Copyright (c) 2012 by Brian Crowell +# +# See the README file for information on usage and redistribution. +# + + +"""Binary input/output support routines.""" + +from __future__ import annotations + +from struct import pack, unpack_from + + +def i8(c: bytes) -> int: + return c[0] + + +def o8(i: int) -> bytes: + return bytes((i & 255,)) + + +# Input, le = little endian, be = big endian +def i16le(c: bytes, o: int = 0) -> int: + """ + Converts a 2-bytes (16 bits) string to an unsigned integer. + + :param c: string containing bytes to convert + :param o: offset of bytes to convert in string + """ + return unpack_from("<H", c, o)[0] + + +def si16le(c: bytes, o: int = 0) -> int: + """ + Converts a 2-bytes (16 bits) string to a signed integer. + + :param c: string containing bytes to convert + :param o: offset of bytes to convert in string + """ + return unpack_from("<h", c, o)[0] + + +def si16be(c: bytes, o: int = 0) -> int: + """ + Converts a 2-bytes (16 bits) string to a signed integer, big endian. + + :param c: string containing bytes to convert + :param o: offset of bytes to convert in string + """ + return unpack_from(">h", c, o)[0] + + +def i32le(c: bytes, o: int = 0) -> int: + """ + Converts a 4-bytes (32 bits) string to an unsigned integer. + + :param c: string containing bytes to convert + :param o: offset of bytes to convert in string + """ + return unpack_from("<I", c, o)[0] + + +def si32le(c: bytes, o: int = 0) -> int: + """ + Converts a 4-bytes (32 bits) string to a signed integer. + + :param c: string containing bytes to convert + :param o: offset of bytes to convert in string + """ + return unpack_from("<i", c, o)[0] + + +def si32be(c: bytes, o: int = 0) -> int: + """ + Converts a 4-bytes (32 bits) string to a signed integer, big endian. + + :param c: string containing bytes to convert + :param o: offset of bytes to convert in string + """ + return unpack_from(">i", c, o)[0] + + +def i16be(c: bytes, o: int = 0) -> int: + return unpack_from(">H", c, o)[0] + + +def i32be(c: bytes, o: int = 0) -> int: + return unpack_from(">I", c, o)[0] + + +# Output, le = little endian, be = big endian +def o16le(i: int) -> bytes: + return pack("<H", i) + + +def o32le(i: int) -> bytes: + return pack("<I", i) + + +def o16be(i: int) -> bytes: + return pack(">H", i) + + +def o32be(i: int) -> bytes: + return pack(">I", i) diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/_deprecate.py b/presentation/.venv/lib/python3.12/site-packages/PIL/_deprecate.py new file mode 100644 index 0000000..711c62a --- /dev/null +++ b/presentation/.venv/lib/python3.12/site-packages/PIL/_deprecate.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +import warnings + +from . import __version__ + + +def deprecate( + deprecated: str, + when: int | None, + replacement: str | None = None, + *, + action: str | None = None, + plural: bool = False, + stacklevel: int = 3, +) -> None: + """ + Deprecations helper. + + :param deprecated: Name of thing to be deprecated. + :param when: Pillow major version to be removed in. + :param replacement: Name of replacement. + :param action: Instead of "replacement", give a custom call to action + e.g. "Upgrade to new thing". + :param plural: if the deprecated thing is plural, needing "are" instead of "is". + + Usually of the form: + + "[deprecated] is deprecated and will be removed in Pillow [when] (yyyy-mm-dd). + Use [replacement] instead." + + You can leave out the replacement sentence: + + "[deprecated] is deprecated and will be removed in Pillow [when] (yyyy-mm-dd)" + + Or with another call to action: + + "[deprecated] is deprecated and will be removed in Pillow [when] (yyyy-mm-dd). + [action]." + """ + + is_ = "are" if plural else "is" + + if when is None: + removed = "a future version" + elif when <= int(__version__.split(".")[0]): + msg = f"{deprecated} {is_} deprecated and should be removed." + raise RuntimeError(msg) + elif when == 13: + removed = "Pillow 13 (2026-10-15)" + elif when == 14: + removed = "Pillow 14 (2027-10-15)" + else: + msg = f"Unknown removal version: {when}. Update {__name__}?" + raise ValueError(msg) + + if replacement and action: + msg = "Use only one of 'replacement' and 'action'" + raise ValueError(msg) + + if replacement: + action = f". Use {replacement} instead." + elif action: + action = f". {action.rstrip('.')}." + else: + action = "" + + warnings.warn( + f"{deprecated} {is_} deprecated and will be removed in {removed}{action}", + DeprecationWarning, + stacklevel=stacklevel, + ) diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/_imaging.cpython-312-x86_64-linux-gnu.so b/presentation/.venv/lib/python3.12/site-packages/PIL/_imaging.cpython-312-x86_64-linux-gnu.so new file mode 100755 index 0000000..49f1901 Binary files /dev/null and b/presentation/.venv/lib/python3.12/site-packages/PIL/_imaging.cpython-312-x86_64-linux-gnu.so differ diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/_imaging.pyi b/presentation/.venv/lib/python3.12/site-packages/PIL/_imaging.pyi new file mode 100644 index 0000000..81028a5 --- /dev/null +++ b/presentation/.venv/lib/python3.12/site-packages/PIL/_imaging.pyi @@ -0,0 +1,31 @@ +from typing import Any + +class ImagingCore: + def __getitem__(self, index: int) -> float | tuple[int, ...] | None: ... + def __getattr__(self, name: str) -> Any: ... + +class ImagingFont: + def __getattr__(self, name: str) -> Any: ... + +class ImagingDraw: + def __getattr__(self, name: str) -> Any: ... + +class PixelAccess: + def __getitem__(self, xy: tuple[int, int]) -> float | tuple[int, ...]: ... + def __setitem__( + self, xy: tuple[int, int], color: float | tuple[int, ...] + ) -> None: ... + +class ImagingDecoder: + def __getattr__(self, name: str) -> Any: ... + +class ImagingEncoder: + def __getattr__(self, name: str) -> Any: ... + +class _Outline: + def close(self) -> None: ... + def __getattr__(self, name: str) -> Any: ... + +def font(image: ImagingCore, glyphdata: bytes) -> ImagingFont: ... +def outline() -> _Outline: ... +def __getattr__(name: str) -> Any: ... diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/_imagingcms.cpython-312-x86_64-linux-gnu.so b/presentation/.venv/lib/python3.12/site-packages/PIL/_imagingcms.cpython-312-x86_64-linux-gnu.so new file mode 100755 index 0000000..ea34a87 Binary files /dev/null and b/presentation/.venv/lib/python3.12/site-packages/PIL/_imagingcms.cpython-312-x86_64-linux-gnu.so differ diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/_imagingcms.pyi b/presentation/.venv/lib/python3.12/site-packages/PIL/_imagingcms.pyi new file mode 100644 index 0000000..4fc0d60 --- /dev/null +++ b/presentation/.venv/lib/python3.12/site-packages/PIL/_imagingcms.pyi @@ -0,0 +1,143 @@ +import datetime +import sys +from typing import Literal, SupportsFloat, TypeAlias, TypedDict + +from ._typing import CapsuleType + +littlecms_version: str | None + +_Tuple3f: TypeAlias = tuple[float, float, float] +_Tuple2x3f: TypeAlias = tuple[_Tuple3f, _Tuple3f] +_Tuple3x3f: TypeAlias = tuple[_Tuple3f, _Tuple3f, _Tuple3f] + +class _IccMeasurementCondition(TypedDict): + observer: int + backing: _Tuple3f + geo: str + flare: float + illuminant_type: str + +class _IccViewingCondition(TypedDict): + illuminant: _Tuple3f + surround: _Tuple3f + illuminant_type: str + +class CmsProfile: + @property + def rendering_intent(self) -> int: ... + @property + def creation_date(self) -> datetime.datetime | None: ... + @property + def copyright(self) -> str | None: ... + @property + def target(self) -> str | None: ... + @property + def manufacturer(self) -> str | None: ... + @property + def model(self) -> str | None: ... + @property + def profile_description(self) -> str | None: ... + @property + def screening_description(self) -> str | None: ... + @property + def viewing_condition(self) -> str | None: ... + @property + def version(self) -> float: ... + @property + def icc_version(self) -> int: ... + @property + def attributes(self) -> int: ... + @property + def header_flags(self) -> int: ... + @property + def header_manufacturer(self) -> str: ... + @property + def header_model(self) -> str: ... + @property + def device_class(self) -> str: ... + @property + def connection_space(self) -> str: ... + @property + def xcolor_space(self) -> str: ... + @property + def profile_id(self) -> bytes: ... + @property + def is_matrix_shaper(self) -> bool: ... + @property + def technology(self) -> str | None: ... + @property + def colorimetric_intent(self) -> str | None: ... + @property + def perceptual_rendering_intent_gamut(self) -> str | None: ... + @property + def saturation_rendering_intent_gamut(self) -> str | None: ... + @property + def red_colorant(self) -> _Tuple2x3f | None: ... + @property + def green_colorant(self) -> _Tuple2x3f | None: ... + @property + def blue_colorant(self) -> _Tuple2x3f | None: ... + @property + def red_primary(self) -> _Tuple2x3f | None: ... + @property + def green_primary(self) -> _Tuple2x3f | None: ... + @property + def blue_primary(self) -> _Tuple2x3f | None: ... + @property + def media_white_point_temperature(self) -> float | None: ... + @property + def media_white_point(self) -> _Tuple2x3f | None: ... + @property + def media_black_point(self) -> _Tuple2x3f | None: ... + @property + def luminance(self) -> _Tuple2x3f | None: ... + @property + def chromatic_adaptation(self) -> tuple[_Tuple3x3f, _Tuple3x3f] | None: ... + @property + def chromaticity(self) -> _Tuple3x3f | None: ... + @property + def colorant_table(self) -> list[str] | None: ... + @property + def colorant_table_out(self) -> list[str] | None: ... + @property + def intent_supported(self) -> dict[int, tuple[bool, bool, bool]] | None: ... + @property + def clut(self) -> dict[int, tuple[bool, bool, bool]] | None: ... + @property + def icc_measurement_condition(self) -> _IccMeasurementCondition | None: ... + @property + def icc_viewing_condition(self) -> _IccViewingCondition | None: ... + def is_intent_supported(self, intent: int, direction: int, /) -> int: ... + +class CmsTransform: + def apply(self, id_in: CapsuleType, id_out: CapsuleType) -> int: ... + +def profile_open(profile: str, /) -> CmsProfile: ... +def profile_frombytes(profile: bytes, /) -> CmsProfile: ... +def profile_tobytes(profile: CmsProfile, /) -> bytes: ... +def buildTransform( + input_profile: CmsProfile, + output_profile: CmsProfile, + in_mode: str, + out_mode: str, + rendering_intent: int = 0, + cms_flags: int = 0, + /, +) -> CmsTransform: ... +def buildProofTransform( + input_profile: CmsProfile, + output_profile: CmsProfile, + proof_profile: CmsProfile, + in_mode: str, + out_mode: str, + rendering_intent: int = 0, + proof_intent: int = 0, + cms_flags: int = 0, + /, +) -> CmsTransform: ... +def createProfile( + color_space: Literal["LAB", "XYZ", "sRGB"], color_temp: SupportsFloat = 0.0, / +) -> CmsProfile: ... + +if sys.platform == "win32": + def get_display_profile_win32(handle: int = 0, is_dc: int = 0, /) -> str | None: ... diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/_imagingft.cpython-312-x86_64-linux-gnu.so b/presentation/.venv/lib/python3.12/site-packages/PIL/_imagingft.cpython-312-x86_64-linux-gnu.so new file mode 100755 index 0000000..7e8d46e Binary files /dev/null and b/presentation/.venv/lib/python3.12/site-packages/PIL/_imagingft.cpython-312-x86_64-linux-gnu.so differ diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/_imagingft.pyi b/presentation/.venv/lib/python3.12/site-packages/PIL/_imagingft.pyi new file mode 100644 index 0000000..2136810 --- /dev/null +++ b/presentation/.venv/lib/python3.12/site-packages/PIL/_imagingft.pyi @@ -0,0 +1,70 @@ +from collections.abc import Callable +from typing import Any + +from . import ImageFont, _imaging + +class Font: + @property + def family(self) -> str | None: ... + @property + def style(self) -> str | None: ... + @property + def ascent(self) -> int: ... + @property + def descent(self) -> int: ... + @property + def height(self) -> int: ... + @property + def x_ppem(self) -> int: ... + @property + def y_ppem(self) -> int: ... + @property + def glyphs(self) -> int: ... + def render( + self, + string: str | bytes, + fill: Callable[[int, int], _imaging.ImagingCore], + mode: str, + dir: str | None, + features: list[str] | None, + lang: str | None, + stroke_width: float, + stroke_filled: bool, + anchor: str | None, + foreground_ink_long: int, + start: tuple[float, float], + /, + ) -> tuple[_imaging.ImagingCore, tuple[int, int]]: ... + def getsize( + self, + string: str | bytes | bytearray, + mode: str, + dir: str | None, + features: list[str] | None, + lang: str | None, + anchor: str | None, + /, + ) -> tuple[tuple[int, int], tuple[int, int]]: ... + def getlength( + self, + string: str | bytes, + mode: str, + dir: str | None, + features: list[str] | None, + lang: str | None, + /, + ) -> float: ... + def getvarnames(self) -> list[bytes]: ... + def getvaraxes(self) -> list[ImageFont.Axis]: ... + def setvarname(self, instance_index: int, /) -> None: ... + def setvaraxes(self, axes: list[float], /) -> None: ... + +def getfont( + filename: str | bytes, + size: float, + index: int, + encoding: str, + font_bytes: bytes, + layout_engine: int, +) -> Font: ... +def __getattr__(name: str) -> Any: ... diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/_imagingmath.cpython-312-x86_64-linux-gnu.so b/presentation/.venv/lib/python3.12/site-packages/PIL/_imagingmath.cpython-312-x86_64-linux-gnu.so new file mode 100755 index 0000000..5224936 Binary files /dev/null and b/presentation/.venv/lib/python3.12/site-packages/PIL/_imagingmath.cpython-312-x86_64-linux-gnu.so differ diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/_imagingmath.pyi b/presentation/.venv/lib/python3.12/site-packages/PIL/_imagingmath.pyi new file mode 100644 index 0000000..e27843e --- /dev/null +++ b/presentation/.venv/lib/python3.12/site-packages/PIL/_imagingmath.pyi @@ -0,0 +1,3 @@ +from typing import Any + +def __getattr__(name: str) -> Any: ... diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/_imagingmorph.cpython-312-x86_64-linux-gnu.so b/presentation/.venv/lib/python3.12/site-packages/PIL/_imagingmorph.cpython-312-x86_64-linux-gnu.so new file mode 100755 index 0000000..d7c5236 Binary files /dev/null and b/presentation/.venv/lib/python3.12/site-packages/PIL/_imagingmorph.cpython-312-x86_64-linux-gnu.so differ diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/_imagingmorph.pyi b/presentation/.venv/lib/python3.12/site-packages/PIL/_imagingmorph.pyi new file mode 100644 index 0000000..e27843e --- /dev/null +++ b/presentation/.venv/lib/python3.12/site-packages/PIL/_imagingmorph.pyi @@ -0,0 +1,3 @@ +from typing import Any + +def __getattr__(name: str) -> Any: ... diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/_imagingtk.cpython-312-x86_64-linux-gnu.so b/presentation/.venv/lib/python3.12/site-packages/PIL/_imagingtk.cpython-312-x86_64-linux-gnu.so new file mode 100755 index 0000000..16e18d2 Binary files /dev/null and b/presentation/.venv/lib/python3.12/site-packages/PIL/_imagingtk.cpython-312-x86_64-linux-gnu.so differ diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/_imagingtk.pyi b/presentation/.venv/lib/python3.12/site-packages/PIL/_imagingtk.pyi new file mode 100644 index 0000000..e27843e --- /dev/null +++ b/presentation/.venv/lib/python3.12/site-packages/PIL/_imagingtk.pyi @@ -0,0 +1,3 @@ +from typing import Any + +def __getattr__(name: str) -> Any: ... diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/_tkinter_finder.py b/presentation/.venv/lib/python3.12/site-packages/PIL/_tkinter_finder.py new file mode 100644 index 0000000..9c01430 --- /dev/null +++ b/presentation/.venv/lib/python3.12/site-packages/PIL/_tkinter_finder.py @@ -0,0 +1,20 @@ +"""Find compiled module linking to Tcl / Tk libraries""" + +from __future__ import annotations + +import sys +import tkinter + +tk = getattr(tkinter, "_tkinter") + +try: + if hasattr(sys, "pypy_find_executable"): + TKINTER_LIB = tk.tklib_cffi.__file__ + else: + TKINTER_LIB = tk.__file__ +except AttributeError: + # _tkinter may be compiled directly into Python, in which case __file__ is + # not available. load_tkinter_funcs will check the binary first in any case. + TKINTER_LIB = None + +tk_version = str(tkinter.TkVersion) diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/_typing.py b/presentation/.venv/lib/python3.12/site-packages/PIL/_typing.py new file mode 100644 index 0000000..a941f89 --- /dev/null +++ b/presentation/.venv/lib/python3.12/site-packages/PIL/_typing.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +import os +import sys +from collections.abc import Sequence +from typing import Any, Protocol, TypeVar + +TYPE_CHECKING = False +if TYPE_CHECKING: + from numbers import _IntegralLike as IntegralLike + + try: + import numpy.typing as npt + + NumpyArray = npt.NDArray[Any] + except ImportError: + pass + +if sys.version_info >= (3, 13): + from types import CapsuleType +else: + CapsuleType = object + +if sys.version_info >= (3, 12): + from collections.abc import Buffer +else: + Buffer = Any + + +_Ink = float | tuple[int, ...] | str + +Coords = Sequence[float] | Sequence[Sequence[float]] + + +_T_co = TypeVar("_T_co", covariant=True) + + +class SupportsRead(Protocol[_T_co]): + def read(self, length: int = ..., /) -> _T_co: ... + + +StrOrBytesPath = str | bytes | os.PathLike[str] | os.PathLike[bytes] + + +__all__ = ["Buffer", "IntegralLike", "StrOrBytesPath", "SupportsRead"] diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/_util.py b/presentation/.venv/lib/python3.12/site-packages/PIL/_util.py new file mode 100644 index 0000000..b1fa6a0 --- /dev/null +++ b/presentation/.venv/lib/python3.12/site-packages/PIL/_util.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +import os + +TYPE_CHECKING = False +if TYPE_CHECKING: + from typing import Any, NoReturn, TypeGuard + + from ._typing import StrOrBytesPath + + +def is_path(f: Any) -> TypeGuard[StrOrBytesPath]: + return isinstance(f, (bytes, str, os.PathLike)) + + +class DeferredError: + def __init__(self, ex: BaseException): + self.ex = ex + + def __getattr__(self, elt: str) -> NoReturn: + raise self.ex + + @staticmethod + def new(ex: BaseException) -> Any: + """ + Creates an object that raises the wrapped exception ``ex`` when used, + and casts it to :py:obj:`~typing.Any` type. + """ + return DeferredError(ex) diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/_version.py b/presentation/.venv/lib/python3.12/site-packages/PIL/_version.py new file mode 100644 index 0000000..72d11ae --- /dev/null +++ b/presentation/.venv/lib/python3.12/site-packages/PIL/_version.py @@ -0,0 +1,4 @@ +# Master version for Pillow +from __future__ import annotations + +__version__ = "12.2.0" diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/_webp.cpython-312-x86_64-linux-gnu.so b/presentation/.venv/lib/python3.12/site-packages/PIL/_webp.cpython-312-x86_64-linux-gnu.so new file mode 100755 index 0000000..0afd77c Binary files /dev/null and b/presentation/.venv/lib/python3.12/site-packages/PIL/_webp.cpython-312-x86_64-linux-gnu.so differ diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/_webp.pyi b/presentation/.venv/lib/python3.12/site-packages/PIL/_webp.pyi new file mode 100644 index 0000000..e27843e --- /dev/null +++ b/presentation/.venv/lib/python3.12/site-packages/PIL/_webp.pyi @@ -0,0 +1,3 @@ +from typing import Any + +def __getattr__(name: str) -> Any: ... diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/features.py b/presentation/.venv/lib/python3.12/site-packages/PIL/features.py new file mode 100644 index 0000000..ff32c25 --- /dev/null +++ b/presentation/.venv/lib/python3.12/site-packages/PIL/features.py @@ -0,0 +1,343 @@ +from __future__ import annotations + +import collections +import os +import sys +import warnings +from typing import IO + +import PIL + +from . import Image + +modules = { + "pil": ("PIL._imaging", "PILLOW_VERSION"), + "tkinter": ("PIL._tkinter_finder", "tk_version"), + "freetype2": ("PIL._imagingft", "freetype2_version"), + "littlecms2": ("PIL._imagingcms", "littlecms_version"), + "webp": ("PIL._webp", "webpdecoder_version"), + "avif": ("PIL._avif", "libavif_version"), +} + + +def check_module(feature: str) -> bool: + """ + Checks if a module is available. + + :param feature: The module to check for. + :returns: ``True`` if available, ``False`` otherwise. + :raises ValueError: If the module is not defined in this version of Pillow. + """ + if feature not in modules: + msg = f"Unknown module {feature}" + raise ValueError(msg) + + module, ver = modules[feature] + + try: + __import__(module) + return True + except ModuleNotFoundError: + return False + except ImportError as ex: + warnings.warn(str(ex)) + return False + + +def version_module(feature: str) -> str | None: + """ + :param feature: The module to check for. + :returns: + The loaded version number as a string, or ``None`` if unknown or not available. + :raises ValueError: If the module is not defined in this version of Pillow. + """ + if not check_module(feature): + return None + + module, ver = modules[feature] + + return getattr(__import__(module, fromlist=[ver]), ver) + + +def get_supported_modules() -> list[str]: + """ + :returns: A list of all supported modules. + """ + return [f for f in modules if check_module(f)] + + +codecs = { + "jpg": ("jpeg", "jpeglib"), + "jpg_2000": ("jpeg2k", "jp2klib"), + "zlib": ("zip", "zlib"), + "libtiff": ("libtiff", "libtiff"), +} + + +def check_codec(feature: str) -> bool: + """ + Checks if a codec is available. + + :param feature: The codec to check for. + :returns: ``True`` if available, ``False`` otherwise. + :raises ValueError: If the codec is not defined in this version of Pillow. + """ + if feature not in codecs: + msg = f"Unknown codec {feature}" + raise ValueError(msg) + + codec, lib = codecs[feature] + + return f"{codec}_encoder" in dir(Image.core) + + +def version_codec(feature: str) -> str | None: + """ + :param feature: The codec to check for. + :returns: + The version number as a string, or ``None`` if not available. + Checked at compile time for ``jpg``, run-time otherwise. + :raises ValueError: If the codec is not defined in this version of Pillow. + """ + if not check_codec(feature): + return None + + codec, lib = codecs[feature] + + version = getattr(Image.core, f"{lib}_version") + + if feature == "libtiff": + return version.split("\n")[0].split("Version ")[1] + + return version + + +def get_supported_codecs() -> list[str]: + """ + :returns: A list of all supported codecs. + """ + return [f for f in codecs if check_codec(f)] + + +features: dict[str, tuple[str, str, str | None]] = { + "raqm": ("PIL._imagingft", "HAVE_RAQM", "raqm_version"), + "fribidi": ("PIL._imagingft", "HAVE_FRIBIDI", "fribidi_version"), + "harfbuzz": ("PIL._imagingft", "HAVE_HARFBUZZ", "harfbuzz_version"), + "libjpeg_turbo": ("PIL._imaging", "HAVE_LIBJPEGTURBO", "libjpeg_turbo_version"), + "mozjpeg": ("PIL._imaging", "HAVE_MOZJPEG", "libjpeg_turbo_version"), + "zlib_ng": ("PIL._imaging", "HAVE_ZLIBNG", "zlib_ng_version"), + "libimagequant": ("PIL._imaging", "HAVE_LIBIMAGEQUANT", "imagequant_version"), + "xcb": ("PIL._imaging", "HAVE_XCB", None), +} + + +def check_feature(feature: str) -> bool | None: + """ + Checks if a feature is available. + + :param feature: The feature to check for. + :returns: ``True`` if available, ``False`` if unavailable, ``None`` if unknown. + :raises ValueError: If the feature is not defined in this version of Pillow. + """ + if feature not in features: + msg = f"Unknown feature {feature}" + raise ValueError(msg) + + module, flag, ver = features[feature] + + try: + imported_module = __import__(module, fromlist=["PIL"]) + return getattr(imported_module, flag) + except ModuleNotFoundError: + return None + except ImportError as ex: + warnings.warn(str(ex)) + return None + + +def version_feature(feature: str) -> str | None: + """ + :param feature: The feature to check for. + :returns: The version number as a string, or ``None`` if not available. + :raises ValueError: If the feature is not defined in this version of Pillow. + """ + if not check_feature(feature): + return None + + module, flag, ver = features[feature] + + if ver is None: + return None + + return getattr(__import__(module, fromlist=[ver]), ver) + + +def get_supported_features() -> list[str]: + """ + :returns: A list of all supported features. + """ + return [f for f in features if check_feature(f)] + + +def check(feature: str) -> bool | None: + """ + :param feature: A module, codec, or feature name. + :returns: + ``True`` if the module, codec, or feature is available, + ``False`` or ``None`` otherwise. + """ + + if feature in modules: + return check_module(feature) + if feature in codecs: + return check_codec(feature) + if feature in features: + return check_feature(feature) + warnings.warn(f"Unknown feature '{feature}'.", stacklevel=2) + return False + + +def version(feature: str) -> str | None: + """ + :param feature: + The module, codec, or feature to check for. + :returns: + The version number as a string, or ``None`` if unknown or not available. + """ + if feature in modules: + return version_module(feature) + if feature in codecs: + return version_codec(feature) + if feature in features: + return version_feature(feature) + return None + + +def get_supported() -> list[str]: + """ + :returns: A list of all supported modules, features, and codecs. + """ + + ret = get_supported_modules() + ret.extend(get_supported_features()) + ret.extend(get_supported_codecs()) + return ret + + +def pilinfo(out: IO[str] | None = None, supported_formats: bool = True) -> None: + """ + Prints information about this installation of Pillow. + This function can be called with ``python3 -m PIL``. + It can also be called with ``python3 -m PIL.report`` or ``python3 -m PIL --report`` + to have "supported_formats" set to ``False``, omitting the list of all supported + image file formats. + + :param out: + The output stream to print to. Defaults to ``sys.stdout`` if ``None``. + :param supported_formats: + If ``True``, a list of all supported image file formats will be printed. + """ + + if out is None: + out = sys.stdout + + Image.init() + + print("-" * 68, file=out) + print(f"Pillow {PIL.__version__}", file=out) + py_version_lines = sys.version.splitlines() + print(f"Python {py_version_lines[0].strip()}", file=out) + for py_version in py_version_lines[1:]: + print(f" {py_version.strip()}", file=out) + print("-" * 68, file=out) + print(f"Python executable is {sys.executable or 'unknown'}", file=out) + if sys.prefix != sys.base_prefix: + print(f"Environment Python files loaded from {sys.prefix}", file=out) + print(f"System Python files loaded from {sys.base_prefix}", file=out) + print("-" * 68, file=out) + print( + f"Python Pillow modules loaded from {os.path.dirname(Image.__file__)}", + file=out, + ) + print( + f"Binary Pillow modules loaded from {os.path.dirname(Image.core.__file__)}", + file=out, + ) + print("-" * 68, file=out) + + for name, feature in [ + ("pil", "PIL CORE"), + ("tkinter", "TKINTER"), + ("freetype2", "FREETYPE2"), + ("littlecms2", "LITTLECMS2"), + ("webp", "WEBP"), + ("avif", "AVIF"), + ("jpg", "JPEG"), + ("jpg_2000", "OPENJPEG (JPEG2000)"), + ("zlib", "ZLIB (PNG/ZIP)"), + ("libtiff", "LIBTIFF"), + ("raqm", "RAQM (Bidirectional Text)"), + ("libimagequant", "LIBIMAGEQUANT (Quantization method)"), + ("xcb", "XCB (X protocol)"), + ]: + if check(name): + v: str | None = None + if name == "jpg": + libjpeg_turbo_version = version_feature("libjpeg_turbo") + if libjpeg_turbo_version is not None: + v = "mozjpeg" if check_feature("mozjpeg") else "libjpeg-turbo" + v += " " + libjpeg_turbo_version + if v is None: + v = version(name) + if v is not None: + version_static = name in ("pil", "jpg") + if name == "littlecms2": + # this check is also in src/_imagingcms.c:setup_module() + version_static = tuple(int(x) for x in v.split(".")) < (2, 7) + t = "compiled for" if version_static else "loaded" + if name == "zlib": + zlib_ng_version = version_feature("zlib_ng") + if zlib_ng_version is not None: + v += ", compiled for zlib-ng " + zlib_ng_version + elif name == "raqm": + for f in ("fribidi", "harfbuzz"): + v2 = version_feature(f) + if v2 is not None: + v += f", {f} {v2}" + print("---", feature, "support ok,", t, v, file=out) + else: + print("---", feature, "support ok", file=out) + else: + print("***", feature, "support not installed", file=out) + print("-" * 68, file=out) + + if supported_formats: + extensions = collections.defaultdict(list) + for ext, i in Image.EXTENSION.items(): + extensions[i].append(ext) + + for i in sorted(Image.ID): + line = f"{i}" + if i in Image.MIME: + line = f"{line} {Image.MIME[i]}" + print(line, file=out) + + if i in extensions: + print( + "Extensions: {}".format(", ".join(sorted(extensions[i]))), file=out + ) + + features = [] + if i in Image.OPEN: + features.append("open") + if i in Image.SAVE: + features.append("save") + if i in Image.SAVE_ALL: + features.append("save_all") + if i in Image.DECODERS: + features.append("decode") + if i in Image.ENCODERS: + features.append("encode") + + print("Features: {}".format(", ".join(features)), file=out) + print("-" * 68, file=out) diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/py.typed b/presentation/.venv/lib/python3.12/site-packages/PIL/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/presentation/.venv/lib/python3.12/site-packages/PIL/report.py b/presentation/.venv/lib/python3.12/site-packages/PIL/report.py new file mode 100644 index 0000000..d2815e8 --- /dev/null +++ b/presentation/.venv/lib/python3.12/site-packages/PIL/report.py @@ -0,0 +1,5 @@ +from __future__ import annotations + +from .features import pilinfo + +pilinfo(supported_formats=False) diff --git a/presentation/.venv/lib/python3.12/site-packages/__pycache__/typing_extensions.cpython-312.pyc b/presentation/.venv/lib/python3.12/site-packages/__pycache__/typing_extensions.cpython-312.pyc new file mode 100644 index 0000000..b8af5f6 Binary files /dev/null and b/presentation/.venv/lib/python3.12/site-packages/__pycache__/typing_extensions.cpython-312.pyc differ diff --git a/presentation/.venv/lib/python3.12/site-packages/lxml-6.1.0.dist-info/INSTALLER b/presentation/.venv/lib/python3.12/site-packages/lxml-6.1.0.dist-info/INSTALLER new file mode 100644 index 0000000..a1b589e --- /dev/null +++ b/presentation/.venv/lib/python3.12/site-packages/lxml-6.1.0.dist-info/INSTALLER @@ -0,0 +1 @@ +pip diff --git a/presentation/.venv/lib/python3.12/site-packages/lxml-6.1.0.dist-info/METADATA b/presentation/.venv/lib/python3.12/site-packages/lxml-6.1.0.dist-info/METADATA new file mode 100644 index 0000000..805cbb8 --- /dev/null +++ b/presentation/.venv/lib/python3.12/site-packages/lxml-6.1.0.dist-info/METADATA @@ -0,0 +1,104 @@ +Metadata-Version: 2.4 +Name: lxml +Version: 6.1.0 +Summary: Powerful and Pythonic XML processing library combining libxml2/libxslt with the ElementTree API. +Home-page: https://lxml.de/ +Author: lxml dev team +Author-email: lxml@lxml.de +Maintainer: lxml dev team +Maintainer-email: lxml@lxml.de +License: BSD-3-Clause +Project-URL: Source, https://github.com/lxml/lxml +Project-URL: Bug Tracker, https://bugs.launchpad.net/lxml +Classifier: Development Status :: 5 - Production/Stable +Classifier: Intended Audience :: Developers +Classifier: Intended Audience :: Information Technology +Classifier: Programming Language :: Cython +Classifier: Programming Language :: Python :: 3 +Classifier: Programming Language :: Python :: 3.8 +Classifier: Programming Language :: Python :: 3.9 +Classifier: Programming Language :: Python :: 3.10 +Classifier: Programming Language :: Python :: 3.11 +Classifier: Programming Language :: Python :: 3.12 +Classifier: Programming Language :: Python :: 3.13 +Classifier: Programming Language :: Python :: 3.14 +Classifier: Programming Language :: C +Classifier: Operating System :: OS Independent +Classifier: Topic :: Text Processing :: Markup :: HTML +Classifier: Topic :: Text Processing :: Markup :: XML +Classifier: Topic :: Software Development :: Libraries :: Python Modules +Requires-Python: >=3.8 +License-File: LICENSE.txt +License-File: LICENSES.txt +Provides-Extra: source +Provides-Extra: cssselect +Requires-Dist: cssselect>=0.7; extra == "cssselect" +Provides-Extra: html5 +Requires-Dist: html5lib; extra == "html5" +Provides-Extra: htmlsoup +Requires-Dist: BeautifulSoup4; extra == "htmlsoup" +Provides-Extra: html-clean +Requires-Dist: lxml_html_clean; extra == "html-clean" +Dynamic: author +Dynamic: author-email +Dynamic: classifier +Dynamic: description +Dynamic: home-page +Dynamic: license +Dynamic: license-file +Dynamic: maintainer +Dynamic: maintainer-email +Dynamic: project-url +Dynamic: provides-extra +Dynamic: requires-python +Dynamic: summary + +lxml is a Pythonic, mature binding for the libxml2 and libxslt libraries. +It provides safe and convenient access to these libraries using the +ElementTree API. + +It extends the ElementTree API significantly to offer support for XPath, +RelaxNG, XML Schema, XSLT, C14N and much more. + +To contact the project, go to the `project home page <https://lxml.de/>`_ +or see our bug tracker at https://launchpad.net/lxml + +In case you want to use the current in-development version of lxml, +you can get it from the github repository at +https://github.com/lxml/lxml . Note that this requires Cython to +build the sources, see the build instructions on the project home page. + + +After an official release of a new stable series, bug fixes may become available at +https://github.com/lxml/lxml/tree/lxml-6.1 . +Running ``pip install https://github.com/lxml/lxml/archive/refs/heads/lxml-6.1.tar.gz`` +will install the unreleased branch state as soon as a maintenance branch has been established. +Note that this requires Cython to be installed at an appropriate version for the build. + +6.1.0 (2026-04-17) +================== + +This release fixes a possible external entity injection (XXE) vulnerability in +``iterparse()`` and the ``ETCompatXMLParser``. + +Features added +-------------- + +* GH#486: The HTML ARIA accessibility attributes were added to the set of safe attributes + in ``lxml.html.defs``. This allows ``lxml_html_clean`` to pass them through. + Patch by oomsveta. + +* The default chunk size for reading from file-likes in ``iterparse()`` is now configurable + with a new ``chunk_size`` argument. + +Bugs fixed +---------- + +* LP#2146291: The ``resolve_entities`` option was still set to ``True`` for + ``iterparse`` and ``ETCompatXMLParser``, allowing for external entity injection (XXE) + when using these parsers without setting this option explicitly. + The default was now changed to ``'internal'`` only (as for the normal XML and HTML parsers + since lxml 5.0). + Issue found by Sihao Qiu as CVE-2026-41066. + + diff --git a/presentation/.venv/lib/python3.12/site-packages/lxml-6.1.0.dist-info/RECORD b/presentation/.venv/lib/python3.12/site-packages/lxml-6.1.0.dist-info/RECORD new file mode 100644 index 0000000..65bdcb2 --- /dev/null +++ b/presentation/.venv/lib/python3.12/site-packages/lxml-6.1.0.dist-info/RECORD @@ -0,0 +1,204 @@ +lxml-6.1.0.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4 +lxml-6.1.0.dist-info/METADATA,sha256=C22o5J3b-pRFqvsDJ8BZIZgShZshbyM4dD_WThX15FU,3953 +lxml-6.1.0.dist-info/RECORD,, +lxml-6.1.0.dist-info/WHEEL,sha256=rSHxYvtox9WA2CLg-qPNRHTwscksbjj7CEInt0-zzg4,152 +lxml-6.1.0.dist-info/licenses/LICENSE.txt,sha256=j8K1aBM1FuRoRdIUeRet7uFkjnCumrXtbFQXr-9M6FU,1507 +lxml-6.1.0.dist-info/licenses/LICENSES.txt,sha256=QdSd1AaqDhVIptXyGjDWv2OLPNlutyid00jYPtLkA5I,1514 +lxml-6.1.0.dist-info/top_level.txt,sha256=NjD988wqaKq512nshNdLt-uDxsjkp4Bh51m6N-dhUrk,5 +lxml/ElementInclude.py,sha256=PSLeZFvCa76WHJulPLxcZXJtCI2-4dK2CtqPRiYOAQg,8560 +lxml/__init__.py,sha256=nNZhK4w0_VI-AvQ3n1b4cWN1fFhYFdteghBR1mR7j1M,574 +lxml/__pycache__/ElementInclude.cpython-312.pyc,, +lxml/__pycache__/__init__.cpython-312.pyc,, +lxml/__pycache__/_elementpath.cpython-312.pyc,, +lxml/__pycache__/builder.cpython-312.pyc,, +lxml/__pycache__/cssselect.cpython-312.pyc,, +lxml/__pycache__/doctestcompare.cpython-312.pyc,, +lxml/__pycache__/pyclasslookup.cpython-312.pyc,, +lxml/__pycache__/sax.cpython-312.pyc,, +lxml/__pycache__/usedoctest.cpython-312.pyc,, +lxml/_elementpath.cpython-312-x86_64-linux-gnu.so,sha256=Gngj3IsCTtH8jsFJTJKW0x4oAbFMPSQIFgVbMUZ-PvU,209096 +lxml/_elementpath.py,sha256=b80hM3ndAkTtRX6v54za3LkkAqCcd0700BbMPZHnTBU,10959 +lxml/apihelpers.pxi,sha256=CugqfQgyn6aFtwp7eH144RUpV2v1ItxoalhJcFbORTQ,64604 +lxml/builder.cpython-312-x86_64-linux-gnu.so,sha256=MSiyw-7TTv91-yEn-dq2qc5QIFmTW2knqs64u7GoUvs,120880 +lxml/builder.py,sha256=KI1HxHTd4wJqqVfmTRtSbXBQdl2T-P36ih4hT-J3MNw,8485 +lxml/classlookup.pxi,sha256=Tax8Vhbm5C6UCjgmRFsYjW0pFHxIuTthH1MOgASDLgc,22435 +lxml/cleanup.pxi,sha256=ZNEpbv7qx_ICPzsxhCaMUHCOfiznOoZ_u3jlYXHAuh4,8454 +lxml/cssselect.py,sha256=_wZdX-B9p5MeIYABmENIYRWEkwXwX-7jO8Dkf-1rUZU,3306 +lxml/debug.pxi,sha256=KTcpR8-slUYvmIPbE35GoHDNTb-gjTEvD7bw6LltM4c,1125 +lxml/docloader.pxi,sha256=fcoVd_mwTFZhjItCv_TfwXpgVek7JlvFObSfRxmVqs0,5790 +lxml/doctestcompare.py,sha256=40EDnkwpcvW86qNa86990OXF42xdHaosSZoiBsEjkzU,17731 +lxml/dtd.pxi,sha256=IAKkmA4ZoC68sqAWcTqoS8jEGYcPQrVMCZgn4iLBYko,15281 +lxml/etree.cpython-312-x86_64-linux-gnu.so,sha256=2TuGzdSHoOS0lBiobIcVea95xwGLxrKj0y8AIXHJ-Yo,5272432 +lxml/etree.h,sha256=hWgHumv7DeTpH0nHhbsCIe693cIcimtLabExxwBxR-c,9792 +lxml/etree.pyx,sha256=zgobCeGw6lw902OA0FWuwlOpqA_0eZeLM2baWi1pzio,138216 +lxml/etree_api.h,sha256=UG16a1ThLt259FSG3vUy6ka6OIat-6LLJyVBLz6d3OM,17710 +lxml/extensions.pxi,sha256=saxxc1mZyA8WdwIIL90B4lQb351j8-G7ttLPI5k-8Hw,32248 +lxml/html/ElementSoup.py,sha256=s_dLobLMuKn2DhexR-iDXdZrMFg1RjLy1feHsIeZMpw,320 +lxml/html/__init__.py,sha256=CC5WdsvSptZhr9MZya1qsL6JKVbviYdrHOhXrGhmORg,64425 +lxml/html/__pycache__/ElementSoup.cpython-312.pyc,, +lxml/html/__pycache__/__init__.cpython-312.pyc,, +lxml/html/__pycache__/_diffcommand.cpython-312.pyc,, +lxml/html/__pycache__/_difflib.cpython-312.pyc,, +lxml/html/__pycache__/_html5builder.cpython-312.pyc,, +lxml/html/__pycache__/_setmixin.cpython-312.pyc,, +lxml/html/__pycache__/builder.cpython-312.pyc,, +lxml/html/__pycache__/clean.cpython-312.pyc,, +lxml/html/__pycache__/defs.cpython-312.pyc,, +lxml/html/__pycache__/diff.cpython-312.pyc,, +lxml/html/__pycache__/formfill.cpython-312.pyc,, +lxml/html/__pycache__/html5parser.cpython-312.pyc,, +lxml/html/__pycache__/soupparser.cpython-312.pyc,, +lxml/html/__pycache__/usedoctest.cpython-312.pyc,, +lxml/html/_diffcommand.py,sha256=kz_7EP9PmYWuczlZcGiw74_rG0eTKvQ2lrO0rkiwlYE,2081 +lxml/html/_difflib.cpython-312-x86_64-linux-gnu.so,sha256=0Za0L59Q9ZDfTG86x9enqvkdutV0Z29YDEjG4nftBLQ,533400 +lxml/html/_difflib.py,sha256=GgH_jVrZQC8tI8WV_lFZQsXFJ3mOTAPup1zjBJNvkPo,84954 +lxml/html/_html5builder.py,sha256=NLaT-Ev-aBgJpeQl-6ZbJChLZK5GV-znDkHOJD5VQC4,3230 +lxml/html/_setmixin.py,sha256=8IFIOLmVz0G-XzsD2tCEkSFWO-dgPBHgvHufC8ni67s,1188 +lxml/html/builder.py,sha256=Uz3r5uiuCdoN0UPa7ngoLMwAadVIhslzGvlRPGigY_M,6187 +lxml/html/clean.py,sha256=WU0KfXAyjDFn2ojE7T1dfk4cl_FduBu5ZbFSTc9Up_o,503 +lxml/html/defs.py,sha256=IHIW70bC-m8RdCtGOyYak58hFiBqyhegTC83QlsO5As,5322 +lxml/html/diff.cpython-312-x86_64-linux-gnu.so,sha256=EoLnmB8DWTm1fvtjq5IQR6j-bXa-h3nnNRt4E_F-LzA,361400 +lxml/html/diff.py,sha256=Za0By-yeYlQEjUu7m7xKB288kKiy8VBS5gT0RPOaFY0,32989 +lxml/html/formfill.py,sha256=umgk0BbkAI1W6q9musFbL-cDnI_aap2NsLBJqk0UmVI,9681 +lxml/html/html5parser.py,sha256=dnyC4cqHxywjZSzk0mu2L7THTZjxhg4yF4pncjusa_w,8634 +lxml/html/soupparser.py,sha256=xo8VvNeOEb-SChuXLKCRECh8J7HBiJLE9sAbEskoUUQ,10197 +lxml/html/usedoctest.py,sha256=tPlmVz4KK1GRKV5DJLrdVECeqsT9PlDzSqqTodVi5s0,249 +lxml/includes/__init__.pxd,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 +lxml/includes/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 +lxml/includes/__pycache__/__init__.cpython-312.pyc,, +lxml/includes/c14n.pxd,sha256=DBQcOJ0c_YS245ohMb8fmuEC1kFyv1LrNY_8Mf-syZg,1110 +lxml/includes/config.pxd,sha256=H6Mrl8It21hzRI2hzMId9W48QqkYYkoLT4dniLNmdTw,96 +lxml/includes/dtdvalid.pxd,sha256=Nv0OykjYehv2lO-Zj--q6jS3TAC_dvQVPSgPMuse1NM,689 +lxml/includes/etree_defs.h,sha256=h_UjJTmNUqPyKNNrWB9hxmt6v4CF7_83XVY8dOfxqW0,14524 +lxml/includes/etreepublic.pxd,sha256=Bn4d3JkWPqXputXqI-eJ0xmPrwNFPTfDCa7axgjB7FM,10184 +lxml/includes/extlibs/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 +lxml/includes/extlibs/__pycache__/__init__.cpython-312.pyc,, +lxml/includes/extlibs/libcharset.h,sha256=GA0FumrbNI4VDGlzq3lf5CLaCwXgn4unw2l0btGQFwI,1510 +lxml/includes/extlibs/localcharset.h,sha256=Z_AagaQeq0aDE7NPsVOqEf4nO4KcUp46ggo4d0ONIOQ,6338 +lxml/includes/extlibs/zconf.h,sha256=BxihG-sylbNF-ymmO0S2VLKC2CxM0FE_YiVYfyspuLs,16921 +lxml/includes/extlibs/zlib.h,sha256=gYZn1qtqN_50acsGp_DLLCyy8slIoD5azPGkp0vzAgo,103848 +lxml/includes/htmlparser.pxd,sha256=9uASkP5dU7OE2lCOLT-z2e01qSbFlp4ehgwdostF_qk,2802 +lxml/includes/libexslt/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 +lxml/includes/libexslt/__pycache__/__init__.cpython-312.pyc,, +lxml/includes/libexslt/exslt.h,sha256=eSW5tMJAewSUANLqk7AGEiU8b2BbCNRyauHnez7nKSU,3114 +lxml/includes/libexslt/exsltconfig.h,sha256=QHxzEbRlv_h0USBvpr0Zrl0Muzlc71VCrvgR6lqnLEY,1172 +lxml/includes/libexslt/exsltexports.h,sha256=1Jm9KTXm2FUUJIZ6V6-Uw55yG0BMULX3_goyxDd2LL8,1077 +lxml/includes/libxml/HTMLparser.h,sha256=sU4xGqj-vBtEvzlxA3hBPWJboifvkc4F1hynKXmsl3k,9569 +lxml/includes/libxml/HTMLtree.h,sha256=Q7UBKFbQ8fx4d_dMnmR6ay8JmfOhopFkDp2B63YkLDU,3517 +lxml/includes/libxml/SAX.h,sha256=SFnG27EFrYGUB9HDL_xSIGBwEns5pl07rApXWThFZFM,386 +lxml/includes/libxml/SAX2.h,sha256=RfFP5o3le-Rg8bnA2GW7L7L9_pfXCs3TieODcv1DTWY,4240 +lxml/includes/libxml/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 +lxml/includes/libxml/__pycache__/__init__.cpython-312.pyc,, +lxml/includes/libxml/c14n.h,sha256=BSBXw6nIZutC8mWvbRrLLmoWjw3wRt-nM93vjXGMCm8,2742 +lxml/includes/libxml/catalog.h,sha256=H9ssTCaBjtDqc-AZqCk1R7h8F2iD9szqLjJyHpaczXg,4633 +lxml/includes/libxml/chvalid.h,sha256=TZcceNp6Cw0QlYwIqK9GxyYqL5UiAjpQyjt_yrZGTQE,5087 +lxml/includes/libxml/debugXML.h,sha256=XXRNI39gJW7bGRC4SzE4ad-SJ906BsUGz3AwOtkKuS4,1667 +lxml/includes/libxml/dict.h,sha256=SweaPGMtTTf4je6dNTIoEzcfEvpsAT9_PhR7FC0K-rQ,1770 +lxml/includes/libxml/encoding.h,sha256=haL7ratww2wkIERGmtwUqU2BbTVe52FZFU7MmrOpsPk,9623 +lxml/includes/libxml/entities.h,sha256=LEOCA826-0f8dhRJzC_2hvUVsSH7lKQjrea9hSTdBbo,4419 +lxml/includes/libxml/globals.h,sha256=NH8zyRI5cXJJGp5k2aLxOm-reJEGOFX6LYP82GBXRlY,583 +lxml/includes/libxml/hash.h,sha256=KIIpAYKBfGUU3ydWhGehUyfuauZz_Ps0gyambzQo_rc,7017 +lxml/includes/libxml/list.h,sha256=oh7iJNQajRA_cHsNk9CcFPYkaW2smf4J_MpedPPjC4k,3128 +lxml/includes/libxml/nanoftp.h,sha256=22PBtWhJueYLFvwukt4oFooRct_xJA83hbluHRBNXUM,302 +lxml/includes/libxml/nanohttp.h,sha256=bLbzYjAyAKmP3ComMOPH6XaUImu6bNAESF1HrVtRve0,2124 +lxml/includes/libxml/parser.h,sha256=Uq7-ce55UUAsvo4n6CiBlNQpmowewvWhOsQtgGM1UQ8,48498 +lxml/includes/libxml/parserInternals.h,sha256=8_Wr6UgRzm8BRn1RPLxyBkw6BagAdDvVqMA_e181_EI,14539 +lxml/includes/libxml/relaxng.h,sha256=VXZ74r5Yja06KqypdBHc8neDwPxQ2aMrsWHSdRt5oi4,5991 +lxml/includes/libxml/schemasInternals.h,sha256=V8M4In3zf24EX55Yt4dcfxwp7NpHGYViKnLKwtyrPJ4,26233 +lxml/includes/libxml/schematron.h,sha256=8EhPDhvtlMxl9e0C5rSbEruOvzJS5BC_OOFbq9RXZnY,4255 +lxml/includes/libxml/threads.h,sha256=mT3CgK4lXK7-NDnUOFXqYuCK6fyY70S3BsHF-TnT45k,1619 +lxml/includes/libxml/tree.h,sha256=zTRLt6h5x6ApyeXgs90CKQZSAl2hKm7b5NxtPKUQFAE,36106 +lxml/includes/libxml/uri.h,sha256=J9teJHme5z883c4twF5oImEYY-E3xSvhdSGpyRVtvIg,2855 +lxml/includes/libxml/valid.h,sha256=By61IbPvk_eLux7a8x0mOaly7oclFaSGaFE8b2xZcUE,13226 +lxml/includes/libxml/xinclude.h,sha256=K3I5jhw2zAMj26LuRNZc15Bwv2JE2hWxwVn4TCqv2b4,3258 +lxml/includes/libxml/xlink.h,sha256=TVLOkISrcKDelo9n_XIUyPiStDYa8NxuF2dz70aBFCI,5062 +lxml/includes/libxml/xmlIO.h,sha256=FvbuMYTy1-S5PScabE03wz0oWKf626pmXvOPZNuLm-w,11948 +lxml/includes/libxml/xmlautomata.h,sha256=7Sc3YgPz1ZIBKCHPSxs5oAwJEZWQ1RT2kyUw85pUtmU,4004 +lxml/includes/libxml/xmlerror.h,sha256=mMfltMxUza6kiSBfP2QfnY3UlMP_rEXKfX0wruBLl4A,37561 +lxml/includes/libxml/xmlexports.h,sha256=IyV3AMeQVbOl0wkjlnNX4B8WUZ-5GNKQmxZc6-maWUU,2025 +lxml/includes/libxml/xmlmemory.h,sha256=m7wGvVMxNzZiuOAo3vkjxaVWstc8aQLzb6obbjPsebE,4658 +lxml/includes/libxml/xmlmodule.h,sha256=ERUHUmDdZRmh6NjLYWUpse51rLWR8qNjPHOtdgmlLF0,1198 +lxml/includes/libxml/xmlreader.h,sha256=BAHinlSOTXX3DEax9BniaIIPAXJyLGfzym9R-27LCcU,12387 +lxml/includes/libxml/xmlregexp.h,sha256=_q6C1XRy8DS3kSmLbEKpvkKQciTgjTJgGc_zUQ6m22M,2632 +lxml/includes/libxml/xmlsave.h,sha256=zcEQr9sO5CsFrnoOLshhdsqMEr8k4AeFhbkYyNfO9Fs,2934 +lxml/includes/libxml/xmlschemas.h,sha256=5AfLnYUcfmxHRzg0dVpdHig--4ui1-XDwDgpKGDKCiU,7067 +lxml/includes/libxml/xmlschemastypes.h,sha256=MYwlGmoKAo3lHRaaKgnCXiLmPT9KRjdxyCJ7TEyZ6jM,4583 +lxml/includes/libxml/xmlstring.h,sha256=d5PpqxP1I1sfmCUHvVJtjoC9h7hLHcAAQ5ok_Rtf50I,5271 +lxml/includes/libxml/xmlunicode.h,sha256=8sq3wEW2AiyTCuc3ZceOEkce7lfrI7VnkRfwEQgc6pU,278 +lxml/includes/libxml/xmlversion.h,sha256=oVpaE_xbttaeZNFKSuSfcLOceWz7LQgKP71Z1msXZNo,5112 +lxml/includes/libxml/xmlwriter.h,sha256=BEUwYNKx3xymDE9vepksEK7yVq9SXYm1d2pQnzlPy90,20688 +lxml/includes/libxml/xpath.h,sha256=CQv6X_pRhuXoCVpqoDXYB7FfusLK7AuPxCNigwhNYAA,16156 +lxml/includes/libxml/xpathInternals.h,sha256=mc9B5tdpfssyz_NPUzww6dKuWCtBybBiBRJkTe4AE4U,18504 +lxml/includes/libxml/xpointer.h,sha256=DAxMsfPp2SSZgXFrPbxBA84RwTMRf35Qg_LBbUzPQhA,1026 +lxml/includes/libxslt/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 +lxml/includes/libxslt/__pycache__/__init__.cpython-312.pyc,, +lxml/includes/libxslt/attributes.h,sha256=qKwzfGf7r89esLC65s96iYJWRA-s-Ezss2_V6Mmo1hk,957 +lxml/includes/libxslt/documents.h,sha256=kBihgH5pqRvFalhm_fOFHtJTFhTpBcm681yT5dxgwfw,2704 +lxml/includes/libxslt/extensions.h,sha256=W5UMyJqUP_1zt6sXZ0mgc0gAIwDJrZ8gjByhyrWqvd8,6899 +lxml/includes/libxslt/extra.h,sha256=6X3Wu3NdPtrlqz-Koo7dB-rccnnszi6j3zg599gTByg,1640 +lxml/includes/libxslt/functions.h,sha256=fc4CZj-9KeBHzO9-WWU_bNqmaEZAz3n7NNwClIBXk14,1972 +lxml/includes/libxslt/imports.h,sha256=18kIjoGqdFXR63Ce3ZtzxsTiYV3XGKpchYakMUPDuUI,1840 +lxml/includes/libxslt/keys.h,sha256=16v25VEluS7jYhgg6gYFwVxgGMn-1ctnlhhWWT4RcBY,1155 +lxml/includes/libxslt/namespaces.h,sha256=VofSn2Kkn-a5JyRKCmY3jPp7amQy3n09vzy0KUQt4q0,1666 +lxml/includes/libxslt/numbersInternals.h,sha256=Eg5gYZ5p3h0_e5wyI61S-0E6_ArVJzv0yr63j6BU2fc,2019 +lxml/includes/libxslt/pattern.h,sha256=tJ-BPfs9UYgiZMMoQZbhij3g7xVppYq7TrrOu25eR7Q,2110 +lxml/includes/libxslt/preproc.h,sha256=D_LjEdHhsdyBnEAvflnwFgoR4hGUb72kgEhXkkmPRsw,896 +lxml/includes/libxslt/security.h,sha256=fUD1cy_WxFCTvTNAF0WOQIU4p5CNWn1LHFyZJd-Fx5U,2652 +lxml/includes/libxslt/templates.h,sha256=bnt6Jqui6KU5pNUdMNPbQZkZ5d-VTWqC0TMGkOlVoIo,2268 +lxml/includes/libxslt/transform.h,sha256=ICT7meUV0OTAx27WaKVrKj-aUmR9LSpTNaOAJd2UStg,6311 +lxml/includes/libxslt/variables.h,sha256=cQAgPe4QCcK2uKbWg7Iz-9peM9xWGm7m3M6jQm0sjIA,3143 +lxml/includes/libxslt/xslt.h,sha256=wmFx2Q31Pd8Iq2phAQpY9J3QQatb8lWg3gABtqKFgEw,1964 +lxml/includes/libxslt/xsltInternals.h,sha256=2EbEKYmnYZq0HjGnUMAlpqnqZJurRXzjlgk5Js1WYaY,57949 +lxml/includes/libxslt/xsltconfig.h,sha256=cV5scdRK6xmOHeOg3OCw6hBfcQ_nrtNs_tKefX67304,2910 +lxml/includes/libxslt/xsltexports.h,sha256=1-luH-0bCIgBAlKAXhV-dqHBfwOAQNDamiYbxIlTf0k,1124 +lxml/includes/libxslt/xsltlocale.h,sha256=ppxGEmJfZIJgwRQzCM0_77p9WNekEWq1NrdYZrQl4IE,942 +lxml/includes/libxslt/xsltutils.h,sha256=1eguYgR9-jeNOVlBUktHboaq-VLX6JXraO80TfbARKM,9085 +lxml/includes/lxml-version.h,sha256=7Jwmesey8JmjKayzDkLgkjKNi77OGhOzjJ_JC41Oj_I,71 +lxml/includes/relaxng.pxd,sha256=HzHlQ6mCcf_tj_JZ9NAVJTVAv8ScCkE8Ifq15y3bS0c,2615 +lxml/includes/schematron.pxd,sha256=Hob7xh-K-MKqp7WiG8thMagf5EkQzmgfi4ds0EF91JA,1604 +lxml/includes/tree.pxd,sha256=XApzMRy_LSqCtQ-OTS-vNSW7CT_OWstybfIT2H84LsA,20179 +lxml/includes/uri.pxd,sha256=3vOXw6AbSPxAM9uo71T1qnfx-wd9ezXLDQtWsb2zX0I,145 +lxml/includes/xinclude.pxd,sha256=CuO_XZNB6E2JK1qXXWn11APrjFQV5kA6SMyb77WZn0A,804 +lxml/includes/xmlerror.pxd,sha256=OQqayytkV0NigAPbsQCCcvmy7luRe0XhVzpTdzJjP3g,58837 +lxml/includes/xmlparser.pxd,sha256=eDGyU5kZyNVksK0dUhMIi7rnE-LSevXsqyl72v99Ess,13730 +lxml/includes/xmlschema.pxd,sha256=OLZPd2WDJyopiXJJyo-dAyyYHaeSYFiMAI4tqIiv-Ik,1702 +lxml/includes/xpath.pxd,sha256=e8-ZYUbRG7N1mHETAlknJ_QqAteOosrYLRgpH-OsTkg,5603 +lxml/includes/xslt.pxd,sha256=4yl3pOu7pAvsx5Tc-W4IWCoB8wgtSSR62HI1jqu6jko,8241 +lxml/isoschematron/__init__.py,sha256=uauerYeKTlWFCJSqieIHhF5l6rYV2myeEJ0Imd1LzRc,13274 +lxml/isoschematron/__pycache__/__init__.cpython-312.pyc,, +lxml/isoschematron/resources/rng/iso-schematron.rng,sha256=VsWxPyi3iViJDDbjJJw0wWkEHkLrz9zoCA8zJLor9N4,18337 +lxml/isoschematron/resources/xsl/RNG2Schtrn.xsl,sha256=ObebsB8Wt-d3uIA_U5NU85TpnQ3PxPX38TdOAqosMac,3172 +lxml/isoschematron/resources/xsl/XSD2Schtrn.xsl,sha256=QweRrIIM-zFcgg98GXA2CaWfIbgVE0XKEeYSfvv67A0,4563 +lxml/isoschematron/resources/xsl/iso-schematron-xslt1/iso_abstract_expand.xsl,sha256=xSZ_Ekq_I-62ZpiE5AqYYHwFW_qh855zt9V4_s7rbkY,11703 +lxml/isoschematron/resources/xsl/iso-schematron-xslt1/iso_dsdl_include.xsl,sha256=x42QJ-dxQ1waPzydsCoQnp2Xj15y53nW43O7BuoDRHk,39957 +lxml/isoschematron/resources/xsl/iso-schematron-xslt1/iso_schematron_message.xsl,sha256=Tr9BnO6pzjVWwhqJfm10UlvAy95EgfSCz2iMlrVGT6Q,2015 +lxml/isoschematron/resources/xsl/iso-schematron-xslt1/iso_schematron_skeleton_for_xslt1.xsl,sha256=ue8q_88X4e_jsJizo31GRNBxNhdxkEE9fY20oq0Iqwk,71764 +lxml/isoschematron/resources/xsl/iso-schematron-xslt1/iso_svrl_for_xslt1.xsl,sha256=BBAdsVSi5zAzeGepuN6gS1saQINDqITXKplmmj4dTWg,20382 +lxml/isoschematron/resources/xsl/iso-schematron-xslt1/readme.txt,sha256=OGLiFswuLJEW5EPYKOeoauuCJFEtVa6jyzBE1OcJI98,3310 +lxml/iterparse.pxi,sha256=rERnJ9D0Cvn87YDMSq8V880DKV2WthXs5BXSSY2Baj4,16845 +lxml/lxml.etree.h,sha256=hWgHumv7DeTpH0nHhbsCIe693cIcimtLabExxwBxR-c,9792 +lxml/lxml.etree_api.h,sha256=O_s_RIicKl7_h5U0aKu9dKSzJCTYSDJI6jlX7b-2v0Q,17715 +lxml/nsclasses.pxi,sha256=5pzNBhBtlqObPdThL9QIGRs1Dxj1qnr0PyXuTCURqTg,9129 +lxml/objectify.cpython-312-x86_64-linux-gnu.so,sha256=iTDWB90lJBk_EVv0x5DwNpvB4cbOEqPDMKg-511TN5w,2916760 +lxml/objectify.pyx,sha256=8lIjgklL3LJ3PYAcWZ5hPLuLFa7oLqEspco4bSz4EI4,75850 +lxml/objectpath.pxi,sha256=s5TNG2-EbaWWKLFAiX303B95zK_Ui8ausB__3QvFFGw,11450 +lxml/parser.pxi,sha256=lw230Za_GcJe-CFfznB1As9MSEi0d6gsMKidQxUR-7E,85817 +lxml/parsertarget.pxi,sha256=v1PidxRaG5giwXcTDkpBI7PDFmsZuOcK0y9LdkQaY8M,6326 +lxml/proxy.pxi,sha256=LnqqSZVUsJ5MnuVrhYrLwMbAKWuv1eOn-XYslut6CLM,24015 +lxml/public-api.pxi,sha256=yVPuZV2LwN5Ki73mD85V9hnUXxOs5VMeFMLtQMNf0Qk,6756 +lxml/pyclasslookup.py,sha256=gLD1HM2HtITYYiGzjEOewSwbB7XkVx_NZv_quCt79Oc,92 +lxml/readonlytree.pxi,sha256=n2MRjz81EADWBXJx0miosN8PJ4i5y3guQJT_RZF6qKg,19008 +lxml/relaxng.pxi,sha256=3OQ-fZMzP-KF5vM6HTozT_9ee3J0DJnpj9RcHC8LoMw,6339 +lxml/sax.cpython-312-x86_64-linux-gnu.so,sha256=_is26aCl78oYdPU_iyAE_CxAfev44Uir8RK03edgTVk,182096 +lxml/sax.py,sha256=yrNvKD6rlon48jrR-1qpFXER8j4psYC2R5yt0u6TWLs,9706 +lxml/saxparser.pxi,sha256=yV67FWD60DfmFi7hbV_eLyJIMu7X2MOfV2g4X99iUR4,33538 +lxml/schematron.pxi,sha256=F2OHKZUl57-byBk_wWtPTnHZ1fwlj0FtwG3VuGtG-UY,6064 +lxml/serializer.pxi,sha256=iIXfechFHfvFs2sTk7wMIy3sDJxmaMPbNO33mkLLBUE,68063 +lxml/usedoctest.py,sha256=qRgZKQVcAZcl-zN0AIXVJnOsETUXz2nPXkxuzs1lGgk,230 +lxml/xinclude.pxi,sha256=7eBrI_OK47mmrHQ0ixbixRI8pKqQ1nwkMV-OmKUVlD4,2456 +lxml/xmlerror.pxi,sha256=MAQv92NXFoaMOzT8WeeM0L3YyksFoo48tpz3KZHTqcY,50234 +lxml/xmlid.pxi,sha256=5zf9oR6bsCtavGiOmilNyHqYwgG_bnrIabSd2SURtm0,6073 +lxml/xmlschema.pxi,sha256=mumNoHni5S3BQPtcmOHRd61KRaVWu4eOie2wQeB0e6E,8490 +lxml/xpath.pxi,sha256=aqW24V817dUxps4Gnc8h7Tm3QVlITKvxU5_9WgJUIFg,19132 +lxml/xslt.pxi,sha256=lLVwvlhMKL970rVqThP6Jnn79L3MK9UG0DGnXnNy7YI,36356 +lxml/xsltext.pxi,sha256=TImDiAPlAezC07P7RY1N9YChA7AuKFH-G53hXdel9yc,11088 diff --git a/presentation/.venv/lib/python3.12/site-packages/lxml-6.1.0.dist-info/WHEEL b/presentation/.venv/lib/python3.12/site-packages/lxml-6.1.0.dist-info/WHEEL new file mode 100644 index 0000000..43257e7 --- /dev/null +++ b/presentation/.venv/lib/python3.12/site-packages/lxml-6.1.0.dist-info/WHEEL @@ -0,0 +1,6 @@ +Wheel-Version: 1.0 +Generator: setuptools (82.0.1) +Root-Is-Purelib: false +Tag: cp312-cp312-manylinux_2_26_x86_64 +Tag: cp312-cp312-manylinux_2_28_x86_64 + diff --git a/presentation/.venv/lib/python3.12/site-packages/lxml-6.1.0.dist-info/licenses/LICENSE.txt b/presentation/.venv/lib/python3.12/site-packages/lxml-6.1.0.dist-info/licenses/LICENSE.txt new file mode 100644 index 0000000..0bdf039 --- /dev/null +++ b/presentation/.venv/lib/python3.12/site-packages/lxml-6.1.0.dist-info/licenses/LICENSE.txt @@ -0,0 +1,31 @@ +BSD 3-Clause License + +Copyright (c) 2004 Infrae. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + 1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + 2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in + the documentation and/or other materials provided with the + distribution. + + 3. Neither the name of Infrae nor the names of its contributors may + be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL INFRAE OR +CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/presentation/.venv/lib/python3.12/site-packages/lxml-6.1.0.dist-info/licenses/LICENSES.txt b/presentation/.venv/lib/python3.12/site-packages/lxml-6.1.0.dist-info/licenses/LICENSES.txt new file mode 100644 index 0000000..9f97c18 --- /dev/null +++ b/presentation/.venv/lib/python3.12/site-packages/lxml-6.1.0.dist-info/licenses/LICENSES.txt @@ -0,0 +1,29 @@ +lxml is copyright Infrae and distributed under the BSD license (see +doc/licenses/BSD.txt), with the following exceptions: + +Some code, such a selftest.py, selftest2.py and +src/lxml/_elementpath.py are derived from ElementTree and +cElementTree. See doc/licenses/elementtree.txt for the license text. + +lxml.cssselect and lxml.html are copyright Ian Bicking and distributed +under the BSD license (see doc/licenses/BSD.txt). + +test.py, the test-runner script, is GPL and copyright Shuttleworth +Foundation. See doc/licenses/GPL.txt. It is believed the unchanged +inclusion of test.py to run the unit test suite falls under the +"aggregation" clause of the GPL and thus does not affect the license +of the rest of the package. + +The isoschematron implementation uses several XSL and RelaxNG resources: + * The (XML syntax) RelaxNG schema for schematron, copyright International + Organization for Standardization (see + src/lxml/isoschematron/resources/rng/iso-schematron.rng for the license + text) + * The skeleton iso-schematron-xlt1 pure-xslt schematron implementation + xsl stylesheets, copyright Rick Jelliffe and Academia Sinica Computing + Center, Taiwan (see the xsl files here for the license text: + src/lxml/isoschematron/resources/xsl/iso-schematron-xslt1/) + * The xsd/rng schema schematron extraction xsl transformations are unlicensed + and copyright the respective authors as noted (see + src/lxml/isoschematron/resources/xsl/RNG2Schtrn.xsl and + src/lxml/isoschematron/resources/xsl/XSD2Schtrn.xsl) diff --git a/presentation/.venv/lib/python3.12/site-packages/lxml-6.1.0.dist-info/top_level.txt b/presentation/.venv/lib/python3.12/site-packages/lxml-6.1.0.dist-info/top_level.txt new file mode 100644 index 0000000..ab90481 --- /dev/null +++ b/presentation/.venv/lib/python3.12/site-packages/lxml-6.1.0.dist-info/top_level.txt @@ -0,0 +1 @@ +lxml diff --git a/presentation/.venv/lib/python3.12/site-packages/lxml/ElementInclude.py b/presentation/.venv/lib/python3.12/site-packages/lxml/ElementInclude.py new file mode 100644 index 0000000..2188433 --- /dev/null +++ b/presentation/.venv/lib/python3.12/site-packages/lxml/ElementInclude.py @@ -0,0 +1,244 @@ +# +# ElementTree +# $Id: ElementInclude.py 1862 2004-06-18 07:31:02Z Fredrik $ +# +# limited xinclude support for element trees +# +# history: +# 2003-08-15 fl created +# 2003-11-14 fl fixed default loader +# +# Copyright (c) 2003-2004 by Fredrik Lundh. All rights reserved. +# +# fredrik@pythonware.com +# http://www.pythonware.com +# +# -------------------------------------------------------------------- +# The ElementTree toolkit is +# +# Copyright (c) 1999-2004 by Fredrik Lundh +# +# By obtaining, using, and/or copying this software and/or its +# associated documentation, you agree that you have read, understood, +# and will comply with the following terms and conditions: +# +# Permission to use, copy, modify, and distribute this software and +# its associated documentation for any purpose and without fee is +# hereby granted, provided that the above copyright notice appears in +# all copies, and that both that copyright notice and this permission +# notice appear in supporting documentation, and that the name of +# Secret Labs AB or the author not be used in advertising or publicity +# pertaining to distribution of the software without specific, written +# prior permission. +# +# SECRET LABS AB AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD +# TO THIS SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANT- +# ABILITY AND FITNESS. IN NO EVENT SHALL SECRET LABS AB OR THE AUTHOR +# BE LIABLE FOR ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY +# DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, +# WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS +# ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE +# OF THIS SOFTWARE. +# -------------------------------------------------------------------- + +""" +Limited XInclude support for the ElementTree package. + +While lxml.etree has full support for XInclude (see +`etree.ElementTree.xinclude()`), this module provides a simpler, pure +Python, ElementTree compatible implementation that supports a simple +form of custom URL resolvers. +""" + +from lxml import etree +try: + from urlparse import urljoin + from urllib2 import urlopen +except ImportError: + # Python 3 + from urllib.parse import urljoin + from urllib.request import urlopen + +XINCLUDE = "{http://www.w3.org/2001/XInclude}" + +XINCLUDE_INCLUDE = XINCLUDE + "include" +XINCLUDE_FALLBACK = XINCLUDE + "fallback" +XINCLUDE_ITER_TAG = XINCLUDE + "*" + +# For security reasons, the inclusion depth is limited to this read-only value by default. +DEFAULT_MAX_INCLUSION_DEPTH = 6 + + +## +# Fatal include error. + +class FatalIncludeError(etree.LxmlSyntaxError): + pass + + +class LimitedRecursiveIncludeError(FatalIncludeError): + pass + + +## +# ET compatible default loader. +# This loader reads an included resource from disk. +# +# @param href Resource reference. +# @param parse Parse mode. Either "xml" or "text". +# @param encoding Optional text encoding. +# @return The expanded resource. If the parse mode is "xml", this +# is an ElementTree instance. If the parse mode is "text", this +# is a Unicode string. If the loader fails, it can return None +# or raise an IOError exception. +# @throws IOError If the loader fails to load the resource. + +def default_loader(href, parse, encoding=None): + file = open(href, 'rb') + if parse == "xml": + data = etree.parse(file).getroot() + else: + data = file.read() + if not encoding: + encoding = 'utf-8' + data = data.decode(encoding) + file.close() + return data + + +## +# Default loader used by lxml.etree - handles custom resolvers properly +# + +def _lxml_default_loader(href, parse, encoding=None, parser=None): + if parse == "xml": + data = etree.parse(href, parser).getroot() + else: + if "://" in href: + f = urlopen(href) + else: + f = open(href, 'rb') + data = f.read() + f.close() + if not encoding: + encoding = 'utf-8' + data = data.decode(encoding) + return data + + +## +# Wrapper for ET compatibility - drops the parser + +def _wrap_et_loader(loader): + def load(href, parse, encoding=None, parser=None): + return loader(href, parse, encoding) + return load + + +## +# Expand XInclude directives. +# +# @param elem Root element. +# @param loader Optional resource loader. If omitted, it defaults +# to {@link default_loader}. If given, it should be a callable +# that implements the same interface as <b>default_loader</b>. +# @param base_url The base URL of the original file, to resolve +# relative include file references. +# @param max_depth The maximum number of recursive inclusions. +# Limited to reduce the risk of malicious content explosion. +# Pass None to disable the limitation. +# @throws LimitedRecursiveIncludeError If the {@link max_depth} was exceeded. +# @throws FatalIncludeError If the function fails to include a given +# resource, or if the tree contains malformed XInclude elements. +# @throws IOError If the function fails to load a given resource. +# @returns the node or its replacement if it was an XInclude node + +def include(elem, loader=None, base_url=None, + max_depth=DEFAULT_MAX_INCLUSION_DEPTH): + if max_depth is None: + max_depth = -1 + elif max_depth < 0: + raise ValueError("expected non-negative depth or None for 'max_depth', got %r" % max_depth) + + if base_url is None: + if hasattr(elem, 'getroot'): + tree = elem + elem = elem.getroot() + else: + tree = elem.getroottree() + if hasattr(tree, 'docinfo'): + base_url = tree.docinfo.URL + elif hasattr(elem, 'getroot'): + elem = elem.getroot() + _include(elem, loader, base_url, max_depth) + + +def _include(elem, loader=None, base_url=None, + max_depth=DEFAULT_MAX_INCLUSION_DEPTH, _parent_hrefs=None): + if loader is not None: + load_include = _wrap_et_loader(loader) + else: + load_include = _lxml_default_loader + + if _parent_hrefs is None: + _parent_hrefs = set() + + parser = elem.getroottree().parser + + include_elements = list( + elem.iter(XINCLUDE_ITER_TAG)) + + for e in include_elements: + if e.tag == XINCLUDE_INCLUDE: + # process xinclude directive + href = urljoin(base_url, e.get("href")) + parse = e.get("parse", "xml") + parent = e.getparent() + if parse == "xml": + if href in _parent_hrefs: + raise FatalIncludeError( + "recursive include of %r detected" % href + ) + if max_depth == 0: + raise LimitedRecursiveIncludeError( + "maximum xinclude depth reached when including file %s" % href) + node = load_include(href, parse, parser=parser) + if node is None: + raise FatalIncludeError( + "cannot load %r as %r" % (href, parse) + ) + node = _include(node, loader, href, max_depth - 1, {href} | _parent_hrefs) + if e.tail: + node.tail = (node.tail or "") + e.tail + if parent is None: + return node # replaced the root node! + parent.replace(e, node) + elif parse == "text": + text = load_include(href, parse, encoding=e.get("encoding")) + if text is None: + raise FatalIncludeError( + "cannot load %r as %r" % (href, parse) + ) + predecessor = e.getprevious() + if predecessor is not None: + predecessor.tail = (predecessor.tail or "") + text + elif parent is None: + return text # replaced the root node! + else: + parent.text = (parent.text or "") + text + (e.tail or "") + parent.remove(e) + else: + raise FatalIncludeError( + "unknown parse type in xi:include tag (%r)" % parse + ) + elif e.tag == XINCLUDE_FALLBACK: + parent = e.getparent() + if parent is not None and parent.tag != XINCLUDE_INCLUDE: + raise FatalIncludeError( + "xi:fallback tag must be child of xi:include (%r)" % e.tag + ) + else: + raise FatalIncludeError( + "Invalid element found in XInclude namespace (%r)" % e.tag + ) + return elem diff --git a/presentation/.venv/lib/python3.12/site-packages/lxml/__init__.py b/presentation/.venv/lib/python3.12/site-packages/lxml/__init__.py new file mode 100644 index 0000000..873696e --- /dev/null +++ b/presentation/.venv/lib/python3.12/site-packages/lxml/__init__.py @@ -0,0 +1,22 @@ +# this is a package + +__version__ = "6.1.0" + + +def get_include(): + """ + Returns a list of header include paths (for lxml itself, libxml2 + and libxslt) needed to compile C code against lxml if it was built + with statically linked libraries. + """ + import os + lxml_path = __path__[0] + include_path = os.path.join(lxml_path, 'includes') + includes = [include_path, lxml_path] + + for name in os.listdir(include_path): + path = os.path.join(include_path, name) + if os.path.isdir(path): + includes.append(path) + + return includes diff --git a/presentation/.venv/lib/python3.12/site-packages/lxml/__pycache__/ElementInclude.cpython-312.pyc b/presentation/.venv/lib/python3.12/site-packages/lxml/__pycache__/ElementInclude.cpython-312.pyc new file mode 100644 index 0000000..18b79df Binary files /dev/null and b/presentation/.venv/lib/python3.12/site-packages/lxml/__pycache__/ElementInclude.cpython-312.pyc differ diff --git a/presentation/.venv/lib/python3.12/site-packages/lxml/__pycache__/__init__.cpython-312.pyc b/presentation/.venv/lib/python3.12/site-packages/lxml/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..6883e79 Binary files /dev/null and b/presentation/.venv/lib/python3.12/site-packages/lxml/__pycache__/__init__.cpython-312.pyc differ diff --git a/presentation/.venv/lib/python3.12/site-packages/lxml/__pycache__/_elementpath.cpython-312.pyc b/presentation/.venv/lib/python3.12/site-packages/lxml/__pycache__/_elementpath.cpython-312.pyc new file mode 100644 index 0000000..a52c29e Binary files /dev/null and b/presentation/.venv/lib/python3.12/site-packages/lxml/__pycache__/_elementpath.cpython-312.pyc differ diff --git a/presentation/.venv/lib/python3.12/site-packages/lxml/__pycache__/builder.cpython-312.pyc b/presentation/.venv/lib/python3.12/site-packages/lxml/__pycache__/builder.cpython-312.pyc new file mode 100644 index 0000000..c6602f8 Binary files /dev/null and b/presentation/.venv/lib/python3.12/site-packages/lxml/__pycache__/builder.cpython-312.pyc differ diff --git a/presentation/.venv/lib/python3.12/site-packages/lxml/__pycache__/cssselect.cpython-312.pyc b/presentation/.venv/lib/python3.12/site-packages/lxml/__pycache__/cssselect.cpython-312.pyc new file mode 100644 index 0000000..6530e70 Binary files /dev/null and b/presentation/.venv/lib/python3.12/site-packages/lxml/__pycache__/cssselect.cpython-312.pyc differ diff --git a/presentation/.venv/lib/python3.12/site-packages/lxml/__pycache__/doctestcompare.cpython-312.pyc b/presentation/.venv/lib/python3.12/site-packages/lxml/__pycache__/doctestcompare.cpython-312.pyc new file mode 100644 index 0000000..8f6493d Binary files /dev/null and b/presentation/.venv/lib/python3.12/site-packages/lxml/__pycache__/doctestcompare.cpython-312.pyc differ diff --git a/presentation/.venv/lib/python3.12/site-packages/lxml/__pycache__/pyclasslookup.cpython-312.pyc b/presentation/.venv/lib/python3.12/site-packages/lxml/__pycache__/pyclasslookup.cpython-312.pyc new file mode 100644 index 0000000..6825ae9 Binary files /dev/null and b/presentation/.venv/lib/python3.12/site-packages/lxml/__pycache__/pyclasslookup.cpython-312.pyc differ diff --git a/presentation/.venv/lib/python3.12/site-packages/lxml/__pycache__/sax.cpython-312.pyc b/presentation/.venv/lib/python3.12/site-packages/lxml/__pycache__/sax.cpython-312.pyc new file mode 100644 index 0000000..67503e1 Binary files /dev/null and b/presentation/.venv/lib/python3.12/site-packages/lxml/__pycache__/sax.cpython-312.pyc differ diff --git a/presentation/.venv/lib/python3.12/site-packages/lxml/__pycache__/usedoctest.cpython-312.pyc b/presentation/.venv/lib/python3.12/site-packages/lxml/__pycache__/usedoctest.cpython-312.pyc new file mode 100644 index 0000000..3ee1328 Binary files /dev/null and b/presentation/.venv/lib/python3.12/site-packages/lxml/__pycache__/usedoctest.cpython-312.pyc differ diff --git a/presentation/.venv/lib/python3.12/site-packages/lxml/_elementpath.cpython-312-x86_64-linux-gnu.so b/presentation/.venv/lib/python3.12/site-packages/lxml/_elementpath.cpython-312-x86_64-linux-gnu.so new file mode 100755 index 0000000..3e602bb Binary files /dev/null and b/presentation/.venv/lib/python3.12/site-packages/lxml/_elementpath.cpython-312-x86_64-linux-gnu.so differ diff --git a/presentation/.venv/lib/python3.12/site-packages/lxml/_elementpath.py b/presentation/.venv/lib/python3.12/site-packages/lxml/_elementpath.py new file mode 100644 index 0000000..760a1e0 --- /dev/null +++ b/presentation/.venv/lib/python3.12/site-packages/lxml/_elementpath.py @@ -0,0 +1,343 @@ +# cython: language_level=3 + +# +# ElementTree +# $Id: ElementPath.py 3375 2008-02-13 08:05:08Z fredrik $ +# +# limited xpath support for element trees +# +# history: +# 2003-05-23 fl created +# 2003-05-28 fl added support for // etc +# 2003-08-27 fl fixed parsing of periods in element names +# 2007-09-10 fl new selection engine +# 2007-09-12 fl fixed parent selector +# 2007-09-13 fl added iterfind; changed findall to return a list +# 2007-11-30 fl added namespaces support +# 2009-10-30 fl added child element value filter +# +# Copyright (c) 2003-2009 by Fredrik Lundh. All rights reserved. +# +# fredrik@pythonware.com +# http://www.pythonware.com +# +# -------------------------------------------------------------------- +# The ElementTree toolkit is +# +# Copyright (c) 1999-2009 by Fredrik Lundh +# +# By obtaining, using, and/or copying this software and/or its +# associated documentation, you agree that you have read, understood, +# and will comply with the following terms and conditions: +# +# Permission to use, copy, modify, and distribute this software and +# its associated documentation for any purpose and without fee is +# hereby granted, provided that the above copyright notice appears in +# all copies, and that both that copyright notice and this permission +# notice appear in supporting documentation, and that the name of +# Secret Labs AB or the author not be used in advertising or publicity +# pertaining to distribution of the software without specific, written +# prior permission. +# +# SECRET LABS AB AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD +# TO THIS SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANT- +# ABILITY AND FITNESS. IN NO EVENT SHALL SECRET LABS AB OR THE AUTHOR +# BE LIABLE FOR ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY +# DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, +# WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS +# ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE +# OF THIS SOFTWARE. +# -------------------------------------------------------------------- + +## +# Implementation module for XPath support. There's usually no reason +# to import this module directly; the <b>ElementTree</b> does this for +# you, if needed. +## + + +import re + +xpath_tokenizer_re = re.compile( + "(" + "'[^']*'|\"[^\"]*\"|" + "::|" + "//?|" + r"\.\.|" + r"\(\)|" + r"[/.*:\[\]\(\)@=])|" + r"((?:\{[^}]+\})?[^/\[\]\(\)@=\s]+)|" + r"\s+" + ) + +def xpath_tokenizer(pattern, namespaces=None, with_prefixes=True): + # ElementTree uses '', lxml used None originally. + default_namespace = (namespaces.get(None) or namespaces.get('')) if namespaces else None + parsing_attribute = False + for token in xpath_tokenizer_re.findall(pattern): + ttype, tag = token + if tag and tag[0] != "{": + if ":" in tag and with_prefixes: + prefix, uri = tag.split(":", 1) + try: + if not namespaces: + raise KeyError + yield ttype, "{%s}%s" % (namespaces[prefix], uri) + except KeyError: + raise SyntaxError("prefix %r not found in prefix map" % prefix) + elif tag.isdecimal(): + yield token # index + elif default_namespace and not parsing_attribute: + yield ttype, "{%s}%s" % (default_namespace, tag) + else: + yield token + parsing_attribute = False + else: + yield token + parsing_attribute = ttype == '@' + + +def prepare_child(next, token): + tag = token[1] + def select(result): + for elem in result: + yield from elem.iterchildren(tag) + return select + +def prepare_star(next, token): + def select(result): + for elem in result: + yield from elem.iterchildren('*') + return select + +def prepare_self(next, token): + def select(result): + return result + return select + +def prepare_descendant(next, token): + token = next() + if token[0] == "*": + tag = "*" + elif not token[0]: + tag = token[1] + else: + raise SyntaxError("invalid descendant") + def select(result): + for elem in result: + yield from elem.iterdescendants(tag) + return select + +def prepare_parent(next, token): + def select(result): + for elem in result: + parent = elem.getparent() + if parent is not None: + yield parent + return select + +def prepare_predicate(next, token): + # FIXME: replace with real parser!!! refs: + # http://effbot.org/zone/simple-iterator-parser.htm + # http://javascript.crockford.com/tdop/tdop.html + signature = '' + predicate = [] + while 1: + token = next() + if token[0] == "]": + break + if token == ('', ''): + # ignore whitespace + continue + if token[0] and token[0][:1] in "'\"": + token = "'", token[0][1:-1] + signature += token[0] or "-" + predicate.append(token[1]) + + # use signature to determine predicate type + if signature == "@-": + # [@attribute] predicate + key = predicate[1] + def select(result): + for elem in result: + if elem.get(key) is not None: + yield elem + return select + if signature == "@-='": + # [@attribute='value'] + key = predicate[1] + value = predicate[-1] + def select(result): + for elem in result: + if elem.get(key) == value: + yield elem + return select + if signature == "-" and not re.match(r"-?\d+$", predicate[0]): + # [tag] + tag = predicate[0] + def select(result): + for elem in result: + for _ in elem.iterchildren(tag): + yield elem + break + return select + if signature == ".='" or (signature == "-='" and not re.match(r"-?\d+$", predicate[0])): + # [.='value'] or [tag='value'] + tag = predicate[0] + value = predicate[-1] + if tag: + def select(result): + for elem in result: + for e in elem.iterchildren(tag): + if "".join(e.itertext()) == value: + yield elem + break + else: + def select(result): + for elem in result: + if "".join(elem.itertext()) == value: + yield elem + return select + if signature == "-" or signature == "-()" or signature == "-()-": + # [index] or [last()] or [last()-index] + if signature == "-": + # [index] + index = int(predicate[0]) - 1 + if index < 0: + if index == -1: + raise SyntaxError( + "indices in path predicates are 1-based, not 0-based") + else: + raise SyntaxError("path index >= 1 expected") + else: + if predicate[0] != "last": + raise SyntaxError("unsupported function") + if signature == "-()-": + try: + index = int(predicate[2]) - 1 + except ValueError: + raise SyntaxError("unsupported expression") + else: + index = -1 + def select(result): + for elem in result: + parent = elem.getparent() + if parent is None: + continue + try: + # FIXME: what if the selector is "*" ? + elems = list(parent.iterchildren(elem.tag)) + if elems[index] is elem: + yield elem + except IndexError: + pass + return select + raise SyntaxError("invalid predicate") + +ops = { + "": prepare_child, + "*": prepare_star, + ".": prepare_self, + "..": prepare_parent, + "//": prepare_descendant, + "[": prepare_predicate, +} + + +# -------------------------------------------------------------------- + +_cache = {} + + +def _build_path_iterator(path, namespaces, with_prefixes=True): + """compile selector pattern""" + if path[-1:] == "/": + path += "*" # implicit all (FIXME: keep this?) + + cache_key = (path,) + if namespaces: + # lxml originally used None for the default namespace but ElementTree uses the + # more convenient (all-strings-dict) empty string, so we support both here, + # preferring the more convenient '', as long as they aren't ambiguous. + if None in namespaces: + if '' in namespaces and namespaces[None] != namespaces['']: + raise ValueError("Ambiguous default namespace provided: %r versus %r" % ( + namespaces[None], namespaces[''])) + cache_key += (namespaces[None],) + tuple(sorted( + item for item in namespaces.items() if item[0] is not None)) + else: + cache_key += tuple(sorted(namespaces.items())) + + try: + return _cache[cache_key] + except KeyError: + pass + if len(_cache) > 100: + _cache.clear() + + if path[:1] == "/": + raise SyntaxError("cannot use absolute path on element") + stream = iter(xpath_tokenizer(path, namespaces, with_prefixes=with_prefixes)) + try: + _next = stream.next + except AttributeError: + # Python 3 + _next = stream.__next__ + try: + token = _next() + except StopIteration: + raise SyntaxError("empty path expression") + selector = [] + while 1: + try: + selector.append(ops[token[0]](_next, token)) + except StopIteration: + raise SyntaxError("invalid path") + try: + token = _next() + if token[0] == "/": + token = _next() + except StopIteration: + break + _cache[cache_key] = selector + return selector + + +## +# Iterate over the matching nodes + +def iterfind(elem, path, namespaces=None, with_prefixes=True): + selector = _build_path_iterator(path, namespaces, with_prefixes=with_prefixes) + result = iter((elem,)) + for select in selector: + result = select(result) + return result + + +## +# Find first matching object. + +def find(elem, path, namespaces=None, with_prefixes=True): + it = iterfind(elem, path, namespaces, with_prefixes=with_prefixes) + try: + return next(it) + except StopIteration: + return None + + +## +# Find all matching objects. + +def findall(elem, path, namespaces=None, with_prefixes=True): + return list(iterfind(elem, path, namespaces)) + + +## +# Find text for first matching object. + +def findtext(elem, path, default=None, namespaces=None, with_prefixes=True): + el = find(elem, path, namespaces, with_prefixes=with_prefixes) + if el is None: + return default + else: + return el.text or '' diff --git a/presentation/.venv/lib/python3.12/site-packages/lxml/apihelpers.pxi b/presentation/.venv/lib/python3.12/site-packages/lxml/apihelpers.pxi new file mode 100644 index 0000000..87a27d9 --- /dev/null +++ b/presentation/.venv/lib/python3.12/site-packages/lxml/apihelpers.pxi @@ -0,0 +1,1819 @@ +# Private/public helper functions for API functions + +from lxml.includes cimport uri + + +cdef void displayNode(xmlNode* c_node, indent) noexcept: + # to help with debugging + cdef xmlNode* c_child + try: + print(indent * ' ', <long>c_node) + c_child = c_node.children + while c_child is not NULL: + displayNode(c_child, indent + 1) + c_child = c_child.next + finally: + return # swallow any exceptions + +cdef inline bint _isHtmlDocument(_Element element) except -1: + cdef xmlNode* c_node = element._c_node + return ( + c_node is not NULL and c_node.doc is not NULL and + c_node.doc.properties & tree.XML_DOC_HTML != 0 + ) + +cdef inline int _assertValidNode(_Element element) except -1: + assert element._c_node is not NULL, "invalid Element proxy at %s" % id(element) + +cdef inline int _assertValidDoc(_Document doc) except -1: + assert doc._c_doc is not NULL, "invalid Document proxy at %s" % id(doc) + +cdef _Document _documentOrRaise(object input): + """Call this to get the document of a _Document, _ElementTree or _Element + object, or to raise an exception if it can't be determined. + + Should be used in all API functions for consistency. + """ + cdef _Document doc + if isinstance(input, _ElementTree): + if (<_ElementTree>input)._context_node is not None: + doc = (<_ElementTree>input)._context_node._doc + else: + doc = None + elif isinstance(input, _Element): + doc = (<_Element>input)._doc + elif isinstance(input, _Document): + doc = <_Document>input + else: + raise TypeError, f"Invalid input object: {python._fqtypename(input).decode('utf8')}" + if doc is None: + raise ValueError, f"Input object has no document: {python._fqtypename(input).decode('utf8')}" + _assertValidDoc(doc) + return doc + +cdef _Element _rootNodeOrRaise(object input): + """Call this to get the root node of a _Document, _ElementTree or + _Element object, or to raise an exception if it can't be determined. + + Should be used in all API functions for consistency. + """ + cdef _Element node + if isinstance(input, _ElementTree): + node = (<_ElementTree>input)._context_node + elif isinstance(input, _Element): + node = <_Element>input + elif isinstance(input, _Document): + node = (<_Document>input).getroot() + else: + raise TypeError, f"Invalid input object: {python._fqtypename(input).decode('utf8')}" + if (node is None or not node._c_node or + node._c_node.type != tree.XML_ELEMENT_NODE): + raise ValueError, f"Input object is not an XML element: {python._fqtypename(input).decode('utf8')}" + _assertValidNode(node) + return node + +cdef bint _isAncestorOrSame(xmlNode* c_ancestor, xmlNode* c_node) noexcept: + while c_node: + if c_node is c_ancestor: + return True + c_node = c_node.parent + return False + +cdef _Element _makeElement(tag, xmlDoc* c_doc, _Document doc, + _BaseParser parser, text, tail, attrib, nsmap, + dict extra_attrs): + """Create a new element and initialize text content, namespaces and + attributes. + + This helper function will reuse as much of the existing document as + possible: + + If 'parser' is None, the parser will be inherited from 'doc' or the + default parser will be used. + + If 'doc' is None, 'c_doc' is used to create a new _Document and the new + element is made its root node. + + If 'c_doc' is also NULL, a new xmlDoc will be created. + """ + cdef xmlNode* c_node + if doc is not None: + c_doc = doc._c_doc + ns_utf, name_utf = _getNsTag(tag) + if parser is not None and parser._for_html: + _htmlTagValidOrRaise(name_utf) + if c_doc is NULL: + c_doc = _newHTMLDoc() + else: + _tagValidOrRaise(name_utf) + if c_doc is NULL: + c_doc = _newXMLDoc() + c_node = _createElement(c_doc, name_utf) + if c_node is NULL: + if doc is None and c_doc is not NULL: + tree.xmlFreeDoc(c_doc) + raise MemoryError() + try: + if doc is None: + tree.xmlDocSetRootElement(c_doc, c_node) + doc = _documentFactory(c_doc, parser) + if text is not None: + _setNodeText(c_node, text) + if tail is not None: + _setTailText(c_node, tail) + # add namespaces to node if necessary + _setNodeNamespaces(c_node, doc, ns_utf, nsmap) + _initNodeAttributes(c_node, doc, attrib, extra_attrs) + return _elementFactory(doc, c_node) + except: + # free allocated c_node/c_doc unless Python does it for us + if c_node.doc is not c_doc: + # node not yet in document => will not be freed by document + if tail is not None: + _removeText(c_node.next) # tail + tree.xmlFreeNode(c_node) + if doc is None: + # c_doc will not be freed by doc + tree.xmlFreeDoc(c_doc) + raise + +cdef int _initNewElement(_Element element, bint is_html, name_utf, ns_utf, + _BaseParser parser, attrib, nsmap, dict extra_attrs) except -1: + """Initialise a new Element object. + + This is used when users instantiate a Python Element subclass + directly, without it being mapped to an existing XML node. + """ + cdef xmlDoc* c_doc + cdef xmlNode* c_node + cdef _Document doc + if is_html: + _htmlTagValidOrRaise(name_utf) + c_doc = _newHTMLDoc() + else: + _tagValidOrRaise(name_utf) + c_doc = _newXMLDoc() + c_node = _createElement(c_doc, name_utf) + if c_node is NULL: + if c_doc is not NULL: + tree.xmlFreeDoc(c_doc) + raise MemoryError() + tree.xmlDocSetRootElement(c_doc, c_node) + doc = _documentFactory(c_doc, parser) + # add namespaces to node if necessary + _setNodeNamespaces(c_node, doc, ns_utf, nsmap) + _initNodeAttributes(c_node, doc, attrib, extra_attrs) + _registerProxy(element, doc, c_node) + element._init() + return 0 + +cdef _Element _makeSubElement(_Element parent, tag, text, tail, + attrib, nsmap, dict extra_attrs): + """Create a new child element and initialize text content, namespaces and + attributes. + """ + cdef xmlNode* c_node + cdef xmlDoc* c_doc + if parent is None or parent._doc is None: + return None + _assertValidNode(parent) + ns_utf, name_utf = _getNsTag(tag) + c_doc = parent._doc._c_doc + + if parent._doc._parser is not None and parent._doc._parser._for_html: + _htmlTagValidOrRaise(name_utf) + else: + _tagValidOrRaise(name_utf) + + c_node = _createElement(c_doc, name_utf) + if c_node is NULL: + raise MemoryError() + tree.xmlAddChild(parent._c_node, c_node) + + try: + if text is not None: + _setNodeText(c_node, text) + if tail is not None: + _setTailText(c_node, tail) + + # add namespaces to node if necessary + _setNodeNamespaces(c_node, parent._doc, ns_utf, nsmap) + _initNodeAttributes(c_node, parent._doc, attrib, extra_attrs) + return _elementFactory(parent._doc, c_node) + except: + # make sure we clean up in case of an error + _removeNode(parent._doc, c_node) + raise + + +cdef int _setNodeNamespaces(xmlNode* c_node, _Document doc, + object node_ns_utf, object nsmap) except -1: + """Lookup current namespace prefixes, then set namespace structure for + node (if 'node_ns_utf' was provided) and register new ns-prefix mappings. + + 'node_ns_utf' should only be passed for a newly created node. + """ + cdef xmlNs* c_ns + cdef list nsdefs + + if nsmap: + for prefix, href in _iter_nsmap(nsmap): + href_utf = _utf8(href) + _uriValidOrRaise(href_utf) + c_href = _xcstr(href_utf) + if prefix is not None: + prefix_utf = _utf8(prefix) + _prefixValidOrRaise(prefix_utf) + c_prefix = _xcstr(prefix_utf) + else: + c_prefix = <const_xmlChar*>NULL + # add namespace with prefix if it is not already known + c_ns = tree.xmlSearchNs(doc._c_doc, c_node, c_prefix) + if c_ns is NULL or \ + c_ns.href is NULL or \ + tree.xmlStrcmp(c_ns.href, c_href) != 0: + c_ns = tree.xmlNewNs(c_node, c_href, c_prefix) + if c_ns is NULL: + # libxml2 has two error conditions: "out of memory" and "prefix exists already". + # We ignore the latter for compatibility reasons. It currently only appears + # during namespace cleanup. + c_ns = c_node.nsDef + while c_ns is not NULL: + if c_prefix is NULL: + if c_ns.prefix is NULL: + break + elif tree.xmlStrcmp(c_ns.prefix, c_prefix) == 0: + break + c_ns = c_ns.next + else: + raise MemoryError() + if href_utf == node_ns_utf: + tree.xmlSetNs(c_node, c_ns) + node_ns_utf = None + + if node_ns_utf is not None: + _uriValidOrRaise(node_ns_utf) + doc._setNodeNs(c_node, _xcstr(node_ns_utf)) + return 0 + + +cdef dict _build_nsmap(xmlNode* c_node): + """ + Namespace prefix->URI mapping known in the context of this Element. + This includes all namespace declarations of the parents. + """ + cdef xmlNs* c_ns + nsmap = {} + while c_node is not NULL and c_node.type == tree.XML_ELEMENT_NODE: + c_ns = c_node.nsDef + while c_ns is not NULL: + if c_ns.prefix or c_ns.href: + prefix = funicodeOrNone(c_ns.prefix) + if prefix not in nsmap: + nsmap[prefix] = funicodeOrNone(c_ns.href) + c_ns = c_ns.next + c_node = c_node.parent + return nsmap + + +cdef _iter_nsmap(nsmap): + """ + Create a reproducibly ordered iterable from an nsmap mapping. + Tries to preserve an existing order and sorts if it assumes no order. + + The difference to _iter_attrib() is that None doesn't sort with strings + in Py3.x. + """ + if isinstance(nsmap, dict): + # dicts are insertion-ordered in Py3.6+ => keep the user provided order. + return nsmap.items() + if len(nsmap) <= 1: + return nsmap.items() + if isinstance(nsmap, OrderedDict): + return nsmap.items() # keep existing order + if None not in nsmap: + return sorted(nsmap.items()) + + # Move the default namespace to the end. This makes sure libxml2 + # prefers a prefix if the ns is defined redundantly on the same + # element. That way, users can work around a problem themselves + # where default namespace attributes on non-default namespaced + # elements serialise without prefix (i.e. into the non-default + # namespace). + default_ns = nsmap[None] + nsdefs = [(k, v) for k, v in nsmap.items() if k is not None] + nsdefs.sort() + nsdefs.append((None, default_ns)) + return nsdefs + + +cdef _iter_attrib(attrib): + """ + Create a reproducibly ordered iterable from an attrib mapping. + Tries to preserve an existing order and sorts if it assumes no order. + """ + # dicts are insertion-ordered in Py3.6+ => keep the user provided order. + if isinstance(attrib, (dict, _Attrib, OrderedDict)): + return attrib.items() + # assume it's an unordered mapping of some kind + return sorted(attrib.items()) + + +cdef _initNodeAttributes(xmlNode* c_node, _Document doc, attrib, dict extra): + """Initialise the attributes of an element node. + """ + cdef bint is_html + cdef xmlNs* c_ns + if attrib is not None and not hasattr(attrib, 'items'): + raise TypeError, f"Invalid attribute dictionary: {python._fqtypename(attrib).decode('utf8')}" + if not attrib and not extra: + return # nothing to do + is_html = doc._parser._for_html + seen = set() + if extra: + for name, value in extra.items(): + _addAttributeToNode(c_node, doc, is_html, name, value, seen) + if attrib: + for name, value in _iter_attrib(attrib): + _addAttributeToNode(c_node, doc, is_html, name, value, seen) + + +cdef int _addAttributeToNode(xmlNode* c_node, _Document doc, bint is_html, + name, value, set seen_tags) except -1: + ns_utf, name_utf = tag = _getNsTag(name) + if tag in seen_tags: + return 0 + seen_tags.add(tag) + if not is_html: + _attributeValidOrRaise(name_utf) + value_utf = _utf8(value) + if ns_utf is None: + new_attr = tree.xmlNewProp(c_node, _xcstr(name_utf), _xcstr(value_utf)) + else: + _uriValidOrRaise(ns_utf) + c_ns = doc._findOrBuildNodeNs(c_node, _xcstr(ns_utf), NULL, 1) + new_attr = tree.xmlNewNsProp(c_node, c_ns, _xcstr(name_utf), _xcstr(value_utf)) + if new_attr is NULL: + raise MemoryError() + return 0 + + +ctypedef struct _ns_node_ref: + xmlNs* ns + xmlNode* node + + +cdef int _collectNsDefs(xmlNode* c_element, _ns_node_ref **_c_ns_list, + size_t *_c_ns_list_len, size_t *_c_ns_list_size) except -1: + c_ns_list = _c_ns_list[0] + cdef size_t c_ns_list_len = _c_ns_list_len[0] + cdef size_t c_ns_list_size = _c_ns_list_size[0] + + c_nsdef = c_element.nsDef + while c_nsdef is not NULL: + if c_ns_list_len >= c_ns_list_size: + if c_ns_list is NULL: + c_ns_list_size = 20 + else: + c_ns_list_size *= 2 + c_nsref_ptr = <_ns_node_ref*> python.lxml_realloc( + c_ns_list, c_ns_list_size, sizeof(_ns_node_ref)) + if c_nsref_ptr is NULL: + if c_ns_list is not NULL: + python.lxml_free(c_ns_list) + _c_ns_list[0] = NULL + raise MemoryError() + c_ns_list = c_nsref_ptr + + c_ns_list[c_ns_list_len] = _ns_node_ref(c_nsdef, c_element) + c_ns_list_len += 1 + c_nsdef = c_nsdef.next + + _c_ns_list_size[0] = c_ns_list_size + _c_ns_list_len[0] = c_ns_list_len + _c_ns_list[0] = c_ns_list + + +cdef int _removeUnusedNamespaceDeclarations(xmlNode* c_element, set prefixes_to_keep) except -1: + """Remove any namespace declarations from a subtree that are not used by + any of its elements (or attributes). + + If a 'prefixes_to_keep' is provided, it must be a set of prefixes. + Any corresponding namespace mappings will not be removed as part of the cleanup. + """ + cdef xmlNode* c_node + cdef _ns_node_ref* c_ns_list = NULL + cdef size_t c_ns_list_size = 0 + cdef size_t c_ns_list_len = 0 + cdef size_t i + + if c_element.parent and c_element.parent.type == tree.XML_DOCUMENT_NODE: + # include declarations on the document node + _collectNsDefs(c_element.parent, &c_ns_list, &c_ns_list_len, &c_ns_list_size) + + tree.BEGIN_FOR_EACH_ELEMENT_FROM(c_element, c_element, 1) + # collect all new namespace declarations into the ns list + if c_element.nsDef: + _collectNsDefs(c_element, &c_ns_list, &c_ns_list_len, &c_ns_list_size) + + # remove all namespace declarations from the list that are referenced + if c_ns_list_len and c_element.type == tree.XML_ELEMENT_NODE: + c_node = c_element + while c_node and c_ns_list_len: + if c_node.ns: + for i in range(c_ns_list_len): + if c_node.ns is c_ns_list[i].ns: + c_ns_list_len -= 1 + c_ns_list[i] = c_ns_list[c_ns_list_len] + #c_ns_list[c_ns_list_len] = _ns_node_ref(NULL, NULL) + break + if c_node is c_element: + # continue with attributes + c_node = <xmlNode*>c_element.properties + else: + c_node = c_node.next + tree.END_FOR_EACH_ELEMENT_FROM(c_element) + + if c_ns_list is NULL: + return 0 + + # free all namespace declarations that remained in the list, + # except for those we should keep explicitly + cdef xmlNs* c_nsdef + for i in range(c_ns_list_len): + if prefixes_to_keep is not None: + if c_ns_list[i].ns.prefix and c_ns_list[i].ns.prefix in prefixes_to_keep: + continue + c_node = c_ns_list[i].node + c_nsdef = c_node.nsDef + if c_nsdef is c_ns_list[i].ns: + c_node.nsDef = c_node.nsDef.next + else: + while c_nsdef.next is not c_ns_list[i].ns: + c_nsdef = c_nsdef.next + c_nsdef.next = c_nsdef.next.next + tree.xmlFreeNs(c_ns_list[i].ns) + + if c_ns_list is not NULL: + python.lxml_free(c_ns_list) + return 0 + +cdef xmlNs* _searchNsByHref(xmlNode* c_node, const_xmlChar* c_href, bint is_attribute) noexcept: + """Search a namespace declaration that covers a node (element or + attribute). + + For attributes, try to find a prefixed namespace declaration + instead of the default namespaces. This helps in supporting + round-trips for attributes on elements with a different namespace. + """ + cdef xmlNs* c_ns + cdef xmlNs* c_default_ns = NULL + cdef xmlNode* c_element + if c_href is NULL or c_node is NULL or c_node.type == tree.XML_ENTITY_REF_NODE: + return NULL + if tree.xmlStrcmp(c_href, tree.XML_XML_NAMESPACE) == 0: + # no special cases here, let libxml2 handle this + return tree.xmlSearchNsByHref(c_node.doc, c_node, c_href) + if c_node.type == tree.XML_ATTRIBUTE_NODE: + is_attribute = 1 + while c_node is not NULL and c_node.type != tree.XML_ELEMENT_NODE: + c_node = c_node.parent + c_element = c_node + while c_node is not NULL: + if c_node.type == tree.XML_ELEMENT_NODE: + c_ns = c_node.nsDef + while c_ns is not NULL: + if c_ns.href is not NULL and tree.xmlStrcmp(c_href, c_ns.href) == 0: + if c_ns.prefix is NULL and is_attribute: + # for attributes, continue searching a named + # prefix, but keep the first default namespace + # declaration that we found + if c_default_ns is NULL: + c_default_ns = c_ns + elif tree.xmlSearchNs( + c_element.doc, c_element, c_ns.prefix) is c_ns: + # start node is in namespace scope => found! + return c_ns + c_ns = c_ns.next + if c_node is not c_element and c_node.ns is not NULL: + # optimise: the node may have the namespace itself + c_ns = c_node.ns + if c_ns.href is not NULL and tree.xmlStrcmp(c_href, c_ns.href) == 0: + if c_ns.prefix is NULL and is_attribute: + # for attributes, continue searching a named + # prefix, but keep the first default namespace + # declaration that we found + if c_default_ns is NULL: + c_default_ns = c_ns + elif tree.xmlSearchNs( + c_element.doc, c_element, c_ns.prefix) is c_ns: + # start node is in namespace scope => found! + return c_ns + c_node = c_node.parent + # nothing found => use a matching default namespace or fail + if c_default_ns is not NULL: + if tree.xmlSearchNs(c_element.doc, c_element, NULL) is c_default_ns: + return c_default_ns + return NULL + +cdef int _replaceNodeByChildren(_Document doc, xmlNode* c_node) except -1: + # NOTE: this does not deallocate the node, just unlink it! + cdef xmlNode* c_parent + cdef xmlNode* c_child + if c_node.children is NULL: + tree.xmlUnlinkNode(c_node) + return 0 + + c_parent = c_node.parent + # fix parent links of children + c_child = c_node.children + while c_child is not NULL: + c_child.parent = c_parent + c_child = c_child.next + + # fix namespace references of children if their parent's namespace + # declarations get lost + if c_node.nsDef is not NULL: + c_child = c_node.children + while c_child is not NULL: + moveNodeToDocument(doc, doc._c_doc, c_child) + c_child = c_child.next + + # fix sibling links to/from child slice + if c_node.prev is NULL: + c_parent.children = c_node.children + else: + c_node.prev.next = c_node.children + c_node.children.prev = c_node.prev + if c_node.next is NULL: + c_parent.last = c_node.last + else: + c_node.next.prev = c_node.last + c_node.last.next = c_node.next + + # unlink c_node + c_node.children = c_node.last = NULL + c_node.parent = c_node.next = c_node.prev = NULL + return 0 + +cdef unicode _attributeValue(xmlNode* c_element, xmlAttr* c_attrib_node): + c_href = _getNs(<xmlNode*>c_attrib_node) + value = tree.xmlGetNsProp(c_element, c_attrib_node.name, c_href) + try: + result = funicode(value) + finally: + tree.xmlFree(value) + return result + +cdef unicode _attributeValueFromNsName(xmlNode* c_element, + const_xmlChar* c_href, const_xmlChar* c_name): + c_result = tree.xmlGetNsProp(c_element, c_name, c_href) + if c_result is NULL: + return None + try: + result = funicode(c_result) + finally: + tree.xmlFree(c_result) + return result + +cdef object _getNodeAttributeValue(xmlNode* c_node, key, default): + ns, tag = _getNsTag(key) + c_href = <const_xmlChar*>NULL if ns is None else _xcstr(ns) + c_result = tree.xmlGetNsProp(c_node, _xcstr(tag), c_href) + if c_result is NULL: + # XXX free namespace that is not in use..? + return default + try: + result = funicode(c_result) + finally: + tree.xmlFree(c_result) + return result + +cdef inline object _getAttributeValue(_Element element, key, default): + return _getNodeAttributeValue(element._c_node, key, default) + +cdef int _setAttributeValue(_Element element, key, value) except -1: + cdef const_xmlChar* c_value + cdef xmlNs* c_ns + ns, tag = _getNsTag(key) + is_html = element._doc._parser._for_html + if not is_html: + _attributeValidOrRaise(tag) + c_tag = _xcstr(tag) + if value is None and is_html: + c_value = NULL + else: + if isinstance(value, QName): + value = _resolveQNameText(element, value) + else: + value = _utf8(value) + c_value = _xcstr(value) + if ns is None: + c_ns = NULL + else: + c_ns = element._doc._findOrBuildNodeNs(element._c_node, _xcstr(ns), NULL, 1) + tree.xmlSetNsProp(element._c_node, c_ns, c_tag, c_value) + return 0 + +cdef int _delAttribute(_Element element, key) except -1: + ns, tag = _getNsTag(key) + c_href = <const_xmlChar*>NULL if ns is None else _xcstr(ns) + if _delAttributeFromNsName(element._c_node, c_href, _xcstr(tag)): + raise KeyError, key + return 0 + +cdef int _delAttributeFromNsName(xmlNode* c_node, const_xmlChar* c_href, const_xmlChar* c_name) noexcept: + c_attr = tree.xmlHasNsProp(c_node, c_name, c_href) + if c_attr is NULL: + # XXX free namespace that is not in use..? + return -1 + tree.xmlRemoveProp(c_attr) + return 0 + +cdef list _collectAttributes(xmlNode* c_node, int collecttype): + """Collect all attributes of a node in a list. Depending on collecttype, + it collects either the name (1), the value (2) or the name-value tuples. + """ + cdef Py_ssize_t count + c_attr = c_node.properties + count = 0 + while c_attr is not NULL: + if c_attr.type == tree.XML_ATTRIBUTE_NODE: + count += 1 + c_attr = c_attr.next + + if not count: + return [] + + attributes = [None] * count + c_attr = c_node.properties + count = 0 + while c_attr is not NULL: + if c_attr.type == tree.XML_ATTRIBUTE_NODE: + if collecttype == 1: + item = _namespacedName(<xmlNode*>c_attr) + elif collecttype == 2: + item = _attributeValue(c_node, c_attr) + else: + item = (_namespacedName(<xmlNode*>c_attr), + _attributeValue(c_node, c_attr)) + attributes[count] = item + count += 1 + c_attr = c_attr.next + return attributes + +cdef object __RE_XML_ENCODING = re.compile( + r'^(<\?xml[^>]+)\s+encoding\s*=\s*["\'][^"\']*["\'](\s*\?>|)', re.U) + +cdef object __REPLACE_XML_ENCODING = __RE_XML_ENCODING.sub +cdef object __HAS_XML_ENCODING = __RE_XML_ENCODING.match + +cdef object _stripEncodingDeclaration(object xml_string): + # this is a hack to remove the XML encoding declaration from unicode + return __REPLACE_XML_ENCODING(r'\g<1>\g<2>', xml_string) + +cdef bint _hasEncodingDeclaration(object xml_string) except -1: + # check if a (unicode) string has an XML encoding declaration + return __HAS_XML_ENCODING(xml_string) is not None + +cdef inline bint _hasText(xmlNode* c_node) noexcept: + return c_node is not NULL and _textNodeOrSkip(c_node.children) is not NULL + +cdef inline bint _hasTail(xmlNode* c_node) noexcept: + return c_node is not NULL and _textNodeOrSkip(c_node.next) is not NULL + +cdef inline bint _hasNonWhitespaceTail(xmlNode* c_node) except -1: + return _hasNonWhitespaceText(c_node, tail=True) + +cdef bint _hasNonWhitespaceText(xmlNode* c_node, bint tail=False) except -1: + c_text_node = c_node and _textNodeOrSkip(c_node.next if tail else c_node.children) + if c_text_node is NULL: + return False + while c_text_node is not NULL: + if c_text_node.content[0] != c'\0' and not _collectText(c_text_node).isspace(): + return True + c_text_node = _textNodeOrSkip(c_text_node.next) + return False + +cdef unicode _collectText(xmlNode* c_node): + """Collect all text nodes and return them as a unicode string. + + Start collecting at c_node. + + If there was no text to collect, return None + """ + cdef Py_ssize_t scount + cdef xmlChar* c_text + cdef xmlNode* c_node_cur + # check for multiple text nodes + scount = 0 + c_text = NULL + c_node_cur = c_node = _textNodeOrSkip(c_node) + while c_node_cur is not NULL: + if c_node_cur.content[0] != c'\0': + c_text = c_node_cur.content + scount += 1 + c_node_cur = _textNodeOrSkip(c_node_cur.next) + + # handle two most common cases first + if c_text is NULL: + return '' if scount > 0 else None + if scount == 1: + return funicode(c_text) + + # the rest is not performance critical anymore + result = b'' + while c_node is not NULL: + result += <unsigned char*>c_node.content + c_node = _textNodeOrSkip(c_node.next) + return funicode(<const_xmlChar*><unsigned char*>result) + +cdef void _removeText(xmlNode* c_node) noexcept: + """Remove all text nodes. + + Start removing at c_node. + """ + cdef xmlNode* c_next + c_node = _textNodeOrSkip(c_node) + while c_node is not NULL: + c_next = _textNodeOrSkip(c_node.next) + tree.xmlUnlinkNode(c_node) + tree.xmlFreeNode(c_node) + c_node = c_next + +cdef xmlNode* _createTextNode(xmlDoc* doc, value) except NULL: + cdef xmlNode* c_text_node + if isinstance(value, CDATA): + c_text_node = tree.xmlNewCDataBlock( + doc, _xcstr((<CDATA>value)._utf8_data), + python.PyBytes_GET_SIZE((<CDATA>value)._utf8_data)) + else: + text = _utf8(value) + c_text_node = tree.xmlNewDocText(doc, _xcstr(text)) + if not c_text_node: + raise MemoryError() + return c_text_node + +cdef int _setNodeText(xmlNode* c_node, value) except -1: + # remove all text nodes at the start first + _removeText(c_node.children) + if value is None: + return 0 + # now add new text node with value at start + c_text_node = _createTextNode(c_node.doc, value) + if c_node.children is NULL: + tree.xmlAddChild(c_node, c_text_node) + else: + tree.xmlAddPrevSibling(c_node.children, c_text_node) + return 0 + +cdef int _setTailText(xmlNode* c_node, value) except -1: + # remove all text nodes at the start first + _removeText(c_node.next) + if value is None: + return 0 + # now append new text node with value + c_text_node = _createTextNode(c_node.doc, value) + tree.xmlAddNextSibling(c_node, c_text_node) + return 0 + +cdef bytes _resolveQNameText(_Element element, value): + cdef xmlNs* c_ns + ns, tag = _getNsTag(value) + if ns is None: + return tag + else: + c_ns = element._doc._findOrBuildNodeNs( + element._c_node, _xcstr(ns), NULL, 0) + return python.PyBytes_FromFormat('%s:%s', c_ns.prefix, _cstr(tag)) + +cdef inline bint _hasChild(xmlNode* c_node) noexcept: + return c_node is not NULL and _findChildForwards(c_node, 0) is not NULL + +cdef inline Py_ssize_t _countElements(xmlNode* c_node) noexcept: + "Counts the elements within the following siblings and the node itself." + cdef Py_ssize_t count + count = 0 + while c_node is not NULL: + if _isElement(c_node): + count += 1 + c_node = c_node.next + return count + + +cdef int _findChildSlice( + slice sliceobject, xmlNode* c_parent, + xmlNode** c_start_node, Py_ssize_t* c_step, Py_ssize_t* c_length) except -1: + """Resolve a children slice. + + Returns the start node, step size and the slice length in the + pointer arguments. + """ + cdef Py_ssize_t start = 0, stop = 0, childcount + childcount = _countElements(c_parent.children) + if childcount == 0: + c_start_node[0] = NULL + c_length[0] = 0 + if sliceobject.step is None: + c_step[0] = 1 + else: + python._PyEval_SliceIndex(sliceobject.step, c_step) + return 0 + + python.PySlice_GetIndicesEx( + sliceobject, childcount, &start, &stop, c_step, c_length) + + if start > childcount // 2: + c_start_node[0] = _findChildBackwards(c_parent, childcount - start - 1) + else: + c_start_node[0] = _findChild(c_parent, start) + return 0 + + +cdef bint _isFullSlice(slice sliceobject) except -1: + """Conservative guess if this slice is a full slice as in ``s[:]``. + """ + cdef Py_ssize_t step = 0 + if sliceobject is None: + return 0 + if sliceobject.start is None and \ + sliceobject.stop is None: + if sliceobject.step is None: + return 1 + python._PyEval_SliceIndex(sliceobject.step, &step) + if step == 1: + return 1 + return 0 + return 0 + +cdef _collectChildren(_Element element): + cdef xmlNode* c_node + cdef list result = [] + c_node = element._c_node.children + if c_node is not NULL: + if not _isElement(c_node): + c_node = _nextElement(c_node) + while c_node is not NULL: + result.append(_elementFactory(element._doc, c_node)) + c_node = _nextElement(c_node) + return result + +cdef inline xmlNode* _findChild(xmlNode* c_node, Py_ssize_t index) noexcept: + if index < 0: + return _findChildBackwards(c_node, -index - 1) + else: + return _findChildForwards(c_node, index) + +cdef inline xmlNode* _findChildForwards(xmlNode* c_node, Py_ssize_t index) noexcept: + """Return child element of c_node with index, or return NULL if not found. + """ + cdef xmlNode* c_child + cdef Py_ssize_t c + c_child = c_node.children + c = 0 + while c_child is not NULL: + if _isElement(c_child): + if c == index: + return c_child + c += 1 + c_child = c_child.next + return NULL + +cdef inline xmlNode* _findChildBackwards(xmlNode* c_node, Py_ssize_t index) noexcept: + """Return child element of c_node with index, or return NULL if not found. + Search from the end. + """ + cdef xmlNode* c_child + cdef Py_ssize_t c + c_child = c_node.last + c = 0 + while c_child is not NULL: + if _isElement(c_child): + if c == index: + return c_child + c += 1 + c_child = c_child.prev + return NULL + +cdef inline xmlNode* _textNodeOrSkip(xmlNode* c_node) noexcept nogil: + """Return the node if it's a text node. Skip over ignorable nodes in a + series of text nodes. Return NULL if a non-ignorable node is found. + + This is used to skip over XInclude nodes when collecting adjacent text + nodes. + """ + while c_node is not NULL: + if c_node.type == tree.XML_TEXT_NODE or \ + c_node.type == tree.XML_CDATA_SECTION_NODE: + return c_node + elif c_node.type == tree.XML_XINCLUDE_START or \ + c_node.type == tree.XML_XINCLUDE_END: + c_node = c_node.next + else: + return NULL + return NULL + +cdef inline xmlNode* _nextElement(xmlNode* c_node) noexcept: + """Given a node, find the next sibling that is an element. + """ + if c_node is NULL: + return NULL + c_node = c_node.next + while c_node is not NULL: + if _isElement(c_node): + return c_node + c_node = c_node.next + return NULL + +cdef inline xmlNode* _previousElement(xmlNode* c_node) noexcept: + """Given a node, find the next sibling that is an element. + """ + if c_node is NULL: + return NULL + c_node = c_node.prev + while c_node is not NULL: + if _isElement(c_node): + return c_node + c_node = c_node.prev + return NULL + +cdef inline xmlNode* _parentElement(xmlNode* c_node) noexcept: + "Given a node, find the parent element." + if c_node is NULL or not _isElement(c_node): + return NULL + c_node = c_node.parent + if c_node is NULL or not _isElement(c_node): + return NULL + return c_node + +cdef inline bint _tagMatches(xmlNode* c_node, const_xmlChar* c_href, const_xmlChar* c_name) noexcept: + """Tests if the node matches namespace URI and tag name. + + A node matches if it matches both c_href and c_name. + + A node matches c_href if any of the following is true: + * c_href is NULL + * its namespace is NULL and c_href is the empty string + * its namespace string equals the c_href string + + A node matches c_name if any of the following is true: + * c_name is NULL + * its name string equals the c_name string + """ + if c_node is NULL: + return 0 + if c_node.type != tree.XML_ELEMENT_NODE: + # not an element, only succeed if we match everything + return c_name is NULL and c_href is NULL + if c_name is NULL: + if c_href is NULL: + # always match + return 1 + else: + c_node_href = _getNs(c_node) + if c_node_href is NULL: + return c_href[0] == c'\0' + else: + return tree.xmlStrcmp(c_node_href, c_href) == 0 + elif c_href is NULL: + if _getNs(c_node) is not NULL: + return 0 + return c_node.name == c_name or tree.xmlStrcmp(c_node.name, c_name) == 0 + elif c_node.name == c_name or tree.xmlStrcmp(c_node.name, c_name) == 0: + c_node_href = _getNs(c_node) + if c_node_href is NULL: + return c_href[0] == c'\0' + else: + return tree.xmlStrcmp(c_node_href, c_href) == 0 + else: + return 0 + +cdef inline bint _tagMatchesExactly(xmlNode* c_node, qname* c_qname) noexcept: + """Tests if the node matches namespace URI and tag name. + + This differs from _tagMatches() in that it does not consider a + NULL value in qname.href a wildcard, and that it expects the c_name + to be taken from the doc dict, i.e. it only compares the names by + address. + + A node matches if it matches both href and c_name of the qname. + + A node matches c_href if any of the following is true: + * its namespace is NULL and c_href is the empty string + * its namespace string equals the c_href string + + A node matches c_name if any of the following is true: + * c_name is NULL + * its name string points to the same address (!) as c_name + """ + return _nsTagMatchesExactly(_getNs(c_node), c_node.name, c_qname) + +cdef inline bint _nsTagMatchesExactly(const_xmlChar* c_node_href, + const_xmlChar* c_node_name, + qname* c_qname) noexcept: + """Tests if name and namespace URI match those of c_qname. + + This differs from _tagMatches() in that it does not consider a + NULL value in qname.href a wildcard, and that it expects the c_name + to be taken from the doc dict, i.e. it only compares the names by + address. + + A node matches if it matches both href and c_name of the qname. + + A node matches c_href if any of the following is true: + * its namespace is NULL and c_href is the empty string + * its namespace string equals the c_href string + + A node matches c_name if any of the following is true: + * c_name is NULL + * its name string points to the same address (!) as c_name + """ + cdef char* c_href + if c_qname.c_name is not NULL and c_qname.c_name is not c_node_name: + return 0 + if c_qname.href is NULL: + return 1 + c_href = python.__cstr(c_qname.href) + if c_href[0] == b'\0': + return c_node_href is NULL or c_node_href[0] == b'\0' + elif c_node_href is NULL: + return 0 + else: + return tree.xmlStrcmp(<const_xmlChar*>c_href, c_node_href) == 0 + +cdef Py_ssize_t _mapTagsToQnameMatchArray(xmlDoc* c_doc, list ns_tags, + qname* c_ns_tags, bint force_into_dict) except -1: + """Map a sequence of (name, namespace) pairs to a qname array for efficient + matching with _tagMatchesExactly() above. + + Note that each qname struct in the array owns its href byte string object + if it is not NULL. + """ + cdef Py_ssize_t count = 0, i, c_tag_len + cdef bytes ns, tag + cdef const_xmlChar* c_tag + + for ns, tag in ns_tags: + if tag is None: + c_tag = <const_xmlChar*> NULL + else: + c_tag_len = len(tag) + if c_tag_len > limits.INT_MAX: + # too long, not in the dict => not in the document + continue + elif force_into_dict: + c_tag = tree.xmlDictLookup(c_doc.dict, _xcstr(tag), <int> c_tag_len) + if c_tag is NULL: + # clean up before raising the error + for i in xrange(count): + cpython.ref.Py_XDECREF(c_ns_tags[i].href) + raise MemoryError() + else: + c_tag = tree.xmlDictExists(c_doc.dict, _xcstr(tag), <int> c_tag_len) + if c_tag is NULL: + # not in the dict => not in the document + continue + + c_ns_tags[count].c_name = c_tag + if ns is None: + c_ns_tags[count].href = NULL + else: + cpython.ref.Py_INCREF(ns) # keep an owned reference! + c_ns_tags[count].href = <python.PyObject*>ns + count += 1 + return count + +cdef int _removeNode(_Document doc, xmlNode* c_node) except -1: + """Unlink and free a node and subnodes if possible. Otherwise, make sure + it's self-contained. + """ + cdef xmlNode* c_next + c_next = c_node.next + tree.xmlUnlinkNode(c_node) + _moveTail(c_next, c_node) + if not attemptDeallocation(c_node): + # make namespaces absolute + moveNodeToDocument(doc, c_node.doc, c_node) + return 0 + +cdef int _removeSiblings(xmlNode* c_element, tree.xmlElementType node_type, bint with_tail) except -1: + cdef xmlNode* c_node + cdef xmlNode* c_next + c_node = c_element.next + while c_node is not NULL: + c_next = _nextElement(c_node) + if c_node.type == node_type: + if with_tail: + _removeText(c_node.next) + tree.xmlUnlinkNode(c_node) + attemptDeallocation(c_node) + c_node = c_next + c_node = c_element.prev + while c_node is not NULL: + c_next = _previousElement(c_node) + if c_node.type == node_type: + if with_tail: + _removeText(c_node.next) + tree.xmlUnlinkNode(c_node) + attemptDeallocation(c_node) + c_node = c_next + return 0 + +cdef void _moveTail(xmlNode* c_tail, xmlNode* c_target) noexcept: + cdef xmlNode* c_next + # tail support: look for any text nodes trailing this node and + # move them too + c_tail = _textNodeOrSkip(c_tail) + while c_tail is not NULL: + c_next = _textNodeOrSkip(c_tail.next) + c_target = tree.xmlAddNextSibling(c_target, c_tail) + c_tail = c_next + +cdef int _copyTail(xmlNode* c_tail, xmlNode* c_target) except -1: + cdef xmlNode* c_new_tail + # tail copying support: look for any text nodes trailing this node and + # copy it to the target node + c_tail = _textNodeOrSkip(c_tail) + while c_tail is not NULL: + if c_target.doc is not c_tail.doc: + c_new_tail = tree.xmlDocCopyNode(c_tail, c_target.doc, 0) + else: + c_new_tail = tree.xmlCopyNode(c_tail, 0) + if c_new_tail is NULL: + raise MemoryError() + c_target = tree.xmlAddNextSibling(c_target, c_new_tail) + c_tail = _textNodeOrSkip(c_tail.next) + return 0 + +cdef int _copyNonElementSiblings(xmlNode* c_node, xmlNode* c_target) except -1: + cdef xmlNode* c_copy + cdef xmlNode* c_sibling = c_node + while c_sibling.prev != NULL and \ + (c_sibling.prev.type == tree.XML_PI_NODE or + c_sibling.prev.type == tree.XML_COMMENT_NODE or + c_sibling.prev.type == tree.XML_DTD_NODE): + c_sibling = c_sibling.prev + while c_sibling != c_node: + if c_sibling.type == tree.XML_DTD_NODE: + c_copy = <xmlNode*>_copyDtd(<tree.xmlDtd*>c_sibling) + if c_sibling == <xmlNode*>c_node.doc.intSubset: + c_target.doc.intSubset = <tree.xmlDtd*>c_copy + else: # c_sibling == c_node.doc.extSubset + c_target.doc.extSubset = <tree.xmlDtd*>c_copy + else: + c_copy = tree.xmlDocCopyNode(c_sibling, c_target.doc, 1) + if c_copy is NULL: + raise MemoryError() + tree.xmlAddPrevSibling(c_target, c_copy) + c_sibling = c_sibling.next + while c_sibling.next != NULL and \ + (c_sibling.next.type == tree.XML_PI_NODE or + c_sibling.next.type == tree.XML_COMMENT_NODE): + c_sibling = c_sibling.next + c_copy = tree.xmlDocCopyNode(c_sibling, c_target.doc, 1) + if c_copy is NULL: + raise MemoryError() + tree.xmlAddNextSibling(c_target, c_copy) + +cdef int _deleteSlice(_Document doc, xmlNode* c_node, + Py_ssize_t count, Py_ssize_t step) except -1: + """Delete slice, ``count`` items starting with ``c_node`` with a step + width of ``step``. + """ + cdef xmlNode* c_next + cdef Py_ssize_t c, i + cdef _node_to_node_function next_element + if c_node is NULL: + return 0 + if step > 0: + next_element = _nextElement + else: + step = -step if step != python.PY_SSIZE_T_MIN else python.PY_SSIZE_T_MAX + next_element = _previousElement + # now start deleting nodes + c = 0 + c_next = c_node + while c_node is not NULL and c < count: + for i in range(step): + c_next = next_element(c_next) + if c_next is NULL: + break + _removeNode(doc, c_node) + c += 1 + c_node = c_next + return 0 + +cdef int _replaceSlice(_Element parent, xmlNode* c_node, + Py_ssize_t slicelength, Py_ssize_t step, + bint left_to_right, elements) except -1: + """Replace the slice of ``count`` elements starting at ``c_node`` with + positive step width ``step`` by the Elements in ``elements``. The + direction is given by the boolean argument ``left_to_right``. + + ``c_node`` may be NULL to indicate the end of the children list. + """ + cdef xmlNode* c_orig_neighbour + cdef xmlNode* c_next + cdef xmlDoc* c_source_doc + cdef _Element element + cdef Py_ssize_t seqlength, i, c + cdef _node_to_node_function next_element + assert step > 0, step + if left_to_right: + next_element = _nextElement + else: + next_element = _previousElement + + if not isinstance(elements, (list, tuple)): + elements = list(elements) + + if step != 1 or not left_to_right: + # *replacing* children stepwise with list => check size! + seqlength = len(elements) + if seqlength != slicelength: + raise ValueError, f"attempt to assign sequence of size {seqlength} " \ + f"to extended slice of size {slicelength}" + + if c_node is NULL: + # no children yet => add all elements straight away + if left_to_right: + for element in elements: + assert element is not None, "Node must not be None" + _appendChild(parent, element) + else: + for element in elements: + assert element is not None, "Node must not be None" + _prependChild(parent, element) + return 0 + + # remove the elements first as some might be re-added + if left_to_right: + # L->R, remember left neighbour + c_orig_neighbour = _previousElement(c_node) + else: + # R->L, remember right neighbour + c_orig_neighbour = _nextElement(c_node) + + # We remove the original slice elements one by one. Since we hold + # a Python reference to all elements that we will insert, it is + # safe to let _removeNode() try (and fail) to free them even if + # the element itself or one of its descendents will be reinserted. + c = 0 + c_next = c_node + while c_node is not NULL and c < slicelength: + for i in range(step): + c_next = next_element(c_next) + if c_next is NULL: + break + _removeNode(parent._doc, c_node) + c += 1 + c_node = c_next + + # make sure each element is inserted only once + elements = iter(elements) + + # find the first node right of the new insertion point + if left_to_right: + if c_orig_neighbour is not NULL: + c_node = next_element(c_orig_neighbour) + else: + # before the first element + c_node = _findChildForwards(parent._c_node, 0) + elif c_orig_neighbour is NULL: + # at the end, but reversed stepping + # append one element and go to the next insertion point + for element in elements: + assert element is not None, "Node must not be None" + _appendChild(parent, element) + c_node = element._c_node + if slicelength > 0: + slicelength -= 1 + for i in range(1, step): + c_node = next_element(c_node) + if c_node is NULL: + break + break + else: + c_node = c_orig_neighbour + + if left_to_right: + # adjust step size after removing slice as we are not stepping + # over the newly inserted elements + step -= 1 + + # now insert elements where we removed them + if c_node is not NULL: + for element in elements: + assert element is not None, "Node must not be None" + _assertValidNode(element) + # move element and tail over + c_source_doc = element._c_node.doc + c_next = element._c_node.next + tree.xmlAddPrevSibling(c_node, element._c_node) + _moveTail(c_next, element._c_node) + + # integrate element into new document + moveNodeToDocument(parent._doc, c_source_doc, element._c_node) + + # stop at the end of the slice + if slicelength > 0: + slicelength -= 1 + for i in range(step): + c_node = next_element(c_node) + if c_node is NULL: + break + if c_node is NULL: + break + else: + # everything inserted + return 0 + + # append the remaining elements at the respective end + if left_to_right: + for element in elements: + assert element is not None, "Node must not be None" + _assertValidNode(element) + _appendChild(parent, element) + else: + for element in elements: + assert element is not None, "Node must not be None" + _assertValidNode(element) + _prependChild(parent, element) + + return 0 + + +cdef int _linkChild(xmlNode* c_parent, xmlNode* c_node) except -1: + """Adaptation of 'xmlAddChild()' that deep-fix the document links iteratively. + """ + assert _isElement(c_node) + c_node.parent = c_parent + if c_parent.children is NULL: + c_parent.children = c_parent.last = c_node + else: + c_node.prev = c_parent.last + c_parent.last.next = c_node + c_parent.last = c_node + + _setTreeDoc(c_node, c_parent.doc) + return 0 + + +cdef int _appendChild(_Element parent, _Element child) except -1: + """Append a new child to a parent element. + """ + c_node = child._c_node + c_source_doc = c_node.doc + # prevent cycles + if _isAncestorOrSame(c_node, parent._c_node): + raise ValueError("cannot append parent to itself") + # store possible text node + c_next = c_node.next + # move node itself + tree.xmlUnlinkNode(c_node) + # do not call xmlAddChild() here since it would deep-traverse the tree + _linkChild(parent._c_node, c_node) + _moveTail(c_next, c_node) + # uh oh, elements may be pointing to different doc when + # parent element has moved; change them too.. + moveNodeToDocument(parent._doc, c_source_doc, c_node) + return 0 + +cdef int _prependChild(_Element parent, _Element child) except -1: + """Prepend a new child to a parent element. + """ + c_node = child._c_node + c_source_doc = c_node.doc + # prevent cycles + if _isAncestorOrSame(c_node, parent._c_node): + raise ValueError("cannot append parent to itself") + # store possible text node + c_next = c_node.next + # move node itself + c_child = _findChildForwards(parent._c_node, 0) + if c_child is NULL: + tree.xmlUnlinkNode(c_node) + # do not call xmlAddChild() here since it would deep-traverse the tree + _linkChild(parent._c_node, c_node) + else: + tree.xmlAddPrevSibling(c_child, c_node) + _moveTail(c_next, c_node) + # uh oh, elements may be pointing to different doc when + # parent element has moved; change them too.. + moveNodeToDocument(parent._doc, c_source_doc, c_node) + return 0 + +cdef int _appendSibling(_Element element, _Element sibling) except -1: + """Add a new sibling behind an element. + """ + return _addSibling(element, sibling, as_next=True) + +cdef int _prependSibling(_Element element, _Element sibling) except -1: + """Add a new sibling before an element. + """ + return _addSibling(element, sibling, as_next=False) + +cdef int _addSibling(_Element element, _Element sibling, bint as_next) except -1: + c_node = sibling._c_node + c_source_doc = c_node.doc + # prevent cycles + if _isAncestorOrSame(c_node, element._c_node): + if element._c_node is c_node: + return 0 # nothing to do + raise ValueError("cannot add ancestor as sibling, please break cycle first") + # store possible text node + c_next = c_node.next + # move node itself + if as_next: + # must insert after any tail text + c_next_node = _nextElement(element._c_node) + if c_next_node is NULL: + c_next_node = element._c_node + while c_next_node.next: + c_next_node = c_next_node.next + tree.xmlAddNextSibling(c_next_node, c_node) + else: + tree.xmlAddPrevSibling(c_next_node, c_node) + else: + tree.xmlAddPrevSibling(element._c_node, c_node) + _moveTail(c_next, c_node) + # uh oh, elements may be pointing to different doc when + # parent element has moved; change them too.. + moveNodeToDocument(element._doc, c_source_doc, c_node) + return 0 + +cdef inline bint isutf8(const_xmlChar* s) noexcept: + cdef xmlChar c = s[0] + while c != c'\0': + if c & 0x80: + return True + s += 1 + c = s[0] + return False + +cdef bint isutf8l(const_xmlChar* s, size_t length) noexcept: + """ + Search for non-ASCII characters in the string, knowing its length in advance. + """ + cdef unsigned int i + cdef unsigned long non_ascii_mask + cdef const unsigned long *lptr = <const unsigned long*> s + + cdef const unsigned long *end = lptr + length // sizeof(unsigned long) + if length >= sizeof(non_ascii_mask): + # Build constant 0x80808080... mask (and let the C compiler fold it). + non_ascii_mask = 0 + for i in range(sizeof(non_ascii_mask) // 2): + non_ascii_mask = (non_ascii_mask << 16) | 0x8080 + + # Advance to long-aligned character before we start reading longs. + while (<size_t>s) % sizeof(unsigned long) and s < <const_xmlChar *>end: + if s[0] & 0x80: + return True + s += 1 + + # Read one long at a time + lptr = <const unsigned long*> s + while lptr < end: + if lptr[0] & non_ascii_mask: + return True + lptr += 1 + s = <const_xmlChar *>lptr + + while s < (<const_xmlChar *>end + length % sizeof(unsigned long)): + if s[0] & 0x80: + return True + s += 1 + + return False + +cdef int _is_valid_xml_ascii(bytes pystring) except -1: + """Check if a string is XML ascii content.""" + cdef signed char ch + # When ch is a *signed* char, non-ascii characters are negative integers + # and xmlIsChar_ch does not accept them. + for ch in pystring: + if not tree.xmlIsChar_ch(ch): + return 0 + return 1 + +cdef bint _is_valid_xml_utf8(bytes pystring) except -1: + """Check if a string is like valid UTF-8 XML content.""" + cdef const_xmlChar* s = _xcstr(pystring) + cdef const_xmlChar* c_end = s + len(pystring) + cdef unsigned long next3 = 0 + if s < c_end - 2: + next3 = (s[0] << 8) | (s[1]) + + while s < c_end - 2: + next3 = 0x00ffffff & ((next3 << 8) | s[2]) + if s[0] & 0x80: + # 0xefbfbe and 0xefbfbf are utf-8 encodings of + # forbidden characters \ufffe and \uffff + if next3 == 0x00efbfbe or next3 == 0x00efbfbf: + return 0 + # 0xeda080 and 0xedbfbf are utf-8 encodings of + # \ud800 and \udfff. Anything between them (inclusive) + # is forbidden, because they are surrogate blocks in utf-16. + if 0x00eda080 <= next3 <= 0x00edbfbf: + return 0 + elif not tree.xmlIsChar_ch(s[0]): + return 0 # invalid ascii char + s += 1 + + while s < c_end: + if not s[0] & 0x80 and not tree.xmlIsChar_ch(s[0]): + return 0 # invalid ascii char + s += 1 + + return 1 + +cdef inline unicode funicodeOrNone(const_xmlChar* s): + return funicode(s) if s is not NULL else None + +cdef inline unicode funicodeOrEmpty(const_xmlChar* s): + return funicode(s) if s is not NULL else '' + +cdef unicode funicode(const_xmlChar* s): + return s.decode('UTF-8') + +cdef bytes _utf8(object s): + """Test if a string is valid user input and encode it to UTF-8. + Reject all bytes/unicode input that contains non-XML characters. + Reject all bytes input that contains non-ASCII characters. + """ + cdef int valid + cdef bytes utf8_string + if isinstance(s, unicode): + utf8_string = (<unicode>s).encode('utf8') + valid = _is_valid_xml_utf8(utf8_string) + elif isinstance(s, (bytes, bytearray)): + utf8_string = s if type(s) is bytes else bytes(s) + valid = _is_valid_xml_ascii(utf8_string) + else: + raise TypeError("Argument must be bytes or unicode, got '%.200s'" % type(s).__name__) + if not valid: + raise ValueError( + "All strings must be XML compatible: Unicode or ASCII, no NULL bytes or control characters") + return utf8_string + + +cdef bytes _utf8orNone(object s): + return _utf8(s) if s is not None else None + + +cdef enum: + NO_FILE_PATH = 0 + ABS_UNIX_FILE_PATH = 1 + ABS_WIN_FILE_PATH = 2 + REL_FILE_PATH = 3 + + +cdef bint _isFilePath(const_xmlChar* c_path) noexcept: + "simple heuristic to see if a path is a filename" + cdef xmlChar c + # test if it looks like an absolute Unix path or a Windows network path + if c_path[0] == c'/': + return ABS_UNIX_FILE_PATH + + # test if it looks like an absolute Windows path or URL + if c'a' <= c_path[0] <= c'z' or c'A' <= c_path[0] <= c'Z': + c_path += 1 + if c_path[0] == c':' and c_path[1] in b'\0\\': + return ABS_WIN_FILE_PATH # C: or C:\... + + # test if it looks like a URL with scheme:// + while c'a' <= c_path[0] <= c'z' or c'A' <= c_path[0] <= c'Z': + c_path += 1 + if c_path[0] == c':' and c_path[1] == c'/' and c_path[2] == c'/': + return NO_FILE_PATH + + # assume it's a relative path + return REL_FILE_PATH + + +cdef object _getFSPathOrObject(object obj): + """ + Get the __fspath__ attribute of an object if it exists. + Otherwise, the original object is returned. + """ + if _isString(obj): + return obj + try: + return python.PyOS_FSPath(obj) + except TypeError: + return obj + + +cdef object _encodeFilename(object filename): + """Make sure a filename is 8-bit encoded (or None). + """ + if filename is None: + return None + elif isinstance(filename, bytes): + return filename + elif isinstance(filename, unicode): + filename8 = (<unicode>filename).encode('utf8') + if _isFilePath(<unsigned char*>filename8): + try: + return python.PyUnicode_AsEncodedString( + filename, _C_FILENAME_ENCODING, NULL) + except UnicodeEncodeError: + pass + return filename8 + else: + raise TypeError("Argument must be string or unicode.") + +cdef object _decodeFilename(const_xmlChar* c_path): + """Make the filename a unicode string if we are in Py3. + """ + return _decodeFilenameWithLength(c_path, tree.xmlStrlen(c_path)) + +cdef object _decodeFilenameWithLength(const_xmlChar* c_path, size_t c_len): + """Make the filename a unicode string if we are in Py3. + """ + if _isFilePath(c_path): + try: + return python.PyUnicode_Decode( + <const_char*>c_path, c_len, _C_FILENAME_ENCODING, NULL) + except UnicodeDecodeError: + pass + try: + return (<unsigned char*>c_path)[:c_len].decode('UTF-8') + except UnicodeDecodeError: + # this is a stupid fallback, but it might still work... + return (<unsigned char*>c_path)[:c_len].decode('latin-1', 'replace') + +cdef object _encodeFilenameUTF8(object filename): + """Recode filename as UTF-8. Tries ASCII, local filesystem encoding and + UTF-8 as source encoding. + """ + cdef char* c_filename + if filename is None: + return None + elif isinstance(filename, bytes): + if not isutf8l(<bytes>filename, len(<bytes>filename)): + # plain ASCII! + return filename + c_filename = _cstr(<bytes>filename) + try: + # try to decode with default encoding + filename = python.PyUnicode_Decode( + c_filename, len(<bytes>filename), + _C_FILENAME_ENCODING, NULL) + except UnicodeDecodeError as decode_exc: + try: + # try if it's proper UTF-8 + (<bytes>filename).decode('utf8') + return filename + except UnicodeDecodeError: + raise decode_exc # otherwise re-raise original exception + if isinstance(filename, unicode): + return (<unicode>filename).encode('utf8') + else: + raise TypeError("Argument must be string or unicode.") + +cdef tuple _getNsTag(tag): + """Given a tag, find namespace URI and tag name. + Return None for NS uri if no namespace URI provided. + """ + return __getNsTag(tag, 0) + +cdef tuple _getNsTagWithEmptyNs(tag): + """Given a tag, find namespace URI and tag name. Return None for NS uri + if no namespace URI provided, or the empty string if namespace + part is '{}'. + """ + return __getNsTag(tag, 1) + +cdef tuple __getNsTag(tag, bint empty_ns): + cdef char* c_tag + cdef char* c_ns_end + cdef Py_ssize_t taglen + cdef Py_ssize_t nslen + cdef bytes ns = None + # _isString() is much faster than isinstance() + if not _isString(tag) and isinstance(tag, QName): + tag = (<QName>tag).text + tag = _utf8(tag) + c_tag = _cstr(tag) + if c_tag[0] == c'{': + c_tag += 1 + c_ns_end = cstring_h.strchr(c_tag, c'}') + if c_ns_end is NULL: + raise ValueError, "Invalid tag name" + nslen = c_ns_end - c_tag + taglen = python.PyBytes_GET_SIZE(tag) - nslen - 2 + if taglen == 0: + raise ValueError, "Empty tag name" + if nslen > 0: + ns = <bytes>c_tag[:nslen] + elif empty_ns: + ns = b'' + tag = <bytes>c_ns_end[1:taglen+1] + elif python.PyBytes_GET_SIZE(tag) == 0: + raise ValueError, "Empty tag name" + return ns, tag + +cdef inline int _pyXmlNameIsValid(name_utf8): + return _xmlNameIsValid(_xcstr(name_utf8)) and b':' not in name_utf8 + +cdef inline int _pyHtmlNameIsValid(name_utf8): + return _htmlNameIsValid(_xcstr(name_utf8)) + +cdef inline int _xmlNameIsValid(const_xmlChar* c_name) noexcept: + return tree.xmlValidateNameValue(c_name) + +cdef int _htmlNameIsValid(const_xmlChar* c_name) noexcept: + if c_name is NULL or c_name[0] == c'\0': + return 0 + while c_name[0] != c'\0': + if c_name[0] in b'&<>/"\'\t\n\x0B\x0C\r ': + return 0 + c_name += 1 + return 1 + +cdef bint _characterReferenceIsValid(const_xmlChar* c_name) noexcept: + cdef bint is_hex + if c_name[0] == c'x': + c_name += 1 + is_hex = 1 + else: + is_hex = 0 + if c_name[0] == c'\0': + return 0 + while c_name[0] != c'\0': + if c_name[0] < c'0' or c_name[0] > c'9': + if not is_hex: + return 0 + if not (c'a' <= c_name[0] <= c'f'): + if not (c'A' <= c_name[0] <= c'F'): + return 0 + c_name += 1 + return 1 + +cdef int _tagValidOrRaise(tag_utf) except -1: + if not _pyXmlNameIsValid(tag_utf): + raise ValueError(f"Invalid tag name {(<bytes>tag_utf).decode('utf8')!r}") + return 0 + +cdef int _htmlTagValidOrRaise(tag_utf) except -1: + if not _pyHtmlNameIsValid(tag_utf): + raise ValueError(f"Invalid HTML tag name {(<bytes>tag_utf).decode('utf8')!r}") + return 0 + +cdef int _attributeValidOrRaise(name_utf) except -1: + if not _pyXmlNameIsValid(name_utf): + raise ValueError(f"Invalid attribute name {(<bytes>name_utf).decode('utf8')!r}") + return 0 + +cdef int _prefixValidOrRaise(tag_utf) except -1: + if not _pyXmlNameIsValid(tag_utf): + raise ValueError(f"Invalid namespace prefix {(<bytes>tag_utf).decode('utf8')!r}") + return 0 + +cdef int _uriValidOrRaise(uri_utf) except -1: + cdef uri.xmlURI* c_uri = uri.xmlParseURI(_cstr(uri_utf)) + if c_uri is NULL: + raise ValueError(f"Invalid namespace URI {(<bytes>uri_utf).decode('utf8')!r}") + uri.xmlFreeURI(c_uri) + return 0 + +cdef inline unicode _namespacedName(xmlNode* c_node): + return _namespacedNameFromNsName(_getNs(c_node), c_node.name) + + +cdef unicode _namespacedNameFromNsName(const_xmlChar* c_href, const_xmlChar* c_name): + name = funicode(c_name) + if c_href is NULL: + return name + href = funicode(c_href) + return f"{{{href}}}{name}" + + +cdef _getFilenameForFile(source): + """Given a Python File or Gzip object, give filename back. + + Returns None if not a file object. + """ + # urllib2 provides a geturl() method + try: + return source.geturl() + except: + pass + # file instances have a name attribute + try: + filename = source.name + if _isString(filename): + return os_path_abspath(filename) + except: + pass + # gzip file instances have a filename attribute (before Py3k) + try: + filename = source.filename + if _isString(filename): + return os_path_abspath(filename) + except: + pass + # can't determine filename + return None diff --git a/presentation/.venv/lib/python3.12/site-packages/lxml/builder.cpython-312-x86_64-linux-gnu.so b/presentation/.venv/lib/python3.12/site-packages/lxml/builder.cpython-312-x86_64-linux-gnu.so new file mode 100755 index 0000000..9dd5bf7 Binary files /dev/null and b/presentation/.venv/lib/python3.12/site-packages/lxml/builder.cpython-312-x86_64-linux-gnu.so differ diff --git a/presentation/.venv/lib/python3.12/site-packages/lxml/builder.py b/presentation/.venv/lib/python3.12/site-packages/lxml/builder.py new file mode 100644 index 0000000..f5831fb --- /dev/null +++ b/presentation/.venv/lib/python3.12/site-packages/lxml/builder.py @@ -0,0 +1,243 @@ +# cython: language_level=2 + +# +# Element generator factory by Fredrik Lundh. +# +# Source: +# http://online.effbot.org/2006_11_01_archive.htm#et-builder +# http://effbot.python-hosting.com/file/stuff/sandbox/elementlib/builder.py +# +# -------------------------------------------------------------------- +# The ElementTree toolkit is +# +# Copyright (c) 1999-2004 by Fredrik Lundh +# +# By obtaining, using, and/or copying this software and/or its +# associated documentation, you agree that you have read, understood, +# and will comply with the following terms and conditions: +# +# Permission to use, copy, modify, and distribute this software and +# its associated documentation for any purpose and without fee is +# hereby granted, provided that the above copyright notice appears in +# all copies, and that both that copyright notice and this permission +# notice appear in supporting documentation, and that the name of +# Secret Labs AB or the author not be used in advertising or publicity +# pertaining to distribution of the software without specific, written +# prior permission. +# +# SECRET LABS AB AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD +# TO THIS SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANT- +# ABILITY AND FITNESS. IN NO EVENT SHALL SECRET LABS AB OR THE AUTHOR +# BE LIABLE FOR ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY +# DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, +# WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS +# ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE +# OF THIS SOFTWARE. +# -------------------------------------------------------------------- + +""" +The ``E`` Element factory for generating XML documents. +""" + + +import lxml.etree as ET +_QName = ET.QName + +from functools import partial + +try: + from types import GenericAlias as _GenericAlias +except ImportError: + # Python 3.8 - we only need this as return value from "__class_getitem__" + def _GenericAlias(cls, item): + return f"{cls.__name__}[{item.__name__}]" + +try: + basestring +except NameError: + basestring = str + +try: + unicode +except NameError: + unicode = str + + +class ElementMaker: + """Element generator factory. + + Unlike the ordinary Element factory, the E factory allows you to pass in + more than just a tag and some optional attributes; you can also pass in + text and other elements. The text is added as either text or tail + attributes, and elements are inserted at the right spot. Some small + examples:: + + >>> from lxml import etree as ET + >>> from lxml.builder import E + + >>> ET.tostring(E("tag")) + '<tag/>' + >>> ET.tostring(E("tag", "text")) + '<tag>text</tag>' + >>> ET.tostring(E("tag", "text", key="value")) + '<tag key="value">text</tag>' + >>> ET.tostring(E("tag", E("subtag", "text"), "tail")) + '<tag><subtag>text</subtag>tail</tag>' + + For simple tags, the factory also allows you to write ``E.tag(...)`` instead + of ``E('tag', ...)``:: + + >>> ET.tostring(E.tag()) + '<tag/>' + >>> ET.tostring(E.tag("text")) + '<tag>text</tag>' + >>> ET.tostring(E.tag(E.subtag("text"), "tail")) + '<tag><subtag>text</subtag>tail</tag>' + + Here's a somewhat larger example; this shows how to generate HTML + documents, using a mix of prepared factory functions for inline elements, + nested ``E.tag`` calls, and embedded XHTML fragments:: + + # some common inline elements + A = E.a + I = E.i + B = E.b + + def CLASS(v): + # helper function, 'class' is a reserved word + return {'class': v} + + page = ( + E.html( + E.head( + E.title("This is a sample document") + ), + E.body( + E.h1("Hello!", CLASS("title")), + E.p("This is a paragraph with ", B("bold"), " text in it!"), + E.p("This is another paragraph, with a ", + A("link", href="http://www.python.org"), "."), + E.p("Here are some reserved characters: <spam&egg>."), + ET.XML("<p>And finally, here is an embedded XHTML fragment.</p>"), + ) + ) + ) + + print ET.tostring(page) + + Here's a prettyprinted version of the output from the above script:: + + <html> + <head> + <title>This is a sample document + + +

Hello!

+

This is a paragraph with bold text in it!

+

This is another paragraph, with link.

+

Here are some reserved characters: <spam&egg>.

+

And finally, here is an embedded XHTML fragment.

+ + + + For namespace support, you can pass a namespace map (``nsmap``) + and/or a specific target ``namespace`` to the ElementMaker class:: + + >>> E = ElementMaker(namespace="http://my.ns/") + >>> print(ET.tostring( E.test )) + + + >>> E = ElementMaker(namespace="http://my.ns/", nsmap={'p':'http://my.ns/'}) + >>> print(ET.tostring( E.test )) + + """ + + def __init__(self, typemap=None, + namespace=None, nsmap=None, makeelement=None): + self._namespace = '{' + namespace + '}' if namespace is not None else None + self._nsmap = dict(nsmap) if nsmap else None + + assert makeelement is None or callable(makeelement) + self._makeelement = makeelement if makeelement is not None else ET.Element + + # initialize the default type map functions for this element factory + typemap = dict(typemap) if typemap else {} + + def add_text(elem, item): + try: + last_child = elem[-1] + except IndexError: + elem.text = (elem.text or "") + item + else: + last_child.tail = (last_child.tail or "") + item + + def add_cdata(elem, cdata): + if elem.text: + raise ValueError("Can't add a CDATA section. Element already has some text: %r" % elem.text) + elem.text = cdata + + if str not in typemap: + typemap[str] = add_text + if unicode not in typemap: + typemap[unicode] = add_text + if ET.CDATA not in typemap: + typemap[ET.CDATA] = add_cdata + + def add_dict(elem, item): + attrib = elem.attrib + for k, v in item.items(): + if isinstance(v, basestring): + attrib[k] = v + else: + attrib[k] = typemap[type(v)](None, v) + + if dict not in typemap: + typemap[dict] = add_dict + + self._typemap = typemap + + def __call__(self, tag, *children, **attrib): + typemap = self._typemap + + # We'll usually get a 'str', and the compiled type check is very fast. + if not isinstance(tag, str) and isinstance(tag, _QName): + # A QName is explicitly qualified, do not look at self._namespace. + tag = tag.text + elif self._namespace is not None and tag[0] != '{': + tag = self._namespace + tag + elem = self._makeelement(tag, nsmap=self._nsmap) + if attrib: + typemap[dict](elem, attrib) + + for item in children: + if callable(item): + item = item() + t = typemap.get(type(item)) + if t is None: + if ET.iselement(item): + elem.append(item) + continue + for basetype in type(item).__mro__: + # See if the typemap knows of any of this type's bases. + t = typemap.get(basetype) + if t is not None: + break + else: + raise TypeError("bad argument type: %s(%r)" % + (type(item).__name__, item)) + v = t(elem, item) + if v: + typemap.get(type(v))(elem, v) + + return elem + + def __getattr__(self, tag): + return partial(self, tag) + + # Allow subscripting ElementMaker in type annotions (PEP 560) + def __class_getitem__(cls, item): + return _GenericAlias(cls, item) + + +# create factory object +E = ElementMaker() diff --git a/presentation/.venv/lib/python3.12/site-packages/lxml/classlookup.pxi b/presentation/.venv/lib/python3.12/site-packages/lxml/classlookup.pxi new file mode 100644 index 0000000..92d1d47 --- /dev/null +++ b/presentation/.venv/lib/python3.12/site-packages/lxml/classlookup.pxi @@ -0,0 +1,580 @@ +# Configurable Element class lookup + +################################################################################ +# Custom Element classes + +cdef public class ElementBase(_Element) [ type LxmlElementBaseType, + object LxmlElementBase ]: + """ElementBase(*children, attrib=None, nsmap=None, **_extra) + + The public Element class. All custom Element classes must inherit + from this one. To create an Element, use the `Element()` factory. + + BIG FAT WARNING: Subclasses *must not* override __init__ or + __new__ as it is absolutely undefined when these objects will be + created or destroyed. All persistent state of Elements must be + stored in the underlying XML. If you really need to initialize + the object after creation, you can implement an ``_init(self)`` + method that will be called directly after object creation. + + Subclasses of this class can be instantiated to create a new + Element. By default, the tag name will be the class name and the + namespace will be empty. You can modify this with the following + class attributes: + + * TAG - the tag name, possibly containing a namespace in Clark + notation + + * NAMESPACE - the default namespace URI, unless provided as part + of the TAG attribute. + + * HTML - flag if the class is an HTML tag, as opposed to an XML + tag. This only applies to un-namespaced tags and defaults to + false (i.e. XML). + + * PARSER - the parser that provides the configuration for the + newly created document. Providing an HTML parser here will + default to creating an HTML element. + + In user code, the latter three are commonly inherited in class + hierarchies that implement a common namespace. + """ + def __init__(self, *children, attrib=None, nsmap=None, **_extra): + """ElementBase(*children, attrib=None, nsmap=None, **_extra) + """ + cdef bint is_html = 0 + cdef _BaseParser parser + cdef _Element last_child + # don't use normal attribute access as it might be overridden + _getattr = object.__getattribute__ + try: + namespace = _utf8(_getattr(self, 'NAMESPACE')) + except AttributeError: + namespace = None + try: + ns, tag = _getNsTag(_getattr(self, 'TAG')) + if ns is not None: + namespace = ns + except AttributeError: + tag = _utf8(_getattr(_getattr(self, '__class__'), '__name__')) + if b'.' in tag: + tag = tag.split(b'.')[-1] + try: + parser = _getattr(self, 'PARSER') + except AttributeError: + parser = None + for child in children: + if isinstance(child, _Element): + parser = (<_Element>child)._doc._parser + break + if isinstance(parser, HTMLParser): + is_html = 1 + if namespace is None: + try: + is_html = _getattr(self, 'HTML') + except AttributeError: + pass + _initNewElement(self, is_html, tag, namespace, parser, + attrib, nsmap, _extra) + last_child = None + for child in children: + if _isString(child): + if last_child is None: + _setNodeText(self._c_node, + (_collectText(self._c_node.children) or '') + child) + else: + _setTailText(last_child._c_node, + (_collectText(last_child._c_node.next) or '') + child) + elif isinstance(child, _Element): + last_child = child + _appendChild(self, last_child) + elif isinstance(child, type) and issubclass(child, ElementBase): + last_child = child() + _appendChild(self, last_child) + else: + raise TypeError, f"Invalid child type: {type(child)!r}" + +cdef class CommentBase(_Comment): + """All custom Comment classes must inherit from this one. + + To create an XML Comment instance, use the ``Comment()`` factory. + + Subclasses *must not* override __init__ or __new__ as it is + absolutely undefined when these objects will be created or + destroyed. All persistent state of Comments must be stored in the + underlying XML. If you really need to initialize the object after + creation, you can implement an ``_init(self)`` method that will be + called after object creation. + """ + def __init__(self, text): + # copied from Comment() factory + cdef _Document doc + cdef xmlDoc* c_doc + if text is None: + text = b'' + else: + text = _utf8(text) + c_doc = _newXMLDoc() + doc = _documentFactory(c_doc, None) + self._c_node = _createComment(c_doc, _xcstr(text)) + if self._c_node is NULL: + raise MemoryError() + tree.xmlAddChild(c_doc, self._c_node) + _registerProxy(self, doc, self._c_node) + self._init() + +cdef class PIBase(_ProcessingInstruction): + """All custom Processing Instruction classes must inherit from this one. + + To create an XML ProcessingInstruction instance, use the ``PI()`` + factory. + + Subclasses *must not* override __init__ or __new__ as it is + absolutely undefined when these objects will be created or + destroyed. All persistent state of PIs must be stored in the + underlying XML. If you really need to initialize the object after + creation, you can implement an ``_init(self)`` method that will be + called after object creation. + """ + def __init__(self, target, text=None): + # copied from PI() factory + cdef _Document doc + cdef xmlDoc* c_doc + target = _utf8(target) + if text is None: + text = b'' + else: + text = _utf8(text) + c_doc = _newXMLDoc() + doc = _documentFactory(c_doc, None) + self._c_node = _createPI(c_doc, _xcstr(target), _xcstr(text)) + if self._c_node is NULL: + raise MemoryError() + tree.xmlAddChild(c_doc, self._c_node) + _registerProxy(self, doc, self._c_node) + self._init() + +cdef class EntityBase(_Entity): + """All custom Entity classes must inherit from this one. + + To create an XML Entity instance, use the ``Entity()`` factory. + + Subclasses *must not* override __init__ or __new__ as it is + absolutely undefined when these objects will be created or + destroyed. All persistent state of Entities must be stored in the + underlying XML. If you really need to initialize the object after + creation, you can implement an ``_init(self)`` method that will be + called after object creation. + """ + def __init__(self, name): + cdef _Document doc + cdef xmlDoc* c_doc + name_utf = _utf8(name) + c_name = _xcstr(name_utf) + if c_name[0] == c'#': + if not _characterReferenceIsValid(c_name + 1): + raise ValueError, f"Invalid character reference: '{name}'" + elif not _xmlNameIsValid(c_name): + raise ValueError, f"Invalid entity reference: '{name}'" + c_doc = _newXMLDoc() + doc = _documentFactory(c_doc, None) + self._c_node = _createEntity(c_doc, c_name) + if self._c_node is NULL: + raise MemoryError() + tree.xmlAddChild(c_doc, self._c_node) + _registerProxy(self, doc, self._c_node) + self._init() + + +cdef int _validateNodeClass(xmlNode* c_node, cls) except -1: + if c_node.type == tree.XML_ELEMENT_NODE: + expected = ElementBase + elif c_node.type == tree.XML_COMMENT_NODE: + expected = CommentBase + elif c_node.type == tree.XML_ENTITY_REF_NODE: + expected = EntityBase + elif c_node.type == tree.XML_PI_NODE: + expected = PIBase + else: + assert False, f"Unknown node type: {c_node.type}" + + if not (isinstance(cls, type) and issubclass(cls, expected)): + raise TypeError( + f"result of class lookup must be subclass of {type(expected)}, got {type(cls)}") + return 0 + + +################################################################################ +# Element class lookup + +ctypedef public object (*_element_class_lookup_function)(object, _Document, xmlNode*) + +# class to store element class lookup functions +cdef public class ElementClassLookup [ type LxmlElementClassLookupType, + object LxmlElementClassLookup ]: + """ElementClassLookup(self) + Superclass of Element class lookups. + """ + cdef _element_class_lookup_function _lookup_function + + +cdef public class FallbackElementClassLookup(ElementClassLookup) \ + [ type LxmlFallbackElementClassLookupType, + object LxmlFallbackElementClassLookup ]: + """FallbackElementClassLookup(self, fallback=None) + + Superclass of Element class lookups with additional fallback. + """ + cdef readonly ElementClassLookup fallback + cdef _element_class_lookup_function _fallback_function + def __cinit__(self): + # fall back to default lookup + self._fallback_function = _lookupDefaultElementClass + + def __init__(self, ElementClassLookup fallback=None): + if fallback is not None: + self._setFallback(fallback) + else: + self._fallback_function = _lookupDefaultElementClass + + cdef void _setFallback(self, ElementClassLookup lookup): + """Sets the fallback scheme for this lookup method. + """ + self.fallback = lookup + self._fallback_function = lookup._lookup_function + if self._fallback_function is NULL: + self._fallback_function = _lookupDefaultElementClass + + def set_fallback(self, ElementClassLookup lookup not None): + """set_fallback(self, lookup) + + Sets the fallback scheme for this lookup method. + """ + self._setFallback(lookup) + +cdef inline object _callLookupFallback(FallbackElementClassLookup lookup, + _Document doc, xmlNode* c_node): + return lookup._fallback_function(lookup.fallback, doc, c_node) + + +################################################################################ +# default lookup scheme + +cdef class ElementDefaultClassLookup(ElementClassLookup): + """ElementDefaultClassLookup(self, element=None, comment=None, pi=None, entity=None) + Element class lookup scheme that always returns the default Element + class. + + The keyword arguments ``element``, ``comment``, ``pi`` and ``entity`` + accept the respective Element classes. + """ + cdef readonly object element_class + cdef readonly object comment_class + cdef readonly object pi_class + cdef readonly object entity_class + def __cinit__(self): + self._lookup_function = _lookupDefaultElementClass + + def __init__(self, element=None, comment=None, pi=None, entity=None): + if element is None: + self.element_class = _Element + elif issubclass(element, ElementBase): + self.element_class = element + else: + raise TypeError, "element class must be subclass of ElementBase" + + if comment is None: + self.comment_class = _Comment + elif issubclass(comment, CommentBase): + self.comment_class = comment + else: + raise TypeError, "comment class must be subclass of CommentBase" + + if entity is None: + self.entity_class = _Entity + elif issubclass(entity, EntityBase): + self.entity_class = entity + else: + raise TypeError, "Entity class must be subclass of EntityBase" + + if pi is None: + self.pi_class = None # special case, see below + elif issubclass(pi, PIBase): + self.pi_class = pi + else: + raise TypeError, "PI class must be subclass of PIBase" + +cdef object _lookupDefaultElementClass(state, _Document _doc, xmlNode* c_node): + "Trivial class lookup function that always returns the default class." + if c_node.type == tree.XML_ELEMENT_NODE: + if state is not None: + return (state).element_class + else: + return _Element + elif c_node.type == tree.XML_COMMENT_NODE: + if state is not None: + return (state).comment_class + else: + return _Comment + elif c_node.type == tree.XML_ENTITY_REF_NODE: + if state is not None: + return (state).entity_class + else: + return _Entity + elif c_node.type == tree.XML_PI_NODE: + if state is None or (state).pi_class is None: + # special case XSLT-PI + if c_node.name is not NULL and c_node.content is not NULL: + if tree.xmlStrcmp(c_node.name, "xml-stylesheet") == 0: + if tree.xmlStrstr(c_node.content, "text/xsl") is not NULL or \ + tree.xmlStrstr(c_node.content, "text/xml") is not NULL: + return _XSLTProcessingInstruction + return _ProcessingInstruction + else: + return (state).pi_class + else: + assert False, f"Unknown node type: {c_node.type}" + + +################################################################################ +# attribute based lookup scheme + +cdef class AttributeBasedElementClassLookup(FallbackElementClassLookup): + """AttributeBasedElementClassLookup(self, attribute_name, class_mapping, fallback=None) + Checks an attribute of an Element and looks up the value in a + class dictionary. + + Arguments: + - attribute name - '{ns}name' style string + - class mapping - Python dict mapping attribute values to Element classes + - fallback - optional fallback lookup mechanism + + A None key in the class mapping will be checked if the attribute is + missing. + """ + cdef object _class_mapping + cdef tuple _pytag + cdef const_xmlChar* _c_ns + cdef const_xmlChar* _c_name + def __cinit__(self): + self._lookup_function = _attribute_class_lookup + + def __init__(self, attribute_name, class_mapping, + ElementClassLookup fallback=None): + self._pytag = _getNsTag(attribute_name) + ns, name = self._pytag + if ns is None: + self._c_ns = NULL + else: + self._c_ns = _xcstr(ns) + self._c_name = _xcstr(name) + self._class_mapping = dict(class_mapping) + + FallbackElementClassLookup.__init__(self, fallback) + +cdef object _attribute_class_lookup(state, _Document doc, xmlNode* c_node): + cdef AttributeBasedElementClassLookup lookup + cdef python.PyObject* dict_result + + lookup = state + if c_node.type == tree.XML_ELEMENT_NODE: + value = _attributeValueFromNsName( + c_node, lookup._c_ns, lookup._c_name) + dict_result = python.PyDict_GetItem(lookup._class_mapping, value) + if dict_result is not NULL: + cls = dict_result + _validateNodeClass(c_node, cls) + return cls + return _callLookupFallback(lookup, doc, c_node) + + +################################################################################ +# per-parser lookup scheme + +cdef class ParserBasedElementClassLookup(FallbackElementClassLookup): + """ParserBasedElementClassLookup(self, fallback=None) + Element class lookup based on the XML parser. + """ + def __cinit__(self): + self._lookup_function = _parser_class_lookup + +cdef object _parser_class_lookup(state, _Document doc, xmlNode* c_node): + if doc._parser._class_lookup is not None: + return doc._parser._class_lookup._lookup_function( + doc._parser._class_lookup, doc, c_node) + return _callLookupFallback(state, doc, c_node) + + +################################################################################ +# custom class lookup based on node type, namespace, name + +cdef class CustomElementClassLookup(FallbackElementClassLookup): + """CustomElementClassLookup(self, fallback=None) + Element class lookup based on a subclass method. + + You can inherit from this class and override the method:: + + lookup(self, type, doc, namespace, name) + + to lookup the element class for a node. Arguments of the method: + * type: one of 'element', 'comment', 'PI', 'entity' + * doc: document that the node is in + * namespace: namespace URI of the node (or None for comments/PIs/entities) + * name: name of the element/entity, None for comments, target for PIs + + If you return None from this method, the fallback will be called. + """ + def __cinit__(self): + self._lookup_function = _custom_class_lookup + + def lookup(self, type, doc, namespace, name): + "lookup(self, type, doc, namespace, name)" + return None + +cdef object _custom_class_lookup(state, _Document doc, xmlNode* c_node): + cdef CustomElementClassLookup lookup + + lookup = state + + if c_node.type == tree.XML_ELEMENT_NODE: + element_type = "element" + elif c_node.type == tree.XML_COMMENT_NODE: + element_type = "comment" + elif c_node.type == tree.XML_PI_NODE: + element_type = "PI" + elif c_node.type == tree.XML_ENTITY_REF_NODE: + element_type = "entity" + else: + element_type = "element" + if c_node.name is NULL: + name = None + else: + name = funicode(c_node.name) + c_str = tree._getNs(c_node) + ns = funicode(c_str) if c_str is not NULL else None + + cls = lookup.lookup(element_type, doc, ns, name) + if cls is not None: + _validateNodeClass(c_node, cls) + return cls + return _callLookupFallback(lookup, doc, c_node) + + +################################################################################ +# read-only tree based class lookup + +cdef class PythonElementClassLookup(FallbackElementClassLookup): + """PythonElementClassLookup(self, fallback=None) + Element class lookup based on a subclass method. + + This class lookup scheme allows access to the entire XML tree in + read-only mode. To use it, re-implement the ``lookup(self, doc, + root)`` method in a subclass:: + + from lxml import etree, pyclasslookup + + class MyElementClass(etree.ElementBase): + honkey = True + + class MyLookup(pyclasslookup.PythonElementClassLookup): + def lookup(self, doc, root): + if root.tag == "sometag": + return MyElementClass + else: + for child in root: + if child.tag == "someothertag": + return MyElementClass + # delegate to default + return None + + If you return None from this method, the fallback will be called. + + The first argument is the opaque document instance that contains + the Element. The second argument is a lightweight Element proxy + implementation that is only valid during the lookup. Do not try + to keep a reference to it. Once the lookup is done, the proxy + will be invalid. + + Also, you cannot wrap such a read-only Element in an ElementTree, + and you must take care not to keep a reference to them outside of + the `lookup()` method. + + Note that the API of the Element objects is not complete. It is + purely read-only and does not support all features of the normal + `lxml.etree` API (such as XPath, extended slicing or some + iteration methods). + + See https://lxml.de/element_classes.html + """ + def __cinit__(self): + self._lookup_function = _python_class_lookup + + def lookup(self, doc, element): + """lookup(self, doc, element) + + Override this method to implement your own lookup scheme. + """ + return None + +cdef object _python_class_lookup(state, _Document doc, tree.xmlNode* c_node): + cdef PythonElementClassLookup lookup + cdef _ReadOnlyProxy proxy + lookup = state + + proxy = _newReadOnlyProxy(None, c_node) + cls = lookup.lookup(doc, proxy) + _freeReadOnlyProxies(proxy) + + if cls is not None: + _validateNodeClass(c_node, cls) + return cls + return _callLookupFallback(lookup, doc, c_node) + +################################################################################ +# Global setup + +cdef _element_class_lookup_function LOOKUP_ELEMENT_CLASS +cdef object ELEMENT_CLASS_LOOKUP_STATE + +cdef void _setElementClassLookupFunction( + _element_class_lookup_function function, object state): + global LOOKUP_ELEMENT_CLASS, ELEMENT_CLASS_LOOKUP_STATE + if function is NULL: + state = DEFAULT_ELEMENT_CLASS_LOOKUP + function = DEFAULT_ELEMENT_CLASS_LOOKUP._lookup_function + + ELEMENT_CLASS_LOOKUP_STATE = state + LOOKUP_ELEMENT_CLASS = function + +def set_element_class_lookup(ElementClassLookup lookup = None): + """set_element_class_lookup(lookup = None) + + Set the global element class lookup method. + + This defines the main entry point for looking up element implementations. + The standard implementation uses the :class:`ParserBasedElementClassLookup` + to delegate to different lookup schemes for each parser. + + .. warning:: + + This should only be changed by applications, not by library packages. + In most cases, parser specific lookups should be preferred, + which can be configured via + :meth:`~lxml.etree.XMLParser.set_element_class_lookup` + (and the same for HTML parsers). + + Globally replacing the element class lookup by something other than a + :class:`ParserBasedElementClassLookup` will prevent parser specific lookup + schemes from working. Several tools rely on parser specific lookups, + including :mod:`lxml.html` and :mod:`lxml.objectify`. + """ + if lookup is None or lookup._lookup_function is NULL: + _setElementClassLookupFunction(NULL, None) + else: + _setElementClassLookupFunction(lookup._lookup_function, lookup) + +# default setup: parser delegation +cdef ParserBasedElementClassLookup DEFAULT_ELEMENT_CLASS_LOOKUP +DEFAULT_ELEMENT_CLASS_LOOKUP = ParserBasedElementClassLookup() + +set_element_class_lookup(DEFAULT_ELEMENT_CLASS_LOOKUP) diff --git a/presentation/.venv/lib/python3.12/site-packages/lxml/cleanup.pxi b/presentation/.venv/lib/python3.12/site-packages/lxml/cleanup.pxi new file mode 100644 index 0000000..8e266b3 --- /dev/null +++ b/presentation/.venv/lib/python3.12/site-packages/lxml/cleanup.pxi @@ -0,0 +1,215 @@ +# functions for tree cleanup and removing elements from subtrees + +def cleanup_namespaces(tree_or_element, top_nsmap=None, keep_ns_prefixes=None): + """cleanup_namespaces(tree_or_element, top_nsmap=None, keep_ns_prefixes=None) + + Remove all namespace declarations from a subtree that are not used + by any of the elements or attributes in that tree. + + If a 'top_nsmap' is provided, it must be a mapping from prefixes + to namespace URIs. These namespaces will be declared on the top + element of the subtree before running the cleanup, which allows + moving namespace declarations to the top of the tree. + + If a 'keep_ns_prefixes' is provided, it must be a list of prefixes. + These prefixes will not be removed as part of the cleanup. + """ + element = _rootNodeOrRaise(tree_or_element) + c_element = element._c_node + + if top_nsmap: + doc = element._doc + # declare namespaces from nsmap, then apply them to the subtree + _setNodeNamespaces(c_element, doc, None, top_nsmap) + moveNodeToDocument(doc, c_element.doc, c_element) + + keep_ns_prefixes = ( + set([_utf8(prefix) for prefix in keep_ns_prefixes]) + if keep_ns_prefixes else None) + + _removeUnusedNamespaceDeclarations(c_element, keep_ns_prefixes) + + +def strip_attributes(tree_or_element, *attribute_names): + """strip_attributes(tree_or_element, *attribute_names) + + Delete all attributes with the provided attribute names from an + Element (or ElementTree) and its descendants. + + Attribute names can contain wildcards as in `_Element.iter`. + + Example usage:: + + strip_attributes(root_element, + 'simpleattr', + '{http://some/ns}attrname', + '{http://other/ns}*') + """ + cdef _MultiTagMatcher matcher + element = _rootNodeOrRaise(tree_or_element) + if not attribute_names: + return + + matcher = _MultiTagMatcher.__new__(_MultiTagMatcher, attribute_names) + matcher.cacheTags(element._doc) + if matcher.rejectsAllAttributes(): + return + _strip_attributes(element._c_node, matcher) + + +cdef _strip_attributes(xmlNode* c_node, _MultiTagMatcher matcher): + cdef xmlAttr* c_attr + cdef xmlAttr* c_next_attr + tree.BEGIN_FOR_EACH_ELEMENT_FROM(c_node, c_node, 1) + if c_node.type == tree.XML_ELEMENT_NODE: + c_attr = c_node.properties + while c_attr is not NULL: + c_next_attr = c_attr.next + if matcher.matchesAttribute(c_attr): + tree.xmlRemoveProp(c_attr) + c_attr = c_next_attr + tree.END_FOR_EACH_ELEMENT_FROM(c_node) + + +def strip_elements(tree_or_element, *tag_names, bint with_tail=True): + """strip_elements(tree_or_element, *tag_names, with_tail=True) + + Delete all elements with the provided tag names from a tree or + subtree. This will remove the elements and their entire subtree, + including all their attributes, text content and descendants. It + will also remove the tail text of the element unless you + explicitly set the ``with_tail`` keyword argument option to False. + + Tag names can contain wildcards as in `_Element.iter`. + + Note that this will not delete the element (or ElementTree root + element) that you passed even if it matches. It will only treat + its descendants. If you want to include the root element, check + its tag name directly before even calling this function. + + Example usage:: + + strip_elements(some_element, + 'simpletagname', # non-namespaced tag + '{http://some/ns}tagname', # namespaced tag + '{http://some/other/ns}*' # any tag from a namespace + lxml.etree.Comment # comments + ) + """ + cdef _MultiTagMatcher matcher + doc = _documentOrRaise(tree_or_element) + element = _rootNodeOrRaise(tree_or_element) + if not tag_names: + return + + matcher = _MultiTagMatcher.__new__(_MultiTagMatcher, tag_names) + matcher.cacheTags(doc) + if matcher.rejectsAll(): + return + + if isinstance(tree_or_element, _ElementTree): + # include PIs and comments next to the root node + if matcher.matchesType(tree.XML_COMMENT_NODE): + _removeSiblings(element._c_node, tree.XML_COMMENT_NODE, with_tail) + if matcher.matchesType(tree.XML_PI_NODE): + _removeSiblings(element._c_node, tree.XML_PI_NODE, with_tail) + _strip_elements(doc, element._c_node, matcher, with_tail) + +cdef _strip_elements(_Document doc, xmlNode* c_node, _MultiTagMatcher matcher, + bint with_tail): + cdef xmlNode* c_child + cdef xmlNode* c_next + + tree.BEGIN_FOR_EACH_ELEMENT_FROM(c_node, c_node, 1) + if c_node.type == tree.XML_ELEMENT_NODE: + # we run through the children here to prevent any problems + # with the tree iteration which would occur if we unlinked the + # c_node itself + c_child = _findChildForwards(c_node, 0) + while c_child is not NULL: + c_next = _nextElement(c_child) + if matcher.matches(c_child): + if c_child.type == tree.XML_ELEMENT_NODE: + if not with_tail: + tree.xmlUnlinkNode(c_child) + _removeNode(doc, c_child) + else: + if with_tail: + _removeText(c_child.next) + tree.xmlUnlinkNode(c_child) + attemptDeallocation(c_child) + c_child = c_next + tree.END_FOR_EACH_ELEMENT_FROM(c_node) + + +def strip_tags(tree_or_element, *tag_names): + """strip_tags(tree_or_element, *tag_names) + + Delete all elements with the provided tag names from a tree or + subtree. This will remove the elements and their attributes, but + *not* their text/tail content or descendants. Instead, it will + merge the text content and children of the element into its + parent. + + Tag names can contain wildcards as in `_Element.iter`. + + Note that this will not delete the element (or ElementTree root + element) that you passed even if it matches. It will only treat + its descendants. + + Example usage:: + + strip_tags(some_element, + 'simpletagname', # non-namespaced tag + '{http://some/ns}tagname', # namespaced tag + '{http://some/other/ns}*' # any tag from a namespace + Comment # comments (including their text!) + ) + """ + cdef _MultiTagMatcher matcher + doc = _documentOrRaise(tree_or_element) + element = _rootNodeOrRaise(tree_or_element) + if not tag_names: + return + + matcher = _MultiTagMatcher.__new__(_MultiTagMatcher, tag_names) + matcher.cacheTags(doc) + if matcher.rejectsAll(): + return + + if isinstance(tree_or_element, _ElementTree): + # include PIs and comments next to the root node + if matcher.matchesType(tree.XML_COMMENT_NODE): + _removeSiblings(element._c_node, tree.XML_COMMENT_NODE, 0) + if matcher.matchesType(tree.XML_PI_NODE): + _removeSiblings(element._c_node, tree.XML_PI_NODE, 0) + _strip_tags(doc, element._c_node, matcher) + +cdef _strip_tags(_Document doc, xmlNode* c_node, _MultiTagMatcher matcher): + cdef xmlNode* c_child + cdef xmlNode* c_next + + tree.BEGIN_FOR_EACH_ELEMENT_FROM(c_node, c_node, 1) + if c_node.type == tree.XML_ELEMENT_NODE: + # we run through the children here to prevent any problems + # with the tree iteration which would occur if we unlinked the + # c_node itself + c_child = _findChildForwards(c_node, 0) + while c_child is not NULL: + if not matcher.matches(c_child): + c_child = _nextElement(c_child) + continue + if c_child.type == tree.XML_ELEMENT_NODE: + c_next = _findChildForwards(c_child, 0) or _nextElement(c_child) + _replaceNodeByChildren(doc, c_child) + if not attemptDeallocation(c_child): + if c_child.nsDef is not NULL: + # make namespaces absolute + moveNodeToDocument(doc, doc._c_doc, c_child) + c_child = c_next + else: + c_next = _nextElement(c_child) + tree.xmlUnlinkNode(c_child) + attemptDeallocation(c_child) + c_child = c_next + tree.END_FOR_EACH_ELEMENT_FROM(c_node) diff --git a/presentation/.venv/lib/python3.12/site-packages/lxml/cssselect.py b/presentation/.venv/lib/python3.12/site-packages/lxml/cssselect.py new file mode 100644 index 0000000..54cd75a --- /dev/null +++ b/presentation/.venv/lib/python3.12/site-packages/lxml/cssselect.py @@ -0,0 +1,101 @@ +"""CSS Selectors based on XPath. + +This module supports selecting XML/HTML tags based on CSS selectors. +See the `CSSSelector` class for details. + +This is a thin wrapper around cssselect 0.7 or later. +""" + + +from . import etree +try: + import cssselect as external_cssselect +except ImportError: + raise ImportError( + 'cssselect does not seem to be installed. ' + 'See https://pypi.org/project/cssselect/') + + +SelectorSyntaxError = external_cssselect.SelectorSyntaxError +ExpressionError = external_cssselect.ExpressionError +SelectorError = external_cssselect.SelectorError + + +__all__ = ['SelectorSyntaxError', 'ExpressionError', 'SelectorError', + 'CSSSelector'] + + +class LxmlTranslator(external_cssselect.GenericTranslator): + """ + A custom CSS selector to XPath translator with lxml-specific extensions. + """ + def xpath_contains_function(self, xpath, function): + # Defined there, removed in later drafts: + # http://www.w3.org/TR/2001/CR-css3-selectors-20011113/#content-selectors + if function.argument_types() not in (['STRING'], ['IDENT']): + raise ExpressionError( + "Expected a single string or ident for :contains(), got %r" + % function.arguments) + value = function.arguments[0].value + return xpath.add_condition( + 'contains(__lxml_internal_css:lower-case(string(.)), %s)' + % self.xpath_literal(value.lower())) + + +class LxmlHTMLTranslator(LxmlTranslator, external_cssselect.HTMLTranslator): + """ + lxml extensions + HTML support. + """ + + +def _make_lower_case(context, s): + return s.lower() + +ns = etree.FunctionNamespace('http://codespeak.net/lxml/css/') +ns.prefix = '__lxml_internal_css' +ns['lower-case'] = _make_lower_case + + +class CSSSelector(etree.XPath): + """A CSS selector. + + Usage:: + + >>> from lxml import etree, cssselect + >>> select = cssselect.CSSSelector("a tag > child") + + >>> root = etree.XML("TEXT") + >>> [ el.tag for el in select(root) ] + ['child'] + + To use CSS namespaces, you need to pass a prefix-to-namespace + mapping as ``namespaces`` keyword argument:: + + >>> rdfns = 'http://www.w3.org/1999/02/22-rdf-syntax-ns#' + >>> select_ns = cssselect.CSSSelector('root > rdf|Description', + ... namespaces={'rdf': rdfns}) + + >>> rdf = etree.XML(( + ... '' + ... 'blah' + ... '') % rdfns) + >>> [(el.tag, el.text) for el in select_ns(rdf)] + [('{http://www.w3.org/1999/02/22-rdf-syntax-ns#}Description', 'blah')] + + """ + def __init__(self, css, namespaces=None, translator='xml'): + if translator == 'xml': + translator = LxmlTranslator() + elif translator == 'html': + translator = LxmlHTMLTranslator() + elif translator == 'xhtml': + translator = LxmlHTMLTranslator(xhtml=True) + path = translator.css_to_xpath(css) + super().__init__(path, namespaces=namespaces) + self.css = css + + def __repr__(self): + return '<%s %x for %r>' % ( + self.__class__.__name__, + abs(id(self)), + self.css) diff --git a/presentation/.venv/lib/python3.12/site-packages/lxml/debug.pxi b/presentation/.venv/lib/python3.12/site-packages/lxml/debug.pxi new file mode 100644 index 0000000..d728e84 --- /dev/null +++ b/presentation/.venv/lib/python3.12/site-packages/lxml/debug.pxi @@ -0,0 +1,36 @@ +@cython.final +@cython.internal +cdef class _MemDebug: + """Debugging support for the memory allocation in libxml2. + """ + def bytes_used(self): + """bytes_used(self) + + Returns the total amount of memory (in bytes) currently used by libxml2. + Note that libxml2 constrains this value to a C int, which limits + the accuracy on 64 bit systems. + """ + return tree.xmlMemUsed() + + def blocks_used(self): + """blocks_used(self) + + Returns the total number of memory blocks currently allocated by libxml2. + Note that libxml2 constrains this value to a C int, which limits + the accuracy on 64 bit systems. + """ + return tree.xmlMemBlocks() + + def dict_size(self): + """dict_size(self) + + Returns the current size of the global name dictionary used by libxml2 + for the current thread. Each thread has its own dictionary. + """ + c_dict = __GLOBAL_PARSER_CONTEXT._getThreadDict(NULL) + if c_dict is NULL: + raise MemoryError() + return tree.xmlDictSize(c_dict) + + +memory_debugger = _MemDebug() diff --git a/presentation/.venv/lib/python3.12/site-packages/lxml/docloader.pxi b/presentation/.venv/lib/python3.12/site-packages/lxml/docloader.pxi new file mode 100644 index 0000000..07e0cd7 --- /dev/null +++ b/presentation/.venv/lib/python3.12/site-packages/lxml/docloader.pxi @@ -0,0 +1,179 @@ +# Custom resolver API + +ctypedef enum _InputDocumentDataType: + PARSER_DATA_INVALID + PARSER_DATA_EMPTY + PARSER_DATA_STRING + PARSER_DATA_FILENAME + PARSER_DATA_FILE + +@cython.final +@cython.internal +cdef class _InputDocument: + cdef _InputDocumentDataType _type + cdef bytes _data_bytes + cdef object _filename + cdef object _file + cdef bint _close_file + + def __cinit__(self): + self._type = PARSER_DATA_INVALID + + +cdef class Resolver: + "This is the base class of all resolvers." + def resolve(self, system_url, public_id, context): + """resolve(self, system_url, public_id, context) + + Override this method to resolve an external source by + ``system_url`` and ``public_id``. The third argument is an + opaque context object. + + Return the result of one of the ``resolve_*()`` methods. + """ + return None + + def resolve_empty(self, context): + """resolve_empty(self, context) + + Return an empty input document. + + Pass context as parameter. + """ + cdef _InputDocument doc_ref + doc_ref = _InputDocument() + doc_ref._type = PARSER_DATA_EMPTY + return doc_ref + + def resolve_string(self, string, context, *, base_url=None): + """resolve_string(self, string, context, base_url=None) + + Return a parsable string as input document. + + Pass data string and context as parameters. You can pass the + source URL or filename through the ``base_url`` keyword + argument. + """ + cdef _InputDocument doc_ref + if isinstance(string, unicode): + string = (string).encode('utf8') + elif not isinstance(string, bytes): + raise TypeError, "argument must be a byte string or unicode string" + doc_ref = _InputDocument() + doc_ref._type = PARSER_DATA_STRING + doc_ref._data_bytes = string + if base_url is not None: + doc_ref._filename = _encodeFilename(base_url) + return doc_ref + + def resolve_filename(self, filename, context): + """resolve_filename(self, filename, context) + + Return the name of a parsable file as input document. + + Pass filename and context as parameters. You can also pass a + URL with an HTTP, FTP or file target. + """ + cdef _InputDocument doc_ref + doc_ref = _InputDocument() + doc_ref._type = PARSER_DATA_FILENAME + doc_ref._filename = _encodeFilename(filename) + return doc_ref + + def resolve_file(self, f, context, *, base_url=None, bint close=True): + """resolve_file(self, f, context, base_url=None, close=True) + + Return an open file-like object as input document. + + Pass open file and context as parameters. You can pass the + base URL or filename of the file through the ``base_url`` + keyword argument. If the ``close`` flag is True (the + default), the file will be closed after reading. + + Note that using ``.resolve_filename()`` is more efficient, + especially in threaded environments. + """ + cdef _InputDocument doc_ref + try: + f.read + except AttributeError: + raise TypeError, "Argument is not a file-like object" + doc_ref = _InputDocument() + doc_ref._type = PARSER_DATA_FILE + if base_url is not None: + doc_ref._filename = _encodeFilename(base_url) + else: + doc_ref._filename = _getFilenameForFile(f) + doc_ref._close_file = close + doc_ref._file = f + return doc_ref + +@cython.final +@cython.internal +cdef class _ResolverRegistry: + cdef object _resolvers + cdef Resolver _default_resolver + def __cinit__(self, Resolver default_resolver=None): + self._resolvers = set() + self._default_resolver = default_resolver + + def add(self, Resolver resolver not None): + """add(self, resolver) + + Register a resolver. + + For each requested entity, the 'resolve' method of the resolver will + be called and the result will be passed to the parser. If this method + returns None, the request will be delegated to other resolvers or the + default resolver. The resolvers will be tested in an arbitrary order + until the first match is found. + """ + self._resolvers.add(resolver) + + def remove(self, resolver): + "remove(self, resolver)" + self._resolvers.discard(resolver) + + cdef _ResolverRegistry _copy(self): + cdef _ResolverRegistry registry + registry = _ResolverRegistry(self._default_resolver) + registry._resolvers = self._resolvers.copy() + return registry + + def copy(self): + "copy(self)" + return self._copy() + + def resolve(self, system_url, public_id, context): + "resolve(self, system_url, public_id, context)" + for resolver in self._resolvers: + result = resolver.resolve(system_url, public_id, context) + if result is not None: + return result + if self._default_resolver is None: + return None + return self._default_resolver.resolve(system_url, public_id, context) + + def __repr__(self): + return repr(self._resolvers) + + +@cython.internal +cdef class _ResolverContext(_ExceptionContext): + cdef _ResolverRegistry _resolvers + cdef _TempStore _storage + + @cython.final + cdef int clear(self) except -1: + _ExceptionContext.clear(self) + self._storage.clear() + return 0 + + +cdef _initResolverContext(_ResolverContext context, + _ResolverRegistry resolvers): + if resolvers is None: + context._resolvers = _ResolverRegistry() + else: + context._resolvers = resolvers + context._storage = _TempStore() diff --git a/presentation/.venv/lib/python3.12/site-packages/lxml/doctestcompare.py b/presentation/.venv/lib/python3.12/site-packages/lxml/doctestcompare.py new file mode 100644 index 0000000..8099771 --- /dev/null +++ b/presentation/.venv/lib/python3.12/site-packages/lxml/doctestcompare.py @@ -0,0 +1,488 @@ +""" +lxml-based doctest output comparison. + +Note: normally, you should just import the `lxml.usedoctest` and +`lxml.html.usedoctest` modules from within a doctest, instead of this +one:: + + >>> import lxml.usedoctest # for XML output + + >>> import lxml.html.usedoctest # for HTML output + +To use this module directly, you must call ``lxmldoctest.install()``, +which will cause doctest to use this in all subsequent calls. + +This changes the way output is checked and comparisons are made for +XML or HTML-like content. + +XML or HTML content is noticed because the example starts with ``<`` +(it's HTML if it starts with ```` or include an ``any`` +attribute in the tag. An ``any`` tag matches any tag, while the +attribute matches any and all attributes. + +When a match fails, the reformatted example and gotten text is +displayed (indented), and a rough diff-like output is given. Anything +marked with ``+`` is in the output but wasn't supposed to be, and +similarly ``-`` means its in the example but wasn't in the output. + +You can disable parsing on one line with ``# doctest:+NOPARSE_MARKUP`` +""" + +from lxml import etree +import sys +import re +import doctest +try: + from html import escape as html_escape +except ImportError: + from cgi import escape as html_escape + +__all__ = ['PARSE_HTML', 'PARSE_XML', 'NOPARSE_MARKUP', 'LXMLOutputChecker', + 'LHTMLOutputChecker', 'install', 'temp_install'] + +PARSE_HTML = doctest.register_optionflag('PARSE_HTML') +PARSE_XML = doctest.register_optionflag('PARSE_XML') +NOPARSE_MARKUP = doctest.register_optionflag('NOPARSE_MARKUP') + +OutputChecker = doctest.OutputChecker + +def strip(v): + if v is None: + return None + else: + return v.strip() + +def norm_whitespace(v): + return _norm_whitespace_re.sub(' ', v) + +_html_parser = etree.HTMLParser(recover=False, remove_blank_text=True) + +def html_fromstring(html): + return etree.fromstring(html, _html_parser) + +# We use this to distinguish repr()s from elements: +_repr_re = re.compile(r'^<[^>]+ (at|object) ') +_norm_whitespace_re = re.compile(r'[ \t\n][ \t\n]+') + +class LXMLOutputChecker(OutputChecker): + + empty_tags = ( + 'param', 'img', 'area', 'br', 'basefont', 'input', + 'base', 'meta', 'link', 'col') + + def get_default_parser(self): + return etree.XML + + def check_output(self, want, got, optionflags): + alt_self = getattr(self, '_temp_override_self', None) + if alt_self is not None: + super_method = self._temp_call_super_check_output + self = alt_self + else: + super_method = OutputChecker.check_output + parser = self.get_parser(want, got, optionflags) + if not parser: + return super_method( + self, want, got, optionflags) + try: + want_doc = parser(want) + except etree.XMLSyntaxError: + return False + try: + got_doc = parser(got) + except etree.XMLSyntaxError: + return False + return self.compare_docs(want_doc, got_doc) + + def get_parser(self, want, got, optionflags): + parser = None + if NOPARSE_MARKUP & optionflags: + return None + if PARSE_HTML & optionflags: + parser = html_fromstring + elif PARSE_XML & optionflags: + parser = etree.XML + elif (want.strip().lower().startswith('' % el.tag + return '<%s %s>' % (el.tag, ' '.join(attrs)) + + def format_end_tag(self, el): + if isinstance(el, etree.CommentBase): + # FIXME: probably PIs should be handled specially too? + return '-->' + return '' % el.tag + + def collect_diff(self, want, got, html, indent): + parts = [] + if not len(want) and not len(got): + parts.append(' '*indent) + parts.append(self.collect_diff_tag(want, got)) + if not self.html_empty_tag(got, html): + parts.append(self.collect_diff_text(want.text, got.text)) + parts.append(self.collect_diff_end_tag(want, got)) + parts.append(self.collect_diff_text(want.tail, got.tail)) + parts.append('\n') + return ''.join(parts) + parts.append(' '*indent) + parts.append(self.collect_diff_tag(want, got)) + parts.append('\n') + if strip(want.text) or strip(got.text): + parts.append(' '*indent) + parts.append(self.collect_diff_text(want.text, got.text)) + parts.append('\n') + want_children = list(want) + got_children = list(got) + while want_children or got_children: + if not want_children: + parts.append(self.format_doc(got_children.pop(0), html, indent+2, '+')) + continue + if not got_children: + parts.append(self.format_doc(want_children.pop(0), html, indent+2, '-')) + continue + parts.append(self.collect_diff( + want_children.pop(0), got_children.pop(0), html, indent+2)) + parts.append(' '*indent) + parts.append(self.collect_diff_end_tag(want, got)) + parts.append('\n') + if strip(want.tail) or strip(got.tail): + parts.append(' '*indent) + parts.append(self.collect_diff_text(want.tail, got.tail)) + parts.append('\n') + return ''.join(parts) + + def collect_diff_tag(self, want, got): + if not self.tag_compare(want.tag, got.tag): + tag = '%s (got: %s)' % (want.tag, got.tag) + else: + tag = got.tag + attrs = [] + any = want.tag == 'any' or 'any' in want.attrib + for name, value in sorted(got.attrib.items()): + if name not in want.attrib and not any: + attrs.append('+%s="%s"' % (name, self.format_text(value, False))) + else: + if name in want.attrib: + text = self.collect_diff_text(want.attrib[name], value, False) + else: + text = self.format_text(value, False) + attrs.append('%s="%s"' % (name, text)) + if not any: + for name, value in sorted(want.attrib.items()): + if name in got.attrib: + continue + attrs.append('-%s="%s"' % (name, self.format_text(value, False))) + if attrs: + tag = '<%s %s>' % (tag, ' '.join(attrs)) + else: + tag = '<%s>' % tag + return tag + + def collect_diff_end_tag(self, want, got): + if want.tag != got.tag: + tag = '%s (got: %s)' % (want.tag, got.tag) + else: + tag = got.tag + return '' % tag + + def collect_diff_text(self, want, got, strip=True): + if self.text_compare(want, got, strip): + if not got: + return '' + return self.format_text(got, strip) + text = '%s (got: %s)' % (want, got) + return self.format_text(text, strip) + +class LHTMLOutputChecker(LXMLOutputChecker): + def get_default_parser(self): + return html_fromstring + +def install(html=False): + """ + Install doctestcompare for all future doctests. + + If html is true, then by default the HTML parser will be used; + otherwise the XML parser is used. + """ + if html: + doctest.OutputChecker = LHTMLOutputChecker + else: + doctest.OutputChecker = LXMLOutputChecker + +def temp_install(html=False, del_module=None): + """ + Use this *inside* a doctest to enable this checker for this + doctest only. + + If html is true, then by default the HTML parser will be used; + otherwise the XML parser is used. + """ + if html: + Checker = LHTMLOutputChecker + else: + Checker = LXMLOutputChecker + frame = _find_doctest_frame() + dt_self = frame.f_locals['self'] + checker = Checker() + old_checker = dt_self._checker + dt_self._checker = checker + # The unfortunate thing is that there is a local variable 'check' + # in the function that runs the doctests, that is a bound method + # into the output checker. We have to update that. We can't + # modify the frame, so we have to modify the object in place. The + # only way to do this is to actually change the func_code + # attribute of the method. We change it, and then wait for + # __record_outcome to be run, which signals the end of the __run + # method, at which point we restore the previous check_output + # implementation. + check_func = frame.f_locals['check'].__func__ + checker_check_func = checker.check_output.__func__ + # Because we can't patch up func_globals, this is the only global + # in check_output that we care about: + doctest.etree = etree + _RestoreChecker(dt_self, old_checker, checker, + check_func, checker_check_func, + del_module) + +class _RestoreChecker: + def __init__(self, dt_self, old_checker, new_checker, check_func, clone_func, + del_module): + self.dt_self = dt_self + self.checker = old_checker + self.checker._temp_call_super_check_output = self.call_super + self.checker._temp_override_self = new_checker + self.check_func = check_func + self.clone_func = clone_func + self.del_module = del_module + self.install_clone() + self.install_dt_self() + def install_clone(self): + self.func_code = self.check_func.__code__ + self.func_globals = self.check_func.__globals__ + self.check_func.__code__ = self.clone_func.__code__ + def uninstall_clone(self): + self.check_func.__code__ = self.func_code + def install_dt_self(self): + self.prev_func = self.dt_self._DocTestRunner__record_outcome + self.dt_self._DocTestRunner__record_outcome = self + def uninstall_dt_self(self): + self.dt_self._DocTestRunner__record_outcome = self.prev_func + def uninstall_module(self): + if self.del_module: + import sys + del sys.modules[self.del_module] + if '.' in self.del_module: + package, module = self.del_module.rsplit('.', 1) + package_mod = sys.modules[package] + delattr(package_mod, module) + def __call__(self, *args, **kw): + self.uninstall_clone() + self.uninstall_dt_self() + del self.checker._temp_override_self + del self.checker._temp_call_super_check_output + result = self.prev_func(*args, **kw) + self.uninstall_module() + return result + def call_super(self, *args, **kw): + self.uninstall_clone() + try: + return self.check_func(*args, **kw) + finally: + self.install_clone() + +def _find_doctest_frame(): + import sys + frame = sys._getframe(1) + while frame: + l = frame.f_locals + if 'BOOM' in l: + # Sign of doctest + return frame + frame = frame.f_back + raise LookupError( + "Could not find doctest (only use this function *inside* a doctest)") + +__test__ = { + 'basic': ''' + >>> temp_install() + >>> print """stuff""" + ... + >>> print """""" + + + + >>> print """blahblahblah""" # doctest: +NOPARSE_MARKUP, +ELLIPSIS + ...foo /> + '''} + +if __name__ == '__main__': + import doctest + doctest.testmod() + + diff --git a/presentation/.venv/lib/python3.12/site-packages/lxml/dtd.pxi b/presentation/.venv/lib/python3.12/site-packages/lxml/dtd.pxi new file mode 100644 index 0000000..ee1b3d4 --- /dev/null +++ b/presentation/.venv/lib/python3.12/site-packages/lxml/dtd.pxi @@ -0,0 +1,479 @@ +# support for DTD validation +from lxml.includes cimport dtdvalid + +cdef class DTDError(LxmlError): + """Base class for DTD errors. + """ + +cdef class DTDParseError(DTDError): + """Error while parsing a DTD. + """ + +cdef class DTDValidateError(DTDError): + """Error while validating an XML document with a DTD. + """ + + +cdef inline int _assertValidDTDNode(node, void *c_node) except -1: + assert c_node is not NULL, "invalid DTD proxy at %s" % id(node) + + +@cython.final +@cython.internal +@cython.freelist(8) +cdef class _DTDElementContentDecl: + cdef DTD _dtd + cdef tree.xmlElementContent* _c_node + + def __repr__(self): + return "<%s.%s object name=%r type=%r occur=%r at 0x%x>" % (self.__class__.__module__, self.__class__.__name__, self.name, self.type, self.occur, id(self)) + + @property + def name(self): + _assertValidDTDNode(self, self._c_node) + return funicodeOrNone(self._c_node.name) + + @property + def type(self): + _assertValidDTDNode(self, self._c_node) + cdef int type = self._c_node.type + if type == tree.XML_ELEMENT_CONTENT_PCDATA: + return "pcdata" + elif type == tree.XML_ELEMENT_CONTENT_ELEMENT: + return "element" + elif type == tree.XML_ELEMENT_CONTENT_SEQ: + return "seq" + elif type == tree.XML_ELEMENT_CONTENT_OR: + return "or" + else: + return None + + @property + def occur(self): + _assertValidDTDNode(self, self._c_node) + cdef int occur = self._c_node.ocur + if occur == tree.XML_ELEMENT_CONTENT_ONCE: + return "once" + elif occur == tree.XML_ELEMENT_CONTENT_OPT: + return "opt" + elif occur == tree.XML_ELEMENT_CONTENT_MULT: + return "mult" + elif occur == tree.XML_ELEMENT_CONTENT_PLUS: + return "plus" + else: + return None + + @property + def left(self): + _assertValidDTDNode(self, self._c_node) + c1 = self._c_node.c1 + if c1: + node = <_DTDElementContentDecl>_DTDElementContentDecl.__new__(_DTDElementContentDecl) + node._dtd = self._dtd + node._c_node = c1 + return node + else: + return None + + @property + def right(self): + _assertValidDTDNode(self, self._c_node) + c2 = self._c_node.c2 + if c2: + node = <_DTDElementContentDecl>_DTDElementContentDecl.__new__(_DTDElementContentDecl) + node._dtd = self._dtd + node._c_node = c2 + return node + else: + return None + + +@cython.final +@cython.internal +@cython.freelist(8) +cdef class _DTDAttributeDecl: + cdef DTD _dtd + cdef tree.xmlAttribute* _c_node + + def __repr__(self): + return "<%s.%s object name=%r elemname=%r prefix=%r type=%r default=%r default_value=%r at 0x%x>" % (self.__class__.__module__, self.__class__.__name__, self.name, self.elemname, self.prefix, self.type, self.default, self.default_value, id(self)) + + @property + def name(self): + _assertValidDTDNode(self, self._c_node) + return funicodeOrNone(self._c_node.name) + + @property + def elemname(self): + _assertValidDTDNode(self, self._c_node) + return funicodeOrNone(self._c_node.elem) + + @property + def prefix(self): + _assertValidDTDNode(self, self._c_node) + return funicodeOrNone(self._c_node.prefix) + + @property + def type(self): + _assertValidDTDNode(self, self._c_node) + cdef int type = self._c_node.atype + if type == tree.XML_ATTRIBUTE_CDATA: + return "cdata" + elif type == tree.XML_ATTRIBUTE_ID: + return "id" + elif type == tree.XML_ATTRIBUTE_IDREF: + return "idref" + elif type == tree.XML_ATTRIBUTE_IDREFS: + return "idrefs" + elif type == tree.XML_ATTRIBUTE_ENTITY: + return "entity" + elif type == tree.XML_ATTRIBUTE_ENTITIES: + return "entities" + elif type == tree.XML_ATTRIBUTE_NMTOKEN: + return "nmtoken" + elif type == tree.XML_ATTRIBUTE_NMTOKENS: + return "nmtokens" + elif type == tree.XML_ATTRIBUTE_ENUMERATION: + return "enumeration" + elif type == tree.XML_ATTRIBUTE_NOTATION: + return "notation" + else: + return None + + @property + def default(self): + _assertValidDTDNode(self, self._c_node) + cdef int default = self._c_node.def_ + if default == tree.XML_ATTRIBUTE_NONE: + return "none" + elif default == tree.XML_ATTRIBUTE_REQUIRED: + return "required" + elif default == tree.XML_ATTRIBUTE_IMPLIED: + return "implied" + elif default == tree.XML_ATTRIBUTE_FIXED: + return "fixed" + else: + return None + + @property + def default_value(self): + _assertValidDTDNode(self, self._c_node) + return funicodeOrNone(self._c_node.defaultValue) + + def itervalues(self): + _assertValidDTDNode(self, self._c_node) + cdef tree.xmlEnumeration *c_node = self._c_node.tree + while c_node is not NULL: + yield funicode(c_node.name) + c_node = c_node.next + + def values(self): + return list(self.itervalues()) + + +@cython.final +@cython.internal +@cython.freelist(8) +cdef class _DTDElementDecl: + cdef DTD _dtd + cdef tree.xmlElement* _c_node + + def __repr__(self): + return "<%s.%s object name=%r prefix=%r type=%r at 0x%x>" % (self.__class__.__module__, self.__class__.__name__, self.name, self.prefix, self.type, id(self)) + + @property + def name(self): + _assertValidDTDNode(self, self._c_node) + return funicodeOrNone(self._c_node.name) + + @property + def prefix(self): + _assertValidDTDNode(self, self._c_node) + return funicodeOrNone(self._c_node.prefix) + + @property + def type(self): + _assertValidDTDNode(self, self._c_node) + cdef int type = self._c_node.etype + if type == tree.XML_ELEMENT_TYPE_UNDEFINED: + return "undefined" + elif type == tree.XML_ELEMENT_TYPE_EMPTY: + return "empty" + elif type == tree.XML_ELEMENT_TYPE_ANY: + return "any" + elif type == tree.XML_ELEMENT_TYPE_MIXED: + return "mixed" + elif type == tree.XML_ELEMENT_TYPE_ELEMENT: + return "element" + else: + return None + + @property + def content(self): + _assertValidDTDNode(self, self._c_node) + cdef tree.xmlElementContent *content = self._c_node.content + if content: + node = <_DTDElementContentDecl>_DTDElementContentDecl.__new__(_DTDElementContentDecl) + node._dtd = self._dtd + node._c_node = content + return node + else: + return None + + def iterattributes(self): + _assertValidDTDNode(self, self._c_node) + cdef tree.xmlAttribute *c_node = self._c_node.attributes + while c_node: + node = <_DTDAttributeDecl>_DTDAttributeDecl.__new__(_DTDAttributeDecl) + node._dtd = self._dtd + node._c_node = c_node + yield node + c_node = c_node.nexth + + def attributes(self): + return list(self.iterattributes()) + + +@cython.final +@cython.internal +@cython.freelist(8) +cdef class _DTDEntityDecl: + cdef DTD _dtd + cdef tree.xmlEntity* _c_node + def __repr__(self): + return "<%s.%s object name=%r at 0x%x>" % (self.__class__.__module__, self.__class__.__name__, self.name, id(self)) + + @property + def name(self): + _assertValidDTDNode(self, self._c_node) + return funicodeOrNone(self._c_node.name) + + @property + def orig(self): + _assertValidDTDNode(self, self._c_node) + return funicodeOrNone(self._c_node.orig) + + @property + def content(self): + _assertValidDTDNode(self, self._c_node) + return funicodeOrNone(self._c_node.content) + + @property + def system_url(self): + _assertValidDTDNode(self, self._c_node) + return funicodeOrNone(self._c_node.SystemID) + + +################################################################################ +# DTD + +cdef class DTD(_Validator): + """DTD(self, file=None, external_id=None) + A DTD validator. + + Can load from filesystem directly given a filename or file-like object. + Alternatively, pass the keyword parameter ``external_id`` to load from a + catalog. + """ + cdef tree.xmlDtd* _c_dtd + def __init__(self, file=None, *, external_id=None): + _Validator.__init__(self) + if file is not None: + file = _getFSPathOrObject(file) + if _isString(file): + file = _encodeFilename(file) + with self._error_log: + orig_loader = _register_document_loader() + self._c_dtd = xmlparser.xmlParseDTD(NULL, _xcstr(file)) + _reset_document_loader(orig_loader) + elif hasattr(file, 'read'): + orig_loader = _register_document_loader() + self._c_dtd = _parseDtdFromFilelike(file) + _reset_document_loader(orig_loader) + else: + raise DTDParseError, "file must be a filename, file-like or path-like object" + elif external_id is not None: + external_id_utf = _utf8(external_id) + with self._error_log: + orig_loader = _register_document_loader() + self._c_dtd = xmlparser.xmlParseDTD(external_id_utf, NULL) + _reset_document_loader(orig_loader) + else: + raise DTDParseError, "either filename or external ID required" + + if self._c_dtd is NULL: + raise DTDParseError( + self._error_log._buildExceptionMessage("error parsing DTD"), + self._error_log) + + @property + def name(self): + if self._c_dtd is NULL: + return None + return funicodeOrNone(self._c_dtd.name) + + @property + def external_id(self): + if self._c_dtd is NULL: + return None + return funicodeOrNone(self._c_dtd.ExternalID) + + @property + def system_url(self): + if self._c_dtd is NULL: + return None + return funicodeOrNone(self._c_dtd.SystemID) + + def iterelements(self): + cdef tree.xmlNode *c_node = self._c_dtd.children if self._c_dtd is not NULL else NULL + while c_node is not NULL: + if c_node.type == tree.XML_ELEMENT_DECL: + node = _DTDElementDecl() + node._dtd = self + node._c_node = c_node + yield node + c_node = c_node.next + + def elements(self): + return list(self.iterelements()) + + def iterentities(self): + cdef tree.xmlNode *c_node = self._c_dtd.children if self._c_dtd is not NULL else NULL + while c_node is not NULL: + if c_node.type == tree.XML_ENTITY_DECL: + node = _DTDEntityDecl() + node._dtd = self + node._c_node = c_node + yield node + c_node = c_node.next + + def entities(self): + return list(self.iterentities()) + + def __dealloc__(self): + tree.xmlFreeDtd(self._c_dtd) + + def __call__(self, etree): + """__call__(self, etree) + + Validate doc using the DTD. + + Returns true if the document is valid, false if not. + """ + cdef _Document doc + cdef _Element root_node + cdef xmlDoc* c_doc + cdef dtdvalid.xmlValidCtxt* valid_ctxt + cdef int ret = -1 + + assert self._c_dtd is not NULL, "DTD not initialised" + doc = _documentOrRaise(etree) + root_node = _rootNodeOrRaise(etree) + + valid_ctxt = dtdvalid.xmlNewValidCtxt() + if valid_ctxt is NULL: + raise DTDError("Failed to create validation context") + + # work around error reporting bug in libxml2 <= 2.9.1 (and later?) + # https://bugzilla.gnome.org/show_bug.cgi?id=724903 + valid_ctxt.error = _nullGenericErrorFunc + valid_ctxt.userData = NULL + + try: + with self._error_log: + c_doc = _fakeRootDoc(doc._c_doc, root_node._c_node) + ret = dtdvalid.xmlValidateDtd(valid_ctxt, c_doc, self._c_dtd) + _destroyFakeDoc(doc._c_doc, c_doc) + finally: + dtdvalid.xmlFreeValidCtxt(valid_ctxt) + + if ret == -1: + raise DTDValidateError("Internal error in DTD validation", + self._error_log) + return ret == 1 + + +cdef tree.xmlDtd* _parseDtdFromFilelike(file) except NULL: + cdef _ExceptionContext exc_context + cdef _FileReaderContext dtd_parser + cdef _ErrorLog error_log + cdef tree.xmlDtd* c_dtd = NULL + exc_context = _ExceptionContext() + dtd_parser = _FileReaderContext(file, exc_context, None) + error_log = _ErrorLog() + + with error_log: + c_dtd = dtd_parser._readDtd() + + exc_context._raise_if_stored() + if c_dtd is NULL: + raise DTDParseError("error parsing DTD", error_log) + return c_dtd + +cdef DTD _dtdFactory(tree.xmlDtd* c_dtd): + # do not run through DTD.__init__()! + cdef DTD dtd + if c_dtd is NULL: + return None + dtd = DTD.__new__(DTD) + dtd._c_dtd = _copyDtd(c_dtd) + _Validator.__init__(dtd) + return dtd + + +cdef tree.xmlDtd* _copyDtd(tree.xmlDtd* c_orig_dtd) except NULL: + """ + Copy a DTD. libxml2 (currently) fails to set up the element->attributes + links when copying DTDs, so we have to rebuild them here. + """ + c_dtd = tree.xmlCopyDtd(c_orig_dtd) + if not c_dtd: + raise MemoryError + cdef tree.xmlNode* c_node = c_dtd.children + while c_node: + if c_node.type == tree.XML_ATTRIBUTE_DECL: + _linkDtdAttribute(c_dtd, c_node) + c_node = c_node.next + return c_dtd + + +cdef void _linkDtdAttribute(tree.xmlDtd* c_dtd, tree.xmlAttribute* c_attr) noexcept: + """ + Create the link to the DTD attribute declaration from the corresponding + element declaration. + """ + c_elem = dtdvalid.xmlGetDtdElementDesc(c_dtd, c_attr.elem) + if not c_elem: + # no such element? something is wrong with the DTD ... + return + c_pos = c_elem.attributes + if not c_pos: + c_elem.attributes = c_attr + c_attr.nexth = NULL + return + # libxml2 keeps namespace declarations first, and we need to make + # sure we don't re-insert attributes that are already there + if _isDtdNsDecl(c_attr): + if not _isDtdNsDecl(c_pos): + c_elem.attributes = c_attr + c_attr.nexth = c_pos + return + while c_pos != c_attr and c_pos.nexth and _isDtdNsDecl(c_pos.nexth): + c_pos = c_pos.nexth + else: + # append at end + while c_pos != c_attr and c_pos.nexth: + c_pos = c_pos.nexth + if c_pos == c_attr: + return + c_attr.nexth = c_pos.nexth + c_pos.nexth = c_attr + + +cdef bint _isDtdNsDecl(tree.xmlAttribute* c_attr) noexcept: + if cstring_h.strcmp(c_attr.name, "xmlns") == 0: + return True + if (c_attr.prefix is not NULL and + cstring_h.strcmp(c_attr.prefix, "xmlns") == 0): + return True + return False diff --git a/presentation/.venv/lib/python3.12/site-packages/lxml/etree.cpython-312-x86_64-linux-gnu.so b/presentation/.venv/lib/python3.12/site-packages/lxml/etree.cpython-312-x86_64-linux-gnu.so new file mode 100755 index 0000000..71293b0 Binary files /dev/null and b/presentation/.venv/lib/python3.12/site-packages/lxml/etree.cpython-312-x86_64-linux-gnu.so differ diff --git a/presentation/.venv/lib/python3.12/site-packages/lxml/etree.h b/presentation/.venv/lib/python3.12/site-packages/lxml/etree.h new file mode 100644 index 0000000..10a6039 --- /dev/null +++ b/presentation/.venv/lib/python3.12/site-packages/lxml/etree.h @@ -0,0 +1,244 @@ +/* Generated by Cython 3.2.4 */ + +#ifndef __PYX_HAVE__lxml__etree +#define __PYX_HAVE__lxml__etree + +#include "Python.h" +struct LxmlDocument; +struct LxmlElement; +struct LxmlElementTree; +struct LxmlElementTagMatcher; +struct LxmlElementIterator; +struct LxmlElementBase; +struct LxmlElementClassLookup; +struct LxmlFallbackElementClassLookup; + +/* "lxml/etree.pyx":456 + * + * # type of a function that steps from node to node + * ctypedef public xmlNode* (*_node_to_node_function)(xmlNode*) # <<<<<<<<<<<<<< + * + * +*/ +typedef xmlNode *(*_node_to_node_function)(xmlNode *); + +/* "lxml/etree.pyx":470 + * # Public Python API + * + * @cython.final # <<<<<<<<<<<<<< + * @cython.freelist(8) + * cdef public class _Document [ type LxmlDocumentType, object LxmlDocument ]: +*/ +struct LxmlDocument { + PyObject_HEAD + struct __pyx_vtabstruct_4lxml_5etree__Document *__pyx_vtab; + int _ns_counter; + PyObject *_prefix_tail; + xmlDoc *_c_doc; + struct __pyx_obj_4lxml_5etree__BaseParser *_parser; +}; + +/* "lxml/etree.pyx":822 + * + * + * @cython.no_gc_clear # <<<<<<<<<<<<<< + * cdef public class _Element [ type LxmlElementType, object LxmlElement ]: + * """Element class. +*/ +struct LxmlElement { + PyObject_HEAD + struct LxmlDocument *_doc; + xmlNode *_c_node; + PyObject *_tag; +}; + +/* "lxml/etree.pyx":1996 + * + * + * cdef public class _ElementTree [ type LxmlElementTreeType, # <<<<<<<<<<<<<< + * object LxmlElementTree ]: + * cdef _Document _doc +*/ +struct LxmlElementTree { + PyObject_HEAD + struct __pyx_vtabstruct_4lxml_5etree__ElementTree *__pyx_vtab; + struct LxmlDocument *_doc; + struct LxmlElement *_context_node; +}; + +/* "lxml/etree.pyx":2770 + * + * + * cdef public class _ElementTagMatcher [ object LxmlElementTagMatcher, # <<<<<<<<<<<<<< + * type LxmlElementTagMatcherType ]: + * """ +*/ +struct LxmlElementTagMatcher { + PyObject_HEAD + struct __pyx_vtabstruct_4lxml_5etree__ElementTagMatcher *__pyx_vtab; + PyObject *_pystrings; + int _node_type; + char *_href; + char *_name; +}; + +/* "lxml/etree.pyx":2801 + * self._name = NULL + * + * cdef public class _ElementIterator(_ElementTagMatcher) [ # <<<<<<<<<<<<<< + * object LxmlElementIterator, type LxmlElementIteratorType ]: + * """ +*/ +struct LxmlElementIterator { + struct LxmlElementTagMatcher __pyx_base; + struct LxmlElement *_node; + _node_to_node_function _next_element; +}; + +/* "src/lxml/classlookup.pxi":6 + * # Custom Element classes + * + * cdef public class ElementBase(_Element) [ type LxmlElementBaseType, # <<<<<<<<<<<<<< + * object LxmlElementBase ]: + * """ElementBase(*children, attrib=None, nsmap=None, **_extra) +*/ +struct LxmlElementBase { + struct LxmlElement __pyx_base; +}; + +/* "src/lxml/classlookup.pxi":210 + * # Element class lookup + * + * ctypedef public object (*_element_class_lookup_function)(object, _Document, xmlNode*) # <<<<<<<<<<<<<< + * + * # class to store element class lookup functions +*/ +typedef PyObject *(*_element_class_lookup_function)(PyObject *, struct LxmlDocument *, xmlNode *); + +/* "src/lxml/classlookup.pxi":213 + * + * # class to store element class lookup functions + * cdef public class ElementClassLookup [ type LxmlElementClassLookupType, # <<<<<<<<<<<<<< + * object LxmlElementClassLookup ]: + * """ElementClassLookup(self) +*/ +struct LxmlElementClassLookup { + PyObject_HEAD + _element_class_lookup_function _lookup_function; +}; + +/* "src/lxml/classlookup.pxi":221 + * + * + * cdef public class FallbackElementClassLookup(ElementClassLookup) \ # <<<<<<<<<<<<<< + * [ type LxmlFallbackElementClassLookupType, + * object LxmlFallbackElementClassLookup ]: +*/ +struct LxmlFallbackElementClassLookup { + struct LxmlElementClassLookup __pyx_base; + struct __pyx_vtabstruct_4lxml_5etree_FallbackElementClassLookup *__pyx_vtab; + struct LxmlElementClassLookup *fallback; + _element_class_lookup_function _fallback_function; +}; + +#ifndef __PYX_HAVE_API__lxml__etree + +#ifdef CYTHON_EXTERN_C + #undef __PYX_EXTERN_C + #define __PYX_EXTERN_C CYTHON_EXTERN_C +#elif defined(__PYX_EXTERN_C) + #ifdef _MSC_VER + #pragma message ("Please do not define the '__PYX_EXTERN_C' macro externally. Use 'CYTHON_EXTERN_C' instead.") + #else + #warning Please do not define the '__PYX_EXTERN_C' macro externally. Use 'CYTHON_EXTERN_C' instead. + #endif +#else + #ifdef __cplusplus + #define __PYX_EXTERN_C extern "C" + #else + #define __PYX_EXTERN_C extern + #endif +#endif + +#ifndef DL_IMPORT + #define DL_IMPORT(_T) _T +#endif + +__PYX_EXTERN_C DL_IMPORT(PyTypeObject) LxmlDocumentType; +__PYX_EXTERN_C DL_IMPORT(PyTypeObject) LxmlElementType; +__PYX_EXTERN_C DL_IMPORT(PyTypeObject) LxmlElementTreeType; +__PYX_EXTERN_C DL_IMPORT(PyTypeObject) LxmlElementTagMatcherType; +__PYX_EXTERN_C DL_IMPORT(PyTypeObject) LxmlElementIteratorType; +__PYX_EXTERN_C DL_IMPORT(PyTypeObject) LxmlElementBaseType; +__PYX_EXTERN_C DL_IMPORT(PyTypeObject) LxmlElementClassLookupType; +__PYX_EXTERN_C DL_IMPORT(PyTypeObject) LxmlFallbackElementClassLookupType; + +__PYX_EXTERN_C struct LxmlElement *deepcopyNodeToDocument(struct LxmlDocument *, xmlNode *); +__PYX_EXTERN_C struct LxmlElementTree *elementTreeFactory(struct LxmlElement *); +__PYX_EXTERN_C struct LxmlElementTree *newElementTree(struct LxmlElement *, PyObject *); +__PYX_EXTERN_C struct LxmlElementTree *adoptExternalDocument(xmlDoc *, PyObject *, int); +__PYX_EXTERN_C struct LxmlElement *elementFactory(struct LxmlDocument *, xmlNode *); +__PYX_EXTERN_C struct LxmlElement *makeElement(PyObject *, struct LxmlDocument *, PyObject *, PyObject *, PyObject *, PyObject *, PyObject *); +__PYX_EXTERN_C struct LxmlElement *makeSubElement(struct LxmlElement *, PyObject *, PyObject *, PyObject *, PyObject *, PyObject *); +__PYX_EXTERN_C void setElementClassLookupFunction(_element_class_lookup_function, PyObject *); +__PYX_EXTERN_C PyObject *lookupDefaultElementClass(PyObject *, PyObject *, xmlNode *); +__PYX_EXTERN_C PyObject *lookupNamespaceElementClass(PyObject *, PyObject *, xmlNode *); +__PYX_EXTERN_C PyObject *callLookupFallback(struct LxmlFallbackElementClassLookup *, struct LxmlDocument *, xmlNode *); +__PYX_EXTERN_C int tagMatches(xmlNode *, const xmlChar *, const xmlChar *); +__PYX_EXTERN_C struct LxmlDocument *documentOrRaise(PyObject *); +__PYX_EXTERN_C struct LxmlElement *rootNodeOrRaise(PyObject *); +__PYX_EXTERN_C int hasText(xmlNode *); +__PYX_EXTERN_C int hasTail(xmlNode *); +__PYX_EXTERN_C PyObject *textOf(xmlNode *); +__PYX_EXTERN_C PyObject *tailOf(xmlNode *); +__PYX_EXTERN_C int setNodeText(xmlNode *, PyObject *); +__PYX_EXTERN_C int setTailText(xmlNode *, PyObject *); +__PYX_EXTERN_C PyObject *attributeValue(xmlNode *, xmlAttr *); +__PYX_EXTERN_C PyObject *attributeValueFromNsName(xmlNode *, const xmlChar *, const xmlChar *); +__PYX_EXTERN_C PyObject *getAttributeValue(struct LxmlElement *, PyObject *, PyObject *); +__PYX_EXTERN_C PyObject *iterattributes(struct LxmlElement *, int); +__PYX_EXTERN_C PyObject *collectAttributes(xmlNode *, int); +__PYX_EXTERN_C int setAttributeValue(struct LxmlElement *, PyObject *, PyObject *); +__PYX_EXTERN_C int delAttribute(struct LxmlElement *, PyObject *); +__PYX_EXTERN_C int delAttributeFromNsName(xmlNode *, const xmlChar *, const xmlChar *); +__PYX_EXTERN_C int hasChild(xmlNode *); +__PYX_EXTERN_C xmlNode *findChild(xmlNode *, Py_ssize_t); +__PYX_EXTERN_C xmlNode *findChildForwards(xmlNode *, Py_ssize_t); +__PYX_EXTERN_C xmlNode *findChildBackwards(xmlNode *, Py_ssize_t); +__PYX_EXTERN_C xmlNode *nextElement(xmlNode *); +__PYX_EXTERN_C xmlNode *previousElement(xmlNode *); +__PYX_EXTERN_C void appendChild(struct LxmlElement *, struct LxmlElement *); +__PYX_EXTERN_C int appendChildToElement(struct LxmlElement *, struct LxmlElement *); +__PYX_EXTERN_C PyObject *pyunicode(const xmlChar *); +__PYX_EXTERN_C PyObject *utf8(PyObject *); +__PYX_EXTERN_C PyObject *getNsTag(PyObject *); +__PYX_EXTERN_C PyObject *getNsTagWithEmptyNs(PyObject *); +__PYX_EXTERN_C PyObject *namespacedName(xmlNode *); +__PYX_EXTERN_C PyObject *namespacedNameFromNsName(const xmlChar *, const xmlChar *); +__PYX_EXTERN_C void iteratorStoreNext(struct LxmlElementIterator *, struct LxmlElement *); +__PYX_EXTERN_C void initTagMatch(struct LxmlElementTagMatcher *, PyObject *); +__PYX_EXTERN_C xmlNs *findOrBuildNodeNsPrefix(struct LxmlDocument *, xmlNode *, const xmlChar *, const xmlChar *); + +#endif /* !__PYX_HAVE_API__lxml__etree */ + +/* WARNING: the interface of the module init function changed in CPython 3.5. */ +/* It now returns a PyModuleDef instance instead of a PyModule instance. */ + +/* WARNING: Use PyImport_AppendInittab("etree", PyInit_etree) instead of calling PyInit_etree directly from Python 3.5 */ +PyMODINIT_FUNC PyInit_etree(void); + +#if PY_VERSION_HEX >= 0x03050000 && (defined(__GNUC__) || defined(__clang__) || defined(_MSC_VER) || (defined(__cplusplus) && __cplusplus >= 201402L)) +#if defined(__cplusplus) && __cplusplus >= 201402L +[[deprecated("Use PyImport_AppendInittab(\"etree\", PyInit_etree) instead of calling PyInit_etree directly.")]] inline +#elif defined(__GNUC__) || defined(__clang__) +__attribute__ ((__deprecated__("Use PyImport_AppendInittab(\"etree\", PyInit_etree) instead of calling PyInit_etree directly."), __unused__)) __inline__ +#elif defined(_MSC_VER) +__declspec(deprecated("Use PyImport_AppendInittab(\"etree\", PyInit_etree) instead of calling PyInit_etree directly.")) __inline +#endif +static PyObject* __PYX_WARN_IF_PyInit_etree_INIT_CALLED(PyObject* res) { + return res; +} +#define PyInit_etree() __PYX_WARN_IF_PyInit_etree_INIT_CALLED(PyInit_etree()) +#endif + +#endif /* !__PYX_HAVE__lxml__etree */ diff --git a/presentation/.venv/lib/python3.12/site-packages/lxml/etree.pyx b/presentation/.venv/lib/python3.12/site-packages/lxml/etree.pyx new file mode 100644 index 0000000..149a414 --- /dev/null +++ b/presentation/.venv/lib/python3.12/site-packages/lxml/etree.pyx @@ -0,0 +1,3858 @@ +# cython: binding=True +# cython: auto_pickle=False +# cython: language_level=3 + +""" +The ``lxml.etree`` module implements the extended ElementTree API for XML. +""" + +__docformat__ = "restructuredtext en" + +__all__ = [ + 'AttributeBasedElementClassLookup', 'C14NError', 'C14NWriterTarget', 'CDATA', + 'Comment', 'CommentBase', 'CustomElementClassLookup', 'DEBUG', + 'DTD', 'DTDError', 'DTDParseError', 'DTDValidateError', + 'DocumentInvalid', 'ETCompatXMLParser', 'ETXPath', 'Element', + 'ElementBase', 'ElementClassLookup', 'ElementDefaultClassLookup', + 'ElementNamespaceClassLookup', 'ElementTree', 'Entity', 'EntityBase', + 'Error', 'ErrorDomains', 'ErrorLevels', 'ErrorTypes', 'Extension', + 'FallbackElementClassLookup', 'FunctionNamespace', 'HTML', 'HTMLParser', + 'ICONV_COMPILED_VERSION', + 'LIBXML_COMPILED_VERSION', 'LIBXML_VERSION', + 'LIBXML_FEATURES', + 'LIBXSLT_COMPILED_VERSION', 'LIBXSLT_VERSION', + 'LXML_VERSION', + 'LxmlError', 'LxmlRegistryError', 'LxmlSyntaxError', + 'NamespaceRegistryError', 'PI', 'PIBase', 'ParseError', + 'ParserBasedElementClassLookup', 'ParserError', 'ProcessingInstruction', + 'PyErrorLog', 'PythonElementClassLookup', 'QName', 'RelaxNG', + 'RelaxNGError', 'RelaxNGErrorTypes', 'RelaxNGParseError', + 'RelaxNGValidateError', 'Resolver', 'Schematron', 'SchematronError', + 'SchematronParseError', 'SchematronValidateError', 'SerialisationError', + 'SubElement', 'TreeBuilder', 'XInclude', 'XIncludeError', 'XML', + 'XMLDTDID', 'XMLID', 'XMLParser', 'XMLSchema', 'XMLSchemaError', + 'XMLSchemaParseError', 'XMLSchemaValidateError', 'XMLSyntaxError', + 'XMLTreeBuilder', 'XPath', 'XPathDocumentEvaluator', 'XPathError', + 'XPathEvalError', 'XPathEvaluator', 'XPathFunctionError', 'XPathResultError', + 'XPathSyntaxError', 'XSLT', 'XSLTAccessControl', 'XSLTApplyError', + 'XSLTError', 'XSLTExtension', 'XSLTExtensionError', 'XSLTParseError', + 'XSLTSaveError', 'canonicalize', + 'cleanup_namespaces', 'clear_error_log', 'dump', + 'fromstring', 'fromstringlist', 'get_default_parser', 'iselement', + 'iterparse', 'iterwalk', 'parse', 'parseid', 'register_namespace', + 'set_default_parser', 'set_element_class_lookup', 'strip_attributes', + 'strip_elements', 'strip_tags', 'tostring', 'tostringlist', 'tounicode', + 'use_global_python_log' + ] + +cimport cython + +from lxml cimport python +from lxml.includes cimport tree, config +from lxml.includes.tree cimport xmlDoc, xmlNode, xmlAttr, xmlNs, _isElement, _getNs +from lxml.includes.tree cimport const_xmlChar, xmlChar, _xcstr +from lxml.python cimport _cstr, _isString +from lxml.includes cimport xpath +from lxml.includes cimport c14n + +# Cython's standard declarations +cimport cpython.mem +cimport cpython.ref +from libc cimport limits, stdio, stdlib +from libc cimport string as cstring_h # not to be confused with stdlib 'string' +from libc.string cimport const_char + +cdef object os_path_abspath +from os.path import abspath as os_path_abspath + +cdef object BytesIO, StringIO +from io import BytesIO, StringIO + +cdef object OrderedDict +from collections import OrderedDict + +cdef object _elementpath +from lxml import _elementpath + +cdef object sys +import sys + +cdef object re +import re + +cdef object partial +from functools import partial + +cdef object islice +from itertools import islice + +cdef object ITER_EMPTY = iter(()) + +cdef object MutableMapping +from collections.abc import MutableMapping + +class _ImmutableMapping(MutableMapping): + def __getitem__(self, key): + raise KeyError, key + + def __setitem__(self, key, value): + raise KeyError, key + + def __delitem__(self, key): + raise KeyError, key + + def __contains__(self, key): + return False + + def __len__(self): + return 0 + + def __iter__(self): + return ITER_EMPTY + iterkeys = itervalues = iteritems = __iter__ + +cdef object IMMUTABLE_EMPTY_MAPPING = _ImmutableMapping() +del _ImmutableMapping + + +# the rules +# --------- +# any libxml C argument/variable is prefixed with c_ +# any non-public function/class is prefixed with an underscore +# instance creation is always through factories + +# what to do with libxml2/libxslt error messages? +# 0 : drop +# 1 : use log +DEF __DEBUG = 1 + +# maximum number of lines in the libxml2/xslt log if __DEBUG == 1 +DEF __MAX_LOG_SIZE = 100 + +# make the compiled-in debug state publicly available +DEBUG = __DEBUG + +# A struct to store a cached qualified tag name+href pair. +# While we can borrow the c_name from the document dict, +# PyPy requires us to store a Python reference for the +# namespace in order to keep the byte buffer alive. +cdef struct qname: + const_xmlChar* c_name + python.PyObject* href + +# initialize parser (and threading) +xmlparser.xmlInitParser() + +# global per-thread setup +tree.xmlThrDefIndentTreeOutput(1) +tree.xmlThrDefLineNumbersDefaultValue(1) + +_initThreadLogging() + +# filename encoding +cdef bytes _FILENAME_ENCODING = (sys.getfilesystemencoding() or sys.getdefaultencoding() or 'ascii').encode("UTF-8") +cdef char* _C_FILENAME_ENCODING = _cstr(_FILENAME_ENCODING) + +# set up some default namespace prefixes +cdef dict _DEFAULT_NAMESPACE_PREFIXES = { + b"http://www.w3.org/XML/1998/namespace": b'xml', + b"http://www.w3.org/1999/xhtml": b"html", + b"http://www.w3.org/1999/XSL/Transform": b"xsl", + b"http://www.w3.org/1999/02/22-rdf-syntax-ns#": b"rdf", + b"http://schemas.xmlsoap.org/wsdl/": b"wsdl", + # xml schema + b"http://www.w3.org/2001/XMLSchema": b"xs", + b"http://www.w3.org/2001/XMLSchema-instance": b"xsi", + # dublin core + b"http://purl.org/dc/elements/1.1/": b"dc", + # objectify + b"http://codespeak.net/lxml/objectify/pytype" : b"py", +} + +# To avoid runtime encoding overhead, we keep a Unicode copy +# of the uri-prefix mapping as (str, str) items view. +cdef object _DEFAULT_NAMESPACE_PREFIXES_ITEMS = [] + +cdef _update_default_namespace_prefixes_items(): + cdef bytes ns, prefix + global _DEFAULT_NAMESPACE_PREFIXES_ITEMS + _DEFAULT_NAMESPACE_PREFIXES_ITEMS = { + ns.decode('utf-8') : prefix.decode('utf-8') + for ns, prefix in _DEFAULT_NAMESPACE_PREFIXES.items() + }.items() + +_update_default_namespace_prefixes_items() + +cdef object _check_internal_prefix = re.compile(br"ns\d+$").match + +def register_namespace(prefix, uri): + """Registers a namespace prefix that newly created Elements in that + namespace will use. The registry is global, and any existing + mapping for either the given prefix or the namespace URI will be + removed. + """ + prefix_utf, uri_utf = _utf8(prefix), _utf8(uri) + if _check_internal_prefix(prefix_utf): + raise ValueError("Prefix format reserved for internal use") + _tagValidOrRaise(prefix_utf) + _uriValidOrRaise(uri_utf) + if (uri_utf == b"http://www.w3.org/XML/1998/namespace" and prefix_utf != b'xml' + or prefix_utf == b'xml' and uri_utf != b"http://www.w3.org/XML/1998/namespace"): + raise ValueError("Cannot change the 'xml' prefix of the XML namespace") + for k, v in list(_DEFAULT_NAMESPACE_PREFIXES.items()): + if k == uri_utf or v == prefix_utf: + del _DEFAULT_NAMESPACE_PREFIXES[k] + _DEFAULT_NAMESPACE_PREFIXES[uri_utf] = prefix_utf + _update_default_namespace_prefixes_items() + + +# Error superclass for ElementTree compatibility +cdef class Error(Exception): + pass + +# module level superclass for all exceptions +cdef class LxmlError(Error): + """Main exception base class for lxml. All other exceptions inherit from + this one. + """ + def __init__(self, message, error_log=None): + super(_Error, self).__init__(message) + if error_log is None: + self.error_log = __copyGlobalErrorLog() + else: + self.error_log = error_log.copy() + +cdef object _Error = Error + + +# superclass for all syntax errors +class LxmlSyntaxError(LxmlError, SyntaxError): + """Base class for all syntax errors. + """ + +cdef class C14NError(LxmlError): + """Error during C14N serialisation. + """ + +# version information +cdef tuple __unpackDottedVersion(version): + version_list = [] + l = (version.decode("ascii").replace('-', '.').split('.') + [0]*4)[:4] + for item in l: + try: + item = int(item) + except ValueError: + if item.startswith('dev'): + count = item[3:] + item = -300 + elif item.startswith('alpha'): + count = item[5:] + item = -200 + elif item.startswith('beta'): + count = item[4:] + item = -100 + else: + count = 0 + if count: + item += int(count) + version_list.append(item) + return tuple(version_list) + +cdef tuple __unpackIntVersion(int c_version, int base=100): + return ( + ((c_version // (base*base)) % base), + ((c_version // base) % base), + (c_version % base) + ) + +cdef int _LIBXML_VERSION_INT +try: + _LIBXML_VERSION_INT = int( + re.match('[0-9]+', (tree.xmlParserVersion).decode("ascii")).group(0)) +except Exception: + print("Unknown libxml2 version: " + (tree.xmlParserVersion).decode("latin1")) + _LIBXML_VERSION_INT = 0 + +LIBXML_VERSION = __unpackIntVersion(_LIBXML_VERSION_INT) +LIBXML_COMPILED_VERSION = __unpackIntVersion(tree.LIBXML_VERSION) +LXML_VERSION = __unpackDottedVersion(tree.LXML_VERSION_STRING) + +__version__ = tree.LXML_VERSION_STRING.decode("ascii") + +cdef extern from *: + """ + #ifdef ZLIB_VERNUM + #define __lxml_zlib_version (ZLIB_VERNUM >> 4) + #else + #define __lxml_zlib_version 0 + #endif + #ifdef _LIBICONV_VERSION + #define __lxml_iconv_version (_LIBICONV_VERSION << 8) + #else + #define __lxml_iconv_version 0 + #endif + """ + # zlib isn't included automatically by libxml2's headers + #long ZLIB_HEX_VERSION "__lxml_zlib_version" + long LIBICONV_HEX_VERSION "__lxml_iconv_version" + +#ZLIB_COMPILED_VERSION = __unpackIntVersion(ZLIB_HEX_VERSION, base=0x10) +ICONV_COMPILED_VERSION = __unpackIntVersion(LIBICONV_HEX_VERSION, base=0x100)[:2] + + +cdef extern from "libxml/xmlversion.h": + """ + static const char* const _lxml_lib_features[] = { +#ifdef LIBXML_HTML_ENABLED + "html", +#endif +#ifdef LIBXML_FTP_ENABLED + "ftp", +#endif +#ifdef LIBXML_HTTP_ENABLED + "http", +#endif +#ifdef LIBXML_CATALOG_ENABLED + "catalog", +#endif +#ifdef LIBXML_XPATH_ENABLED + "xpath", +#endif +#ifdef LIBXML_ICONV_ENABLED + "iconv", +#endif +#ifdef LIBXML_ICU_ENABLED + "icu", +#endif +#ifdef LIBXML_REGEXP_ENABLED + "regexp", +#endif +#ifdef LIBXML_SCHEMAS_ENABLED + "xmlschema", +#endif +#ifdef LIBXML_SCHEMATRON_ENABLED + "schematron", +#endif +#ifdef LIBXML_ZLIB_ENABLED + "zlib", +#endif +#ifdef LIBXML_LZMA_ENABLED + "lzma", +#endif + 0 + }; + """ + const char* const* _LXML_LIB_FEATURES "_lxml_lib_features" + + +cdef set _copy_lib_features(): + features = set() + feature = _LXML_LIB_FEATURES + while feature[0]: + features.add(feature[0].decode('ASCII')) + feature += 1 + return features + +LIBXML_COMPILED_FEATURES = _copy_lib_features() +LIBXML_FEATURES = { + feature_name for feature_id, feature_name in [ + #XML_WITH_THREAD = 1 + #XML_WITH_TREE = 2 + #XML_WITH_OUTPUT = 3 + #XML_WITH_PUSH = 4 + #XML_WITH_READER = 5 + #XML_WITH_PATTERN = 6 + #XML_WITH_WRITER = 7 + #XML_WITH_SAX1 = 8 + (xmlparser.XML_WITH_FTP, "ftp"), # XML_WITH_FTP = 9 + (xmlparser.XML_WITH_HTTP, "http"), # XML_WITH_HTTP = 10 + #XML_WITH_VALID = 11 + (xmlparser.XML_WITH_HTML, "html"), # XML_WITH_HTML = 12 + #XML_WITH_LEGACY = 13 + #XML_WITH_C14N = 14 + (xmlparser.XML_WITH_CATALOG, "catalog"), # XML_WITH_CATALOG = 15 + (xmlparser.XML_WITH_XPATH, "xpath"), # XML_WITH_XPATH = 16 + #XML_WITH_XPTR = 17 + #XML_WITH_XINCLUDE = 18 + (xmlparser.XML_WITH_ICONV, "iconv"), # XML_WITH_ICONV = 19 + #XML_WITH_ISO8859X = 20 + #XML_WITH_UNICODE = 21 + (xmlparser.XML_WITH_REGEXP, "regexp"), # XML_WITH_REGEXP = 22 + #XML_WITH_AUTOMATA = 23 + #XML_WITH_EXPR = 24 + (xmlparser.XML_WITH_SCHEMAS, "xmlschema"), # XML_WITH_SCHEMAS = 25 + (xmlparser.XML_WITH_SCHEMATRON, "schematron"), # XML_WITH_SCHEMATRON = 26 + #XML_WITH_MODULES = 27 + #XML_WITH_DEBUG = 28 + #XML_WITH_DEBUG_MEM = 29 + #XML_WITH_DEBUG_RUN = 30 # unused + (xmlparser.XML_WITH_ZLIB, "zlib"), # XML_WITH_ZLIB = 31 + (xmlparser.XML_WITH_ICU, "icu"), # XML_WITH_ICU = 32 + (xmlparser.XML_WITH_LZMA, "lzma"), # XML_WITH_LZMA = 33 + ] if xmlparser.xmlHasFeature(feature_id) +} + +cdef bint HAS_ZLIB_COMPRESSION = xmlparser.xmlHasFeature(xmlparser.XML_WITH_ZLIB) + + +# class for temporary storage of Python references, +# used e.g. for XPath results +@cython.final +@cython.internal +cdef class _TempStore: + cdef list _storage + def __init__(self): + self._storage = [] + + cdef int add(self, obj) except -1: + self._storage.append(obj) + return 0 + + cdef int clear(self) except -1: + del self._storage[:] + return 0 + + +# class for temporarily storing exceptions raised in extensions +@cython.internal +cdef class _ExceptionContext: + cdef object _exc_info + + cdef int clear(self) except -1: + self._exc_info = None + return 0 + + @cython.final + cdef void _store_raised(self) noexcept: + try: + self._exc_info = sys.exc_info() + except BaseException as e: + self._store_exception(e) + finally: + return # and swallow any further exceptions + + @cython.final + cdef int _store_exception(self, exception) except -1: + self._exc_info = (exception, None, None) + return 0 + + @cython.final + cdef bint _has_raised(self) except -1: + return self._exc_info is not None + + @cython.final + cdef int _raise_if_stored(self) except -1: + if self._exc_info is None: + return 0 + type, value, traceback = self._exc_info + self._exc_info = None + if value is None and traceback is None: + raise type + else: + raise type, value, traceback + + +# type of a function that steps from node to node +ctypedef public xmlNode* (*_node_to_node_function)(xmlNode*) + + +################################################################################ +# Include submodules + +include "proxy.pxi" # Proxy handling (element backpointers/memory/etc.) +include "apihelpers.pxi" # Private helper functions +include "xmlerror.pxi" # Error and log handling + + +################################################################################ +# Public Python API + +@cython.final +@cython.freelist(8) +cdef public class _Document [ type LxmlDocumentType, object LxmlDocument ]: + """Internal base class to reference a libxml document. + + When instances of this class are garbage collected, the libxml + document is cleaned up. + """ + cdef int _ns_counter + cdef bytes _prefix_tail + cdef xmlDoc* _c_doc + cdef _BaseParser _parser + + def __dealloc__(self): + # if there are no more references to the document, it is safe + # to clean the whole thing up, as all nodes have a reference to + # the document + tree.xmlFreeDoc(self._c_doc) + + @cython.final + cdef getroot(self): + # return an element proxy for the document root + cdef xmlNode* c_node + c_node = tree.xmlDocGetRootElement(self._c_doc) + if c_node is NULL: + return None + return _elementFactory(self, c_node) + + @cython.final + cdef bint hasdoctype(self) noexcept: + # DOCTYPE gets parsed into internal subset (xmlDTD*) + return self._c_doc is not NULL and self._c_doc.intSubset is not NULL + + @cython.final + cdef getdoctype(self): + # get doctype info: root tag, public/system ID (or None if not known) + cdef tree.xmlDtd* c_dtd + cdef xmlNode* c_root_node + public_id = None + sys_url = None + c_dtd = self._c_doc.intSubset + if c_dtd is not NULL: + if c_dtd.ExternalID is not NULL: + public_id = funicode(c_dtd.ExternalID) + if c_dtd.SystemID is not NULL: + sys_url = funicode(c_dtd.SystemID) + c_dtd = self._c_doc.extSubset + if c_dtd is not NULL: + if not public_id and c_dtd.ExternalID is not NULL: + public_id = funicode(c_dtd.ExternalID) + if not sys_url and c_dtd.SystemID is not NULL: + sys_url = funicode(c_dtd.SystemID) + c_root_node = tree.xmlDocGetRootElement(self._c_doc) + if c_root_node is NULL: + root_name = None + else: + root_name = funicode(c_root_node.name) + return root_name, public_id, sys_url + + @cython.final + cdef getxmlinfo(self): + # return XML version and encoding (or None if not known) + cdef xmlDoc* c_doc = self._c_doc + if c_doc.version is NULL: + version = None + else: + version = funicode(c_doc.version) + if c_doc.encoding is NULL: + encoding = None + else: + encoding = funicode(c_doc.encoding) + return version, encoding + + @cython.final + cdef isstandalone(self): + # returns True for "standalone=true", + # False for "standalone=false", None if not provided + if self._c_doc.standalone == -1: + return None + else: + return (self._c_doc.standalone == 1) + + @cython.final + cdef bytes buildNewPrefix(self): + # get a new unique prefix ("nsX") for this document + cdef bytes ns + if self._ns_counter < len(_PREFIX_CACHE): + ns = _PREFIX_CACHE[self._ns_counter] + else: + ns = python.PyBytes_FromFormat("ns%d", self._ns_counter) + if self._prefix_tail is not None: + ns += self._prefix_tail + self._ns_counter += 1 + if self._ns_counter < 0: + # overflow! + self._ns_counter = 0 + if self._prefix_tail is None: + self._prefix_tail = b"A" + else: + self._prefix_tail += b"A" + return ns + + @cython.final + cdef xmlNs* _findOrBuildNodeNs(self, xmlNode* c_node, + const_xmlChar* c_href, const_xmlChar* c_prefix, + bint is_attribute) except NULL: + """Get or create namespace structure for a node. Reuses the prefix if + possible. + """ + cdef xmlNs* c_ns + cdef xmlNs* c_doc_ns + cdef python.PyObject* dict_result + if c_node.type != tree.XML_ELEMENT_NODE: + assert c_node.type == tree.XML_ELEMENT_NODE, \ + "invalid node type %d, expected %d" % ( + c_node.type, tree.XML_ELEMENT_NODE) + # look for existing ns declaration + c_ns = _searchNsByHref(c_node, c_href, is_attribute) + if c_ns is not NULL: + if is_attribute and c_ns.prefix is NULL: + # do not put namespaced attributes into the default + # namespace as this would break serialisation + pass + else: + return c_ns + + # none found => determine a suitable new prefix + if c_prefix is NULL: + dict_result = python.PyDict_GetItem( + _DEFAULT_NAMESPACE_PREFIXES, c_href) + if dict_result is not NULL: + prefix = dict_result + else: + prefix = self.buildNewPrefix() + c_prefix = _xcstr(prefix) + + # make sure the prefix is not in use already + while tree.xmlSearchNs(self._c_doc, c_node, c_prefix) is not NULL: + prefix = self.buildNewPrefix() + c_prefix = _xcstr(prefix) + + # declare the namespace and return it + c_ns = tree.xmlNewNs(c_node, c_href, c_prefix) + if c_ns is NULL: + raise MemoryError() + return c_ns + + @cython.final + cdef int _setNodeNs(self, xmlNode* c_node, const_xmlChar* c_href) except -1: + "Lookup namespace structure and set it for the node." + c_ns = self._findOrBuildNodeNs(c_node, c_href, NULL, 0) + tree.xmlSetNs(c_node, c_ns) + + +cdef tuple __initPrefixCache(): + cdef int i + return tuple([ python.PyBytes_FromFormat("ns%d", i) + for i in range(26) ]) + +cdef tuple _PREFIX_CACHE = __initPrefixCache() + + +cdef _Document _documentFactory(xmlDoc* c_doc, _BaseParser parser): + cdef _Document result + result = _Document.__new__(_Document) + result._c_doc = c_doc + result._ns_counter = 0 + result._prefix_tail = None + if parser is None: + parser = __GLOBAL_PARSER_CONTEXT.getDefaultParser() + result._parser = parser + return result + + +cdef object _find_invalid_public_id_characters = re.compile( + ur"[^\x20\x0D\x0Aa-zA-Z0-9'()+,./:=?;!*#@$_%-]+").search + + +cdef class DocInfo: + "Document information provided by parser and DTD." + cdef _Document _doc + def __cinit__(self, tree): + "Create a DocInfo object for an ElementTree object or root Element." + self._doc = _documentOrRaise(tree) + root_name, public_id, system_url = self._doc.getdoctype() + if not root_name and (public_id or system_url): + raise ValueError, "Could not find root node" + + @property + def root_name(self): + """Returns the name of the root node as defined by the DOCTYPE.""" + root_name, public_id, system_url = self._doc.getdoctype() + return root_name + + @cython.final + cdef tree.xmlDtd* _get_c_dtd(self) noexcept: + """"Return the DTD. Create it if it does not yet exist.""" + cdef xmlDoc* c_doc = self._doc._c_doc + cdef xmlNode* c_root_node + cdef const_xmlChar* c_name + + if c_doc.intSubset: + return c_doc.intSubset + + c_root_node = tree.xmlDocGetRootElement(c_doc) + c_name = c_root_node.name if c_root_node else NULL + return tree.xmlCreateIntSubset(c_doc, c_name, NULL, NULL) + + def clear(self): + """Removes DOCTYPE and internal subset from the document.""" + cdef xmlDoc* c_doc = self._doc._c_doc + cdef tree.xmlNode* c_dtd = c_doc.intSubset + if c_dtd is NULL: + return + tree.xmlUnlinkNode(c_dtd) + tree.xmlFreeNode(c_dtd) + + property public_id: + """Public ID of the DOCTYPE. + + Mutable. May be set to a valid string or None. If a DTD does not + exist, setting this variable (even to None) will create one. + """ + def __get__(self): + root_name, public_id, system_url = self._doc.getdoctype() + return public_id + + def __set__(self, value): + cdef xmlChar* c_value = NULL + if value is not None: + match = _find_invalid_public_id_characters(value) + if match: + raise ValueError, f'Invalid character(s) {match.group(0)!r} in public_id.' + value = _utf8(value) + c_value = tree.xmlStrdup(_xcstr(value)) + if not c_value: + raise MemoryError() + + c_dtd = self._get_c_dtd() + if not c_dtd: + tree.xmlFree(c_value) + raise MemoryError() + if c_dtd.ExternalID: + tree.xmlFree(c_dtd.ExternalID) + c_dtd.ExternalID = c_value + + property system_url: + """System ID of the DOCTYPE. + + Mutable. May be set to a valid string or None. If a DTD does not + exist, setting this variable (even to None) will create one. + """ + def __get__(self): + root_name, public_id, system_url = self._doc.getdoctype() + return system_url + + def __set__(self, value): + cdef xmlChar* c_value = NULL + if value is not None: + bvalue = _utf8(value) + # sys_url may be any valid unicode string that can be + # enclosed in single quotes or quotes. + if b"'" in bvalue and b'"' in bvalue: + raise ValueError( + 'System URL may not contain both single (\') and double quotes (").') + c_value = tree.xmlStrdup(_xcstr(bvalue)) + if not c_value: + raise MemoryError() + + c_dtd = self._get_c_dtd() + if not c_dtd: + tree.xmlFree(c_value) + raise MemoryError() + if c_dtd.SystemID: + tree.xmlFree(c_dtd.SystemID) + c_dtd.SystemID = c_value + + @property + def xml_version(self): + """Returns the XML version as declared by the document.""" + xml_version, encoding = self._doc.getxmlinfo() + return xml_version + + @property + def encoding(self): + """Returns the encoding name as declared by the document.""" + xml_version, encoding = self._doc.getxmlinfo() + return encoding + + @property + def standalone(self): + """Returns the standalone flag as declared by the document. The possible + values are True (``standalone='yes'``), False + (``standalone='no'`` or flag not provided in the declaration), + and None (unknown or no declaration found). Note that a + normal truth test on this value will always tell if the + ``standalone`` flag was set to ``'yes'`` or not. + """ + return self._doc.isstandalone() + + property URL: + "The source URL of the document (or None if unknown)." + def __get__(self): + if self._doc._c_doc.URL is NULL: + return None + return _decodeFilename(self._doc._c_doc.URL) + def __set__(self, url): + url = _encodeFilename(url) + c_oldurl = self._doc._c_doc.URL + if url is None: + self._doc._c_doc.URL = NULL + else: + self._doc._c_doc.URL = tree.xmlStrdup(_xcstr(url)) + if c_oldurl is not NULL: + tree.xmlFree(c_oldurl) + + @property + def doctype(self): + """Returns a DOCTYPE declaration string for the document.""" + root_name, public_id, system_url = self._doc.getdoctype() + if system_url: + # If '"' in system_url, we must escape it with single + # quotes, otherwise escape with double quotes. If url + # contains both a single quote and a double quote, XML + # standard is being violated. + if '"' in system_url: + quoted_system_url = f"'{system_url}'" + else: + quoted_system_url = f'"{system_url}"' + if public_id: + if system_url: + return f'' + else: + return f'' + elif system_url: + return f'' + elif self._doc.hasdoctype(): + return f'' + else: + return '' + + @property + def internalDTD(self): + """Returns a DTD validator based on the internal subset of the document.""" + return _dtdFactory(self._doc._c_doc.intSubset) + + @property + def externalDTD(self): + """Returns a DTD validator based on the external subset of the document.""" + return _dtdFactory(self._doc._c_doc.extSubset) + + +@cython.no_gc_clear +cdef public class _Element [ type LxmlElementType, object LxmlElement ]: + """Element class. + + References a document object and a libxml node. + + By pointing to a Document instance, a reference is kept to + _Document as long as there is some pointer to a node in it. + """ + cdef _Document _doc + cdef xmlNode* _c_node + cdef object _tag + + def _init(self): + """_init(self) + + Called after object initialisation. Custom subclasses may override + this if they recursively call _init() in the superclasses. + """ + + @cython.linetrace(False) + @cython.profile(False) + def __dealloc__(self): + #print("trying to free node:", self._c_node) + #displayNode(self._c_node, 0) + if self._c_node is not NULL: + _unregisterProxy(self) + attemptDeallocation(self._c_node) + + # MANIPULATORS + + def __setitem__(self, x, value): + """__setitem__(self, x, value) + + Replaces the given subelement index or slice. + """ + cdef xmlNode* c_node = NULL + cdef xmlNode* c_next + cdef xmlDoc* c_source_doc + cdef _Element element + cdef bint left_to_right + cdef Py_ssize_t slicelength = 0, step = 0 + _assertValidNode(self) + if value is None: + raise ValueError, "cannot assign None" + if isinstance(x, slice): + # slice assignment + _findChildSlice(x, self._c_node, &c_node, &step, &slicelength) + if step > 0: + left_to_right = 1 + else: + left_to_right = 0 + step = -step if step != python.PY_SSIZE_T_MIN else python.PY_SSIZE_T_MAX + _replaceSlice(self, c_node, slicelength, step, left_to_right, value) + return + else: + # otherwise: normal item assignment + element = value + _assertValidNode(element) + c_node = _findChild(self._c_node, x) + if c_node is NULL: + raise IndexError, "list index out of range" + c_source_doc = element._c_node.doc + c_next = element._c_node.next + _removeText(c_node.next) + tree.xmlReplaceNode(c_node, element._c_node) + _moveTail(c_next, element._c_node) + moveNodeToDocument(self._doc, c_source_doc, element._c_node) + if not attemptDeallocation(c_node): + moveNodeToDocument(self._doc, c_node.doc, c_node) + + def __delitem__(self, x): + """__delitem__(self, x) + + Deletes the given subelement or a slice. + """ + cdef xmlNode* c_node = NULL + cdef xmlNode* c_next + cdef Py_ssize_t step = 0, slicelength = 0 + _assertValidNode(self) + if isinstance(x, slice): + # slice deletion + if _isFullSlice(x): + c_node = self._c_node.children + if c_node is not NULL: + if not _isElement(c_node): + c_node = _nextElement(c_node) + while c_node is not NULL: + c_next = _nextElement(c_node) + _removeNode(self._doc, c_node) + c_node = c_next + else: + _findChildSlice(x, self._c_node, &c_node, &step, &slicelength) + _deleteSlice(self._doc, c_node, slicelength, step) + else: + # item deletion + c_node = _findChild(self._c_node, x) + if c_node is NULL: + raise IndexError, f"index out of range: {x}" + _removeNode(self._doc, c_node) + + def __deepcopy__(self, memo): + "__deepcopy__(self, memo)" + return self.__copy__() + + def __copy__(self): + "__copy__(self)" + cdef xmlDoc* c_doc + cdef xmlNode* c_node + cdef _Document new_doc + _assertValidNode(self) + c_doc = _copyDocRoot(self._doc._c_doc, self._c_node) # recursive + new_doc = _documentFactory(c_doc, self._doc._parser) + root = new_doc.getroot() + if root is not None: + return root + # Comment/PI + c_node = c_doc.children + while c_node is not NULL and c_node.type != self._c_node.type: + c_node = c_node.next + if c_node is NULL: + return None + return _elementFactory(new_doc, c_node) + + def set(self, key, value): + """set(self, key, value) + + Sets an element attribute. + In HTML documents (not XML or XHTML), the value None is allowed and creates + an attribute without value (just the attribute name). + """ + _assertValidNode(self) + _setAttributeValue(self, key, value) + + def append(self, _Element element not None): + """append(self, element) + + Adds a subelement to the end of this element. + """ + _assertValidNode(self) + _assertValidNode(element) + _appendChild(self, element) + + def addnext(self, _Element element not None): + """addnext(self, element) + + Adds the element as a following sibling directly after this + element. + + This is normally used to set a processing instruction or comment after + the root node of a document. Note that tail text is automatically + discarded when adding at the root level. + """ + _assertValidNode(self) + _assertValidNode(element) + if self._c_node.parent != NULL and not _isElement(self._c_node.parent): + if element._c_node.type not in (tree.XML_PI_NODE, tree.XML_COMMENT_NODE): + raise TypeError, "Only processing instructions and comments can be siblings of the root element" + element.tail = None + _appendSibling(self, element) + + def addprevious(self, _Element element not None): + """addprevious(self, element) + + Adds the element as a preceding sibling directly before this + element. + + This is normally used to set a processing instruction or comment + before the root node of a document. Note that tail text is + automatically discarded when adding at the root level. + """ + _assertValidNode(self) + _assertValidNode(element) + if self._c_node.parent != NULL and not _isElement(self._c_node.parent): + if element._c_node.type != tree.XML_PI_NODE: + if element._c_node.type != tree.XML_COMMENT_NODE: + raise TypeError, "Only processing instructions and comments can be siblings of the root element" + element.tail = None + _prependSibling(self, element) + + def extend(self, elements): + """extend(self, elements) + + Extends the current children by the elements in the iterable. + """ + cdef _Element element + _assertValidNode(self) + for element in elements: + if element is None: + raise TypeError, "Node must not be None" + _assertValidNode(element) + _appendChild(self, element) + + def clear(self, bint keep_tail=False): + """clear(self, keep_tail=False) + + Resets an element. This function removes all subelements, clears + all attributes and sets the text and tail properties to None. + + Pass ``keep_tail=True`` to leave the tail text untouched. + """ + cdef xmlAttr* c_attr + cdef xmlAttr* c_attr_next + cdef xmlNode* c_node + cdef xmlNode* c_node_next + _assertValidNode(self) + c_node = self._c_node + # remove self.text and self.tail + _removeText(c_node.children) + if not keep_tail: + _removeText(c_node.next) + # remove all attributes + c_attr = c_node.properties + if c_attr: + c_node.properties = NULL + tree.xmlFreePropList(c_attr) + # remove all subelements + c_node = c_node.children + if c_node and not _isElement(c_node): + c_node = _nextElement(c_node) + while c_node is not NULL: + c_node_next = _nextElement(c_node) + _removeNode(self._doc, c_node) + c_node = c_node_next + + def insert(self, index: int, _Element element not None): + """insert(self, index, element) + + Inserts a subelement at the given position in this element + """ + cdef xmlNode* c_node + cdef xmlNode* c_next + cdef xmlDoc* c_source_doc + _assertValidNode(self) + _assertValidNode(element) + c_node = _findChild(self._c_node, index) + if c_node is NULL: + _appendChild(self, element) + return + # prevent cycles + if _isAncestorOrSame(element._c_node, self._c_node): + raise ValueError("cannot append parent to itself") + c_source_doc = element._c_node.doc + c_next = element._c_node.next + tree.xmlAddPrevSibling(c_node, element._c_node) + _moveTail(c_next, element._c_node) + moveNodeToDocument(self._doc, c_source_doc, element._c_node) + + def remove(self, _Element element not None): + """remove(self, element) + + Removes a matching subelement. Unlike the find methods, this + method compares elements based on identity, not on tag value + or contents. + """ + cdef xmlNode* c_node + cdef xmlNode* c_next + _assertValidNode(self) + _assertValidNode(element) + c_node = element._c_node + if c_node.parent is not self._c_node: + raise ValueError, "Element is not a child of this node." + c_next = element._c_node.next + tree.xmlUnlinkNode(c_node) + _moveTail(c_next, c_node) + # fix namespace declarations + moveNodeToDocument(self._doc, c_node.doc, c_node) + + def replace(self, _Element old_element not None, + _Element new_element not None): + """replace(self, old_element, new_element) + + Replaces a subelement with the element passed as second argument. + """ + cdef xmlNode* c_old_node + cdef xmlNode* c_old_next + cdef xmlNode* c_new_node + cdef xmlNode* c_new_next + cdef xmlDoc* c_source_doc + _assertValidNode(self) + _assertValidNode(old_element) + _assertValidNode(new_element) + c_old_node = old_element._c_node + if c_old_node.parent is not self._c_node: + raise ValueError, "Element is not a child of this node." + c_new_node = new_element._c_node + # prevent cycles + if _isAncestorOrSame(c_new_node, self._c_node): + raise ValueError("cannot append parent to itself") + # replace node + c_old_next = c_old_node.next + c_new_next = c_new_node.next + c_source_doc = c_new_node.doc + tree.xmlReplaceNode(c_old_node, c_new_node) + _moveTail(c_new_next, c_new_node) + _moveTail(c_old_next, c_old_node) + moveNodeToDocument(self._doc, c_source_doc, c_new_node) + # fix namespace declarations + moveNodeToDocument(self._doc, c_old_node.doc, c_old_node) + + # PROPERTIES + property tag: + """Element tag + """ + def __get__(self): + if self._tag is not None: + return self._tag + _assertValidNode(self) + self._tag = _namespacedName(self._c_node) + return self._tag + + def __set__(self, value): + cdef _BaseParser parser + _assertValidNode(self) + ns, name = _getNsTag(value) + parser = self._doc._parser + if parser is not None and parser._for_html: + _htmlTagValidOrRaise(name) + else: + _tagValidOrRaise(name) + self._tag = value + tree.xmlNodeSetName(self._c_node, _xcstr(name)) + if ns is None: + self._c_node.ns = NULL + else: + self._doc._setNodeNs(self._c_node, _xcstr(ns)) + + @property + def attrib(self): + """Element attribute dictionary. Where possible, use get(), set(), + keys(), values() and items() to access element attributes. + """ + return _Attrib.__new__(_Attrib, self) + + property text: + """Text before the first subelement. This is either a string or + the value None, if there was no text. + """ + def __get__(self): + _assertValidNode(self) + return _collectText(self._c_node.children) + + def __set__(self, value): + _assertValidNode(self) + if isinstance(value, QName): + value = _resolveQNameText(self, value).decode('utf8') + _setNodeText(self._c_node, value) + + # using 'del el.text' is the wrong thing to do + #def __del__(self): + # _setNodeText(self._c_node, None) + + property tail: + """Text after this element's end tag, but before the next sibling + element's start tag. This is either a string or the value None, if + there was no text. + """ + def __get__(self): + _assertValidNode(self) + return _collectText(self._c_node.next) + + def __set__(self, value): + _assertValidNode(self) + _setTailText(self._c_node, value) + + # using 'del el.tail' is the wrong thing to do + #def __del__(self): + # _setTailText(self._c_node, None) + + # not in ElementTree, read-only + @property + def prefix(self): + """Namespace prefix or None. + """ + if self._c_node.ns is not NULL: + if self._c_node.ns.prefix is not NULL: + return funicode(self._c_node.ns.prefix) + return None + + # not in ElementTree, read-only + property sourceline: + """Original line number as found by the parser or None if unknown. + """ + def __get__(self): + cdef long line + _assertValidNode(self) + line = tree.xmlGetLineNo(self._c_node) + return line if line > 0 else None + + def __set__(self, line): + _assertValidNode(self) + if line <= 0: + self._c_node.line = 0 + else: + self._c_node.line = line + + # not in ElementTree, read-only + @property + def nsmap(self): + """Namespace prefix->URI mapping known in the context of this + Element. This includes all namespace declarations of the + parents. + + Note that changing the returned dict has no effect on the Element. + """ + _assertValidNode(self) + return _build_nsmap(self._c_node) + + # not in ElementTree, read-only + property base: + """The base URI of the Element (xml:base or HTML base URL). + None if the base URI is unknown. + + Note that the value depends on the URL of the document that + holds the Element if there is no xml:base attribute on the + Element or its ancestors. + + Setting this property will set an xml:base attribute on the + Element, regardless of the document type (XML or HTML). + """ + def __get__(self): + _assertValidNode(self) + c_base = tree.xmlNodeGetBase(self._doc._c_doc, self._c_node) + if c_base is NULL: + if self._doc._c_doc.URL is NULL: + return None + return _decodeFilename(self._doc._c_doc.URL) + try: + base = _decodeFilename(c_base) + finally: + tree.xmlFree(c_base) + return base + + def __set__(self, url): + _assertValidNode(self) + if url is None: + c_base = NULL + else: + url = _encodeFilename(url) + c_base = _xcstr(url) + tree.xmlNodeSetBase(self._c_node, c_base) + + # ACCESSORS + def __repr__(self): + "__repr__(self)" + return "" % (self.tag, id(self)) + + def __getitem__(self, x): + """Returns the subelement at the given position or the requested + slice. + """ + cdef xmlNode* c_node = NULL + cdef Py_ssize_t step = 0, slicelength = 0 + cdef Py_ssize_t c, i + cdef _node_to_node_function next_element + cdef list result + _assertValidNode(self) + if isinstance(x, slice): + # slicing + if _isFullSlice(x): + return _collectChildren(self) + _findChildSlice(x, self._c_node, &c_node, &step, &slicelength) + if c_node is NULL: + return [] + if step > 0: + next_element = _nextElement + else: + step = -step if step != python.PY_SSIZE_T_MIN else python.PY_SSIZE_T_MAX + next_element = _previousElement + result = [] + c = 0 + while c_node is not NULL and c < slicelength: + result.append(_elementFactory(self._doc, c_node)) + c += 1 + for i in range(step): + c_node = next_element(c_node) + if c_node is NULL: + break + return result + else: + # indexing + c_node = _findChild(self._c_node, x) + if c_node is NULL: + raise IndexError, "list index out of range" + return _elementFactory(self._doc, c_node) + + def __len__(self): + """__len__(self) + + Returns the number of subelements. + """ + _assertValidNode(self) + return _countElements(self._c_node.children) + + def __bool__(self): + """__bool__(self)""" + import warnings + warnings.warn( + "Truth-testing of elements was a source of confusion and will always " + "return True in future versions. " + "Use specific 'len(elem)' or 'elem is not None' test instead.", + FutureWarning + ) + # emulate old behaviour + _assertValidNode(self) + return _hasChild(self._c_node) + + def __contains__(self, element): + "__contains__(self, element)" + cdef xmlNode* c_node + _assertValidNode(self) + if not isinstance(element, _Element): + return 0 + c_node = (<_Element>element)._c_node + return c_node is not NULL and c_node.parent is self._c_node + + def __iter__(self): + "__iter__(self)" + return ElementChildIterator(self) + + def __reversed__(self): + "__reversed__(self)" + return ElementChildIterator(self, reversed=True) + + def index(self, child: _Element, start: int = None, stop: int = None): + """index(self, child, start=None, stop=None) + + Find the position of the child within the parent. + + This method is not part of the original ElementTree API. + """ + cdef Py_ssize_t k, l + cdef Py_ssize_t c_start, c_stop + cdef xmlNode* c_child + cdef xmlNode* c_start_node + _assertValidNode(self) + _assertValidNode(child) + c_child = child._c_node + if c_child.parent is not self._c_node: + raise ValueError, "Element is not a child of this node." + + # handle the unbounded search straight away (normal case) + if stop is None and (start is None or start == 0): + k = 0 + c_child = c_child.prev + while c_child is not NULL: + if _isElement(c_child): + k += 1 + c_child = c_child.prev + return k + + # check indices + if start is None: + c_start = 0 + else: + c_start = start + if stop is None: + c_stop = 0 + else: + c_stop = stop + if c_stop == 0 or \ + c_start >= c_stop and (c_stop > 0 or c_start < 0): + raise ValueError, "list.index(x): x not in slice" + + # for negative slice indices, check slice before searching index + if c_start < 0 or c_stop < 0: + # start from right, at most up to leftmost(c_start, c_stop) + if c_start < c_stop: + k = -c_start + else: + k = -c_stop + c_start_node = self._c_node.last + l = 1 + while c_start_node != c_child and l < k: + if _isElement(c_start_node): + l += 1 + c_start_node = c_start_node.prev + if c_start_node == c_child: + # found! before slice end? + if c_stop < 0 and l <= -c_stop: + raise ValueError, "list.index(x): x not in slice" + elif c_start < 0: + raise ValueError, "list.index(x): x not in slice" + + # now determine the index backwards from child + c_child = c_child.prev + k = 0 + if c_stop > 0: + # we can optimize: stop after c_stop elements if not found + while c_child != NULL and k < c_stop: + if _isElement(c_child): + k += 1 + c_child = c_child.prev + if k < c_stop: + return k + else: + # traverse all + while c_child != NULL: + if _isElement(c_child): + k = k + 1 + c_child = c_child.prev + if c_start > 0: + if k >= c_start: + return k + else: + return k + if c_start != 0 or c_stop != 0: + raise ValueError, "list.index(x): x not in slice" + else: + raise ValueError, "list.index(x): x not in list" + + def get(self, key, default=None): + """get(self, key, default=None) + + Gets an element attribute. + """ + _assertValidNode(self) + return _getAttributeValue(self, key, default) + + def keys(self): + """keys(self) + + Gets a list of attribute names. The names are returned in an + arbitrary order (just like for an ordinary Python dictionary). + """ + _assertValidNode(self) + return _collectAttributes(self._c_node, 1) + + def values(self): + """values(self) + + Gets element attribute values as a sequence of strings. The + attributes are returned in an arbitrary order. + """ + _assertValidNode(self) + return _collectAttributes(self._c_node, 2) + + def items(self): + """items(self) + + Gets element attributes, as a sequence. The attributes are returned in + an arbitrary order. + """ + _assertValidNode(self) + return _collectAttributes(self._c_node, 3) + + def getchildren(self): + """getchildren(self) + + Returns all direct children. The elements are returned in document + order. + + :deprecated: Note that this method has been deprecated as of + ElementTree 1.3 and lxml 2.0. New code should use + ``list(element)`` or simply iterate over elements. + """ + _assertValidNode(self) + return _collectChildren(self) + + def getparent(self): + """getparent(self) + + Returns the parent of this element or None for the root element. + """ + cdef xmlNode* c_node + #_assertValidNode(self) # not needed + c_node = _parentElement(self._c_node) + if c_node is NULL: + return None + return _elementFactory(self._doc, c_node) + + def getnext(self): + """getnext(self) + + Returns the following sibling of this element or None. + """ + cdef xmlNode* c_node + #_assertValidNode(self) # not needed + c_node = _nextElement(self._c_node) + if c_node is NULL: + return None + return _elementFactory(self._doc, c_node) + + def getprevious(self): + """getprevious(self) + + Returns the preceding sibling of this element or None. + """ + cdef xmlNode* c_node + #_assertValidNode(self) # not needed + c_node = _previousElement(self._c_node) + if c_node is NULL: + return None + return _elementFactory(self._doc, c_node) + + def itersiblings(self, tag=None, *tags, preceding=False): + """itersiblings(self, tag=None, *tags, preceding=False) + + Iterate over the following or preceding siblings of this element. + + The direction is determined by the 'preceding' keyword which + defaults to False, i.e. forward iteration over the following + siblings. When True, the iterator yields the preceding + siblings in reverse document order, i.e. starting right before + the current element and going backwards. + + Can be restricted to find only elements with specific tags, + see `iter`. + """ + if preceding: + if self._c_node and not self._c_node.prev: + return ITER_EMPTY + elif self._c_node and not self._c_node.next: + return ITER_EMPTY + if tag is not None: + tags += (tag,) + return SiblingsIterator(self, tags, preceding=preceding) + + def iterancestors(self, tag=None, *tags): + """iterancestors(self, tag=None, *tags) + + Iterate over the ancestors of this element (from parent to parent). + + Can be restricted to find only elements with specific tags, + see `iter`. + """ + if self._c_node and not self._c_node.parent: + return ITER_EMPTY + if tag is not None: + tags += (tag,) + return AncestorsIterator(self, tags) + + def iterdescendants(self, tag=None, *tags): + """iterdescendants(self, tag=None, *tags) + + Iterate over the descendants of this element in document order. + + As opposed to ``el.iter()``, this iterator does not yield the element + itself. The returned elements can be restricted to find only elements + with specific tags, see `iter`. + """ + if self._c_node and not self._c_node.children: + return ITER_EMPTY + if tag is not None: + tags += (tag,) + return ElementDepthFirstIterator(self, tags, inclusive=False) + + def iterchildren(self, tag=None, *tags, reversed=False): + """iterchildren(self, tag=None, *tags, reversed=False) + + Iterate over the children of this element. + + As opposed to using normal iteration on this element, the returned + elements can be reversed with the 'reversed' keyword and restricted + to find only elements with specific tags, see `iter`. + """ + if self._c_node and not self._c_node.children: + return ITER_EMPTY + if tag is not None: + tags += (tag,) + return ElementChildIterator(self, tags, reversed=reversed) + + def getroottree(self): + """getroottree(self) + + Return an ElementTree for the root node of the document that + contains this element. + + This is the same as following element.getparent() up the tree until it + returns None (for the root element) and then build an ElementTree for + the last parent that was returned.""" + _assertValidDoc(self._doc) + return _elementTreeFactory(self._doc, None) + + def getiterator(self, tag=None, *tags): + """getiterator(self, tag=None, *tags) + + Returns a sequence or iterator of all elements in the subtree in + document order (depth first pre-order), starting with this + element. + + Can be restricted to find only elements with specific tags, + see `iter`. + + :deprecated: Note that this method is deprecated as of + ElementTree 1.3 and lxml 2.0. It returns an iterator in + lxml, which diverges from the original ElementTree + behaviour. If you want an efficient iterator, use the + ``element.iter()`` method instead. You should only use this + method in new code if you require backwards compatibility + with older versions of lxml or ElementTree. + """ + if tag is not None: + tags += (tag,) + return ElementDepthFirstIterator(self, tags) + + def iter(self, tag=None, *tags): + """iter(self, tag=None, *tags) + + Iterate over all elements in the subtree in document order (depth + first pre-order), starting with this element. + + Can be restricted to find only elements with specific tags: + pass ``"{ns}localname"`` as tag. Either or both of ``ns`` and + ``localname`` can be ``*`` for a wildcard; ``ns`` can be empty + for no namespace. ``"localname"`` is equivalent to ``"{}localname"`` + (i.e. no namespace) but ``"*"`` is ``"{*}*"`` (any or no namespace), + not ``"{}*"``. + + You can also pass the Element, Comment, ProcessingInstruction and + Entity factory functions to look only for the specific element type. + + Passing multiple tags (or a sequence of tags) instead of a single tag + will let the iterator return all elements matching any of these tags, + in document order. + """ + if tag is not None: + tags += (tag,) + return ElementDepthFirstIterator(self, tags) + + def itertext(self, tag=None, *tags, with_tail=True): + """itertext(self, tag=None, *tags, with_tail=True) + + Iterates over the text content of a subtree. + + You can pass tag names to restrict text content to specific elements, + see `iter`. + + You can set the ``with_tail`` keyword argument to ``False`` to skip + over tail text. + """ + if tag is not None: + tags += (tag,) + return ElementTextIterator(self, tags, with_tail=with_tail) + + def makeelement(self, _tag, attrib=None, nsmap=None, **_extra): + """makeelement(self, _tag, attrib=None, nsmap=None, **_extra) + + Creates a new element associated with the same document. + """ + _assertValidDoc(self._doc) + return _makeElement(_tag, NULL, self._doc, None, None, None, + attrib, nsmap, _extra) + + def find(self, path, namespaces=None): + """find(self, path, namespaces=None) + + Finds the first matching subelement, by tag name or path. + + The optional ``namespaces`` argument accepts a + prefix-to-namespace mapping that allows the usage of XPath + prefixes in the path expression. + """ + if isinstance(path, QName): + path = (path).text + return _elementpath.find(self, path, namespaces, with_prefixes=not _isHtmlDocument(self)) + + def findtext(self, path, default=None, namespaces=None): + """findtext(self, path, default=None, namespaces=None) + + Finds text for the first matching subelement, by tag name or path. + + The optional ``namespaces`` argument accepts a + prefix-to-namespace mapping that allows the usage of XPath + prefixes in the path expression. + """ + if isinstance(path, QName): + path = (path).text + return _elementpath.findtext(self, path, default, namespaces, with_prefixes=not _isHtmlDocument(self)) + + def findall(self, path, namespaces=None): + """findall(self, path, namespaces=None) + + Finds all matching subelements, by tag name or path. + + The optional ``namespaces`` argument accepts a + prefix-to-namespace mapping that allows the usage of XPath + prefixes in the path expression. + """ + if isinstance(path, QName): + path = (path).text + return _elementpath.findall(self, path, namespaces, with_prefixes=not _isHtmlDocument(self)) + + def iterfind(self, path, namespaces=None): + """iterfind(self, path, namespaces=None) + + Iterates over all matching subelements, by tag name or path. + + The optional ``namespaces`` argument accepts a + prefix-to-namespace mapping that allows the usage of XPath + prefixes in the path expression. + """ + if isinstance(path, QName): + path = (path).text + return _elementpath.iterfind(self, path, namespaces, with_prefixes=not _isHtmlDocument(self)) + + def xpath(self, _path, *, namespaces=None, extensions=None, + smart_strings=True, **_variables): + """xpath(self, _path, namespaces=None, extensions=None, smart_strings=True, **_variables) + + Evaluate an xpath expression using the element as context node. + """ + evaluator = XPathElementEvaluator(self, namespaces=namespaces, + extensions=extensions, + smart_strings=smart_strings) + return evaluator(_path, **_variables) + + def cssselect(self, expr, *, translator='xml'): + """ + Run the CSS expression on this element and its children, + returning a list of the results. + + Equivalent to lxml.cssselect.CSSSelect(expr)(self) -- note + that pre-compiling the expression can provide a substantial + speedup. + """ + # Do the import here to make the dependency optional. + from lxml.cssselect import CSSSelector + return CSSSelector(expr, translator=translator)(self) + + +@cython.linetrace(False) +cdef _Element _elementFactory(_Document doc, xmlNode* c_node): + cdef _Element result + result = getProxy(c_node) + if result is not None: + return result + if c_node is NULL: + return None + + element_class = LOOKUP_ELEMENT_CLASS( + ELEMENT_CLASS_LOOKUP_STATE, doc, c_node) + if type(element_class) is not type: + if not isinstance(element_class, type): + raise TypeError(f"Element class is not a type, got {type(element_class)}") + if hasProxy(c_node): + # prevent re-entry race condition - we just called into Python + return getProxy(c_node) + result = element_class.__new__(element_class) + if hasProxy(c_node): + # prevent re-entry race condition - we just called into Python + result._c_node = NULL + return getProxy(c_node) + + _registerProxy(result, doc, c_node) + if element_class is not _Element: + result._init() + return result + + +@cython.internal +cdef class __ContentOnlyElement(_Element): + cdef int _raiseImmutable(self) except -1: + raise TypeError, "this element does not have children or attributes" + + def set(self, key, value): + "set(self, key, value)" + self._raiseImmutable() + + def append(self, value): + "append(self, value)" + self._raiseImmutable() + + def insert(self, index, value): + "insert(self, index, value)" + self._raiseImmutable() + + def __setitem__(self, index, value): + "__setitem__(self, index, value)" + self._raiseImmutable() + + @property + def attrib(self): + return IMMUTABLE_EMPTY_MAPPING + + property text: + def __get__(self): + _assertValidNode(self) + return funicodeOrEmpty(self._c_node.content) + + def __set__(self, value): + cdef tree.xmlDict* c_dict + _assertValidNode(self) + if value is None: + c_text = NULL + else: + value = _utf8(value) + c_text = _xcstr(value) + tree.xmlNodeSetContent(self._c_node, c_text) + + # ACCESSORS + def __getitem__(self, x): + "__getitem__(self, x)" + if isinstance(x, slice): + return [] + else: + raise IndexError, "list index out of range" + + def __len__(self): + "__len__(self)" + return 0 + + def get(self, key, default=None): + "get(self, key, default=None)" + return None + + def keys(self): + "keys(self)" + return [] + + def items(self): + "items(self)" + return [] + + def values(self): + "values(self)" + return [] + +cdef class _Comment(__ContentOnlyElement): + @property + def tag(self): + return Comment + + def __repr__(self): + return "" % self.text + +cdef class _ProcessingInstruction(__ContentOnlyElement): + @property + def tag(self): + return ProcessingInstruction + + property target: + # not in ElementTree + def __get__(self): + _assertValidNode(self) + return funicode(self._c_node.name) + + def __set__(self, value): + _assertValidNode(self) + value = _utf8(value) + c_text = _xcstr(value) + tree.xmlNodeSetName(self._c_node, c_text) + + def __repr__(self): + text = self.text + if text: + return "" % (self.target, text) + else: + return "" % self.target + + def get(self, key, default=None): + """get(self, key, default=None) + + Try to parse pseudo-attributes from the text content of the + processing instruction, search for one with the given key as + name and return its associated value. + + Note that this is only a convenience method for the most + common case that all text content is structured in + attribute-like name-value pairs with properly quoted values. + It is not guaranteed to work for all possible text content. + """ + return self.attrib.get(key, default) + + @property + def attrib(self): + """Returns a dict containing all pseudo-attributes that can be + parsed from the text content of this processing instruction. + Note that modifying the dict currently has no effect on the + XML node, although this is not guaranteed to stay this way. + """ + return { attr : (value1 or value2) + for attr, value1, value2 in _FIND_PI_ATTRIBUTES(' ' + self.text) } + +cdef object _FIND_PI_ATTRIBUTES = re.compile(r'\s+(\w+)\s*=\s*(?:\'([^\']*)\'|"([^"]*)")', re.U).findall + +cdef class _Entity(__ContentOnlyElement): + @property + def tag(self): + return Entity + + property name: + # not in ElementTree + def __get__(self): + _assertValidNode(self) + return funicode(self._c_node.name) + + def __set__(self, value): + _assertValidNode(self) + value_utf = _utf8(value) + if b'&' in value_utf or b';' in value_utf: + raise ValueError, f"Invalid entity name '{value}'" + tree.xmlNodeSetName(self._c_node, _xcstr(value_utf)) + + @property + def text(self): + # FIXME: should this be None or '&[VALUE];' or the resolved + # entity value ? + _assertValidNode(self) + return f'&{funicode(self._c_node.name)};' + + def __repr__(self): + return "&%s;" % self.name + + +cdef class QName: + """QName(text_or_uri_or_element, tag=None) + + QName wrapper for qualified XML names. + + Pass a tag name by itself or a namespace URI and a tag name to + create a qualified name. Alternatively, pass an Element to + extract its tag name. ``None`` as first argument is ignored in + order to allow for generic 2-argument usage. + + The ``text`` property holds the qualified name in + ``{namespace}tagname`` notation. The ``namespace`` and + ``localname`` properties hold the respective parts of the tag + name. + + You can pass QName objects wherever a tag name is expected. Also, + setting Element text from a QName will resolve the namespace prefix + on assignment and set a qualified text value. This is helpful in XML + languages like SOAP or XML-Schema that use prefixed tag names in + their text content. + """ + cdef readonly unicode text + cdef readonly unicode localname + cdef readonly unicode namespace + def __init__(self, text_or_uri_or_element, tag=None): + if text_or_uri_or_element is None: + # Allow None as no namespace. + text_or_uri_or_element, tag = tag, None + if not _isString(text_or_uri_or_element): + if isinstance(text_or_uri_or_element, _Element): + text_or_uri_or_element = (<_Element>text_or_uri_or_element).tag + if not _isString(text_or_uri_or_element): + raise ValueError, f"Invalid input tag of type {type(text_or_uri_or_element)!r}" + elif isinstance(text_or_uri_or_element, QName): + text_or_uri_or_element = (text_or_uri_or_element).text + elif text_or_uri_or_element is not None: + text_or_uri_or_element = unicode(text_or_uri_or_element) + else: + raise ValueError, f"Invalid input tag of type {type(text_or_uri_or_element)!r}" + + ns_utf, tag_utf = _getNsTag(text_or_uri_or_element) + if tag is not None: + # either ('ns', 'tag') or ('{ns}oldtag', 'newtag') + if ns_utf is None: + ns_utf = tag_utf # case 1: namespace ended up as tag name + tag_utf = _utf8(tag) + _tagValidOrRaise(tag_utf) + self.localname = (tag_utf).decode('utf8') + if ns_utf is None: + self.namespace = None + self.text = self.localname + else: + self.namespace = (ns_utf).decode('utf8') + self.text = "{%s}%s" % (self.namespace, self.localname) + def __str__(self): + return self.text + def __hash__(self): + return hash(self.text) + def __richcmp__(self, other, int op): + try: + if type(other) is QName: + other = (other).text + elif not isinstance(other, unicode): + other = unicode(other) + except (ValueError, UnicodeDecodeError): + return NotImplemented + return python.PyObject_RichCompare(self.text, other, op) + + +cdef public class _ElementTree [ type LxmlElementTreeType, + object LxmlElementTree ]: + cdef _Document _doc + cdef _Element _context_node + + # Note that _doc is only used to store the original document if we do not + # have a _context_node. All methods should prefer self._context_node._doc + # to honour tree restructuring. _doc can happily be None! + + @cython.final + cdef int _assertHasRoot(self) except -1: + """We have to take care here: the document may not have a root node! + This can happen if ElementTree() is called without any argument and + the caller 'forgets' to call parse() afterwards, so this is a bug in + the caller program. + """ + assert self._context_node is not None, \ + "ElementTree not initialized, missing root" + return 0 + + def parse(self, source, _BaseParser parser=None, *, base_url=None): + """parse(self, source, parser=None, base_url=None) + + Updates self with the content of source and returns its root. + """ + cdef _Document doc = None + try: + doc = _parseDocument(source, parser, base_url) + except _TargetParserResult as result_container: + # raises a TypeError if we don't get an _Element + self._context_node = result_container.result + else: + self._context_node = doc.getroot() + self._doc = None if self._context_node is not None else doc + return self._context_node + + def _setroot(self, _Element root not None): + """_setroot(self, root) + + Relocate the ElementTree to a new root node. + """ + _assertValidNode(root) + if root._c_node.type != tree.XML_ELEMENT_NODE: + raise TypeError, "Only elements can be the root of an ElementTree" + self._context_node = root + self._doc = None + + def getroot(self): + """getroot(self) + + Gets the root element for this tree. + """ + return self._context_node + + def __copy__(self): + return _elementTreeFactory(self._doc, self._context_node) + + def __deepcopy__(self, memo): + cdef _Element root + cdef _Document doc + cdef xmlDoc* c_doc + if self._context_node is not None: + root = self._context_node.__copy__() + assert root is not None + _assertValidNode(root) + _copyNonElementSiblings(self._context_node._c_node, root._c_node) + return _elementTreeFactory(None, root) + elif self._doc is not None: + _assertValidDoc(self._doc) + c_doc = tree.xmlCopyDoc(self._doc._c_doc, 1) + if c_doc is NULL: + raise MemoryError() + doc = _documentFactory(c_doc, self._doc._parser) + return _elementTreeFactory(doc, None) + else: + # so what ... + return self + + # not in ElementTree + @property + def docinfo(self) -> DocInfo: + """Information about the document provided by parser and DTD.""" + self._assertHasRoot() + return DocInfo(self._context_node._doc) + + # not in ElementTree, read-only + @property + def parser(self): + """The parser that was used to parse the document in this ElementTree. + """ + if self._context_node is not None and \ + self._context_node._doc is not None: + return self._context_node._doc._parser + if self._doc is not None: + return self._doc._parser + return None + + def write(self, file, *, encoding=None, method="xml", + bint pretty_print=False, xml_declaration=None, bint with_tail=True, + standalone=None, doctype=None, compression=0, + bint exclusive=False, inclusive_ns_prefixes=None, + bint with_comments=True, bint strip_text=False, + docstring=None): + """write(self, file, encoding=None, method="xml", + pretty_print=False, xml_declaration=None, with_tail=True, + standalone=None, doctype=None, compression=0, + exclusive=False, inclusive_ns_prefixes=None, + with_comments=True, strip_text=False) + + Write the tree to a filename, file or file-like object. + + Defaults to ASCII encoding and writing a declaration as needed. + + The keyword argument 'method' selects the output method: + 'xml', 'html', 'text', 'c14n' or 'c14n2'. Default is 'xml'. + + With ``method="c14n"`` (C14N version 1), the options ``exclusive``, + ``with_comments`` and ``inclusive_ns_prefixes`` request exclusive + C14N, include comments, and list the inclusive prefixes respectively. + + With ``method="c14n2"`` (C14N version 2), the ``with_comments`` and + ``strip_text`` options control the output of comments and text space + according to C14N 2.0. + + Passing a boolean value to the ``standalone`` option will + output an XML declaration with the corresponding + ``standalone`` flag. + + The ``doctype`` option allows passing in a plain string that will + be serialised before the XML tree. Note that passing in non + well-formed content here will make the XML output non well-formed. + Also, an existing doctype in the document tree will not be removed + when serialising an ElementTree instance. + + The ``compression`` option enables GZip compression level 1-9. + + The ``inclusive_ns_prefixes`` should be a list of namespace strings + (i.e. ['xs', 'xsi']) that will be promoted to the top-level element + during exclusive C14N serialisation. This parameter is ignored if + exclusive mode=False. + + If exclusive=True and no list is provided, a namespace will only be + rendered if it is used by the immediate parent or one of its attributes + and its prefix and values have not already been rendered by an ancestor + of the namespace node's parent element. + """ + cdef bint write_declaration + cdef int is_standalone + + self._assertHasRoot() + _assertValidNode(self._context_node) + if compression is None or compression < 0: + compression = 0 + + # C14N serialisation + if method in ('c14n', 'c14n2'): + if encoding is not None: + raise ValueError("Cannot specify encoding with C14N") + if xml_declaration: + raise ValueError("Cannot enable XML declaration in C14N") + + if method == 'c14n': + _tofilelikeC14N(file, self._context_node, exclusive, with_comments, + compression, inclusive_ns_prefixes) + else: # c14n2 + with _open_utf8_file(file, compression=compression) as f: + target = C14NWriterTarget( + f.write, with_comments=with_comments, strip_text=strip_text) + _tree_to_target(self, target) + return + + if not with_comments: + raise ValueError("Can only discard comments in C14N serialisation") + # suppress decl. in default case (purely for ElementTree compatibility) + if xml_declaration is not None: + write_declaration = xml_declaration + if encoding is None: + encoding = 'ASCII' + else: + encoding = encoding.upper() + elif encoding is None: + encoding = 'ASCII' + write_declaration = 0 + else: + encoding = encoding.upper() + write_declaration = encoding not in ( + 'US-ASCII', 'ASCII', 'UTF8', 'UTF-8') + if standalone is None: + is_standalone = -1 + elif standalone: + write_declaration = 1 + is_standalone = 1 + else: + write_declaration = 1 + is_standalone = 0 + + if docstring is not None and doctype is None: + import warnings + warnings.warn( + "The 'docstring' option is deprecated. Use 'doctype' instead.", + DeprecationWarning) + doctype = docstring + + _tofilelike(file, self._context_node, encoding, doctype, method, + write_declaration, 1, pretty_print, with_tail, + is_standalone, compression) + + def getpath(self, _Element element not None): + """getpath(self, element) + + Returns a structural, absolute XPath expression to find the element. + + For namespaced elements, the expression uses prefixes from the + document, which therefore need to be provided in order to make any + use of the expression in XPath. + + Also see the method getelementpath(self, element), which returns a + self-contained ElementPath expression. + """ + cdef _Document doc + cdef _Element root + cdef xmlDoc* c_doc + _assertValidNode(element) + if self._context_node is not None: + root = self._context_node + doc = root._doc + elif self._doc is not None: + doc = self._doc + root = doc.getroot() + else: + raise ValueError, "Element is not in this tree." + _assertValidDoc(doc) + _assertValidNode(root) + if element._doc is not doc: + raise ValueError, "Element is not in this tree." + + c_doc = _fakeRootDoc(doc._c_doc, root._c_node) + c_path = tree.xmlGetNodePath(element._c_node) + _destroyFakeDoc(doc._c_doc, c_doc) + if c_path is NULL: + raise MemoryError() + path = funicode(c_path) + tree.xmlFree(c_path) + return path + + def getelementpath(self, _Element element not None): + """getelementpath(self, element) + + Returns a structural, absolute ElementPath expression to find the + element. This path can be used in the .find() method to look up + the element, provided that the elements along the path and their + list of immediate children were not modified in between. + + ElementPath has the advantage over an XPath expression (as returned + by the .getpath() method) that it does not require additional prefix + declarations. It is always self-contained. + """ + cdef _Element root + cdef Py_ssize_t count + _assertValidNode(element) + if element._c_node.type != tree.XML_ELEMENT_NODE: + raise ValueError, "input is not an Element" + if self._context_node is not None: + root = self._context_node + elif self._doc is not None: + root = self._doc.getroot() + else: + raise ValueError, "Element is not in this tree" + _assertValidNode(root) + if element._doc is not root._doc: + raise ValueError, "Element is not in this tree" + + path = [] + c_element = element._c_node + while c_element is not root._c_node: + c_name = c_element.name + c_href = _getNs(c_element) + tag = _namespacedNameFromNsName(c_href, c_name) + if c_href is NULL: + c_href = b'' # no namespace (NULL is wildcard) + # use tag[N] if there are preceding siblings with the same tag + count = 0 + c_node = c_element.prev + while c_node is not NULL: + if c_node.type == tree.XML_ELEMENT_NODE: + if _tagMatches(c_node, c_href, c_name): + count += 1 + c_node = c_node.prev + if count: + tag = f'{tag}[{count+1}]' + else: + # use tag[1] if there are following siblings with the same tag + c_node = c_element.next + while c_node is not NULL: + if c_node.type == tree.XML_ELEMENT_NODE: + if _tagMatches(c_node, c_href, c_name): + tag += '[1]' + break + c_node = c_node.next + + path.append(tag) + c_element = c_element.parent + if c_element is NULL or c_element.type != tree.XML_ELEMENT_NODE: + raise ValueError, "Element is not in this tree." + if not path: + return '.' + path.reverse() + return '/'.join(path) + + def getiterator(self, tag=None, *tags): + """getiterator(self, *tags, tag=None) + + Returns a sequence or iterator of all elements in document order + (depth first pre-order), starting with the root element. + + Can be restricted to find only elements with specific tags, + see `_Element.iter`. + + :deprecated: Note that this method is deprecated as of + ElementTree 1.3 and lxml 2.0. It returns an iterator in + lxml, which diverges from the original ElementTree + behaviour. If you want an efficient iterator, use the + ``tree.iter()`` method instead. You should only use this + method in new code if you require backwards compatibility + with older versions of lxml or ElementTree. + """ + root = self.getroot() + if root is None: + return ITER_EMPTY + if tag is not None: + tags += (tag,) + return root.getiterator(*tags) + + def iter(self, tag=None, *tags): + """iter(self, tag=None, *tags) + + Creates an iterator for the root element. The iterator loops over + all elements in this tree, in document order. Note that siblings + of the root element (comments or processing instructions) are not + returned by the iterator. + + Can be restricted to find only elements with specific tags, + see `_Element.iter`. + """ + root = self.getroot() + if root is None: + return ITER_EMPTY + if tag is not None: + tags += (tag,) + return root.iter(*tags) + + def find(self, path, namespaces=None): + """find(self, path, namespaces=None) + + Finds the first toplevel element with given tag. Same as + ``tree.getroot().find(path)``. + + The optional ``namespaces`` argument accepts a + prefix-to-namespace mapping that allows the usage of XPath + prefixes in the path expression. + """ + self._assertHasRoot() + root = self.getroot() + if _isString(path): + if path[:1] == "/": + path = "." + path + from warnings import warn + warn( + "This search incorrectly ignores the root element, and will be " + "fixed in a future version. If you rely on the current " + f"behaviour, change it to {path!r}", + FutureWarning, stacklevel=1 + ) + return root.find(path, namespaces) + + def findtext(self, path, default=None, namespaces=None): + """findtext(self, path, default=None, namespaces=None) + + Finds the text for the first element matching the ElementPath + expression. Same as getroot().findtext(path) + + The optional ``namespaces`` argument accepts a + prefix-to-namespace mapping that allows the usage of XPath + prefixes in the path expression. + """ + self._assertHasRoot() + root = self.getroot() + if _isString(path): + if path[:1] == "/": + path = "." + path + from warnings import warn + warn( + "This search incorrectly ignores the root element, and will be " + "fixed in a future version. If you rely on the current " + f"behaviour, change it to {path!r}", + FutureWarning, stacklevel=1 + ) + return root.findtext(path, default, namespaces) + + def findall(self, path, namespaces=None): + """findall(self, path, namespaces=None) + + Finds all elements matching the ElementPath expression. Same as + getroot().findall(path). + + The optional ``namespaces`` argument accepts a + prefix-to-namespace mapping that allows the usage of XPath + prefixes in the path expression. + """ + self._assertHasRoot() + root = self.getroot() + if _isString(path): + if path[:1] == "/": + path = "." + path + from warnings import warn + warn( + "This search incorrectly ignores the root element, and will be " + "fixed in a future version. If you rely on the current " + f"behaviour, change it to {path!r}", + FutureWarning, stacklevel=1 + ) + return root.findall(path, namespaces) + + def iterfind(self, path, namespaces=None): + """iterfind(self, path, namespaces=None) + + Iterates over all elements matching the ElementPath expression. + Same as getroot().iterfind(path). + + The optional ``namespaces`` argument accepts a + prefix-to-namespace mapping that allows the usage of XPath + prefixes in the path expression. + """ + self._assertHasRoot() + root = self.getroot() + if _isString(path): + if path[:1] == "/": + path = "." + path + from warnings import warn + warn( + "This search incorrectly ignores the root element, and will be " + "fixed in a future version. If you rely on the current " + f"behaviour, change it to {path!r}", + FutureWarning, stacklevel=1 + ) + return root.iterfind(path, namespaces) + + def xpath(self, _path, *, namespaces=None, extensions=None, + smart_strings=True, **_variables): + """xpath(self, _path, namespaces=None, extensions=None, smart_strings=True, **_variables) + + XPath evaluate in context of document. + + ``namespaces`` is an optional dictionary with prefix to namespace URI + mappings, used by XPath. ``extensions`` defines additional extension + functions. + + Returns a list (nodeset), or bool, float or string. + + In case of a list result, return Element for element nodes, + string for text and attribute values. + + Note: if you are going to apply multiple XPath expressions + against the same document, it is more efficient to use + XPathEvaluator directly. + """ + self._assertHasRoot() + evaluator = XPathDocumentEvaluator(self, namespaces=namespaces, + extensions=extensions, + smart_strings=smart_strings) + return evaluator(_path, **_variables) + + def xslt(self, _xslt, extensions=None, access_control=None, **_kw): + """xslt(self, _xslt, extensions=None, access_control=None, **_kw) + + Transform this document using other document. + + xslt is a tree that should be XSLT + keyword parameters are XSLT transformation parameters. + + Returns the transformed tree. + + Note: if you are going to apply the same XSLT stylesheet against + multiple documents, it is more efficient to use the XSLT + class directly. + """ + self._assertHasRoot() + style = XSLT(_xslt, extensions=extensions, + access_control=access_control) + return style(self, **_kw) + + def relaxng(self, relaxng): + """relaxng(self, relaxng) + + Validate this document using other document. + + The relaxng argument is a tree that should contain a Relax NG schema. + + Returns True or False, depending on whether validation + succeeded. + + Note: if you are going to apply the same Relax NG schema against + multiple documents, it is more efficient to use the RelaxNG + class directly. + """ + self._assertHasRoot() + schema = RelaxNG(relaxng) + return schema.validate(self) + + def xmlschema(self, xmlschema): + """xmlschema(self, xmlschema) + + Validate this document using other document. + + The xmlschema argument is a tree that should contain an XML Schema. + + Returns True or False, depending on whether validation + succeeded. + + Note: If you are going to apply the same XML Schema against + multiple documents, it is more efficient to use the XMLSchema + class directly. + """ + self._assertHasRoot() + schema = XMLSchema(xmlschema) + return schema.validate(self) + + def xinclude(self): + """xinclude(self) + + Process the XInclude nodes in this document and include the + referenced XML fragments. + + There is support for loading files through the file system, HTTP and + FTP. + + Note that XInclude does not support custom resolvers in Python space + due to restrictions of libxml2 <= 2.6.29. + """ + self._assertHasRoot() + XInclude()(self._context_node) + + def write_c14n(self, file, *, bint exclusive=False, bint with_comments=True, + compression=0, inclusive_ns_prefixes=None): + """write_c14n(self, file, exclusive=False, with_comments=True, + compression=0, inclusive_ns_prefixes=None) + + C14N write of document. Always writes UTF-8. + + The ``compression`` option enables GZip compression level 1-9. + + The ``inclusive_ns_prefixes`` should be a list of namespace strings + (i.e. ['xs', 'xsi']) that will be promoted to the top-level element + during exclusive C14N serialisation. This parameter is ignored if + exclusive mode=False. + + If exclusive=True and no list is provided, a namespace will only be + rendered if it is used by the immediate parent or one of its attributes + and its prefix and values have not already been rendered by an ancestor + of the namespace node's parent element. + + NOTE: This method is deprecated as of lxml 4.4 and will be removed in a + future release. Use ``.write(f, method="c14n")`` instead. + """ + self._assertHasRoot() + _assertValidNode(self._context_node) + if compression is None or compression < 0: + compression = 0 + + _tofilelikeC14N(file, self._context_node, exclusive, with_comments, + compression, inclusive_ns_prefixes) + +cdef _ElementTree _elementTreeFactory(_Document doc, _Element context_node): + return _newElementTree(doc, context_node, _ElementTree) + +cdef _ElementTree _newElementTree(_Document doc, _Element context_node, + object baseclass): + cdef _ElementTree result + result = baseclass() + if context_node is None and doc is not None: + context_node = doc.getroot() + if context_node is None: + _assertValidDoc(doc) + result._doc = doc + else: + _assertValidNode(context_node) + result._context_node = context_node + return result + + +@cython.final +@cython.freelist(16) +cdef class _Attrib: + """A dict-like proxy for the ``Element.attrib`` property. + """ + cdef _Element _element + def __cinit__(self, _Element element not None): + _assertValidNode(element) + self._element = element + + # MANIPULATORS + def __setitem__(self, key, value): + _assertValidNode(self._element) + _setAttributeValue(self._element, key, value) + + def __delitem__(self, key): + _assertValidNode(self._element) + _delAttribute(self._element, key) + + def update(self, sequence_or_dict): + _assertValidNode(self._element) + if isinstance(sequence_or_dict, (dict, _Attrib)): + sequence_or_dict = sequence_or_dict.items() + for key, value in sequence_or_dict: + _setAttributeValue(self._element, key, value) + + def pop(self, key, *default): + if len(default) > 1: + raise TypeError, f"pop expected at most 2 arguments, got {len(default)+1}" + _assertValidNode(self._element) + result = _getAttributeValue(self._element, key, None) + if result is None: + if not default: + raise KeyError, key + result = default[0] + else: + _delAttribute(self._element, key) + return result + + def clear(self): + _assertValidNode(self._element) + c_attrs = self._element._c_node.properties + if c_attrs: + self._element._c_node.properties = NULL + tree.xmlFreePropList(c_attrs) + + # ACCESSORS + def __repr__(self): + _assertValidNode(self._element) + return repr(dict( _collectAttributes(self._element._c_node, 3) )) + + def __copy__(self): + _assertValidNode(self._element) + return dict(_collectAttributes(self._element._c_node, 3)) + + def __deepcopy__(self, memo): + _assertValidNode(self._element) + return dict(_collectAttributes(self._element._c_node, 3)) + + def __getitem__(self, key): + _assertValidNode(self._element) + result = _getAttributeValue(self._element, key, None) + if result is None: + raise KeyError, key + return result + + def __bool__(self): + _assertValidNode(self._element) + cdef xmlAttr* c_attr = self._element._c_node.properties + while c_attr is not NULL: + if c_attr.type == tree.XML_ATTRIBUTE_NODE: + return 1 + c_attr = c_attr.next + return 0 + + def __len__(self): + _assertValidNode(self._element) + cdef xmlAttr* c_attr = self._element._c_node.properties + cdef Py_ssize_t c = 0 + while c_attr is not NULL: + if c_attr.type == tree.XML_ATTRIBUTE_NODE: + c += 1 + c_attr = c_attr.next + return c + + def get(self, key, default=None): + _assertValidNode(self._element) + return _getAttributeValue(self._element, key, default) + + def keys(self): + _assertValidNode(self._element) + return _collectAttributes(self._element._c_node, 1) + + def __iter__(self): + _assertValidNode(self._element) + return iter(_collectAttributes(self._element._c_node, 1)) + + def iterkeys(self): + _assertValidNode(self._element) + return iter(_collectAttributes(self._element._c_node, 1)) + + def values(self): + _assertValidNode(self._element) + return _collectAttributes(self._element._c_node, 2) + + def itervalues(self): + _assertValidNode(self._element) + return iter(_collectAttributes(self._element._c_node, 2)) + + def items(self): + _assertValidNode(self._element) + return _collectAttributes(self._element._c_node, 3) + + def iteritems(self): + _assertValidNode(self._element) + return iter(_collectAttributes(self._element._c_node, 3)) + + def has_key(self, key): + _assertValidNode(self._element) + return key in self + + def __contains__(self, key): + _assertValidNode(self._element) + cdef xmlNode* c_node + ns, tag = _getNsTag(key) + c_node = self._element._c_node + c_href = NULL if ns is None else _xcstr(ns) + return 1 if tree.xmlHasNsProp(c_node, _xcstr(tag), c_href) else 0 + + def __richcmp__(self, other, int op): + try: + one = dict(self.items()) + if not isinstance(other, dict): + other = dict(other) + except (TypeError, ValueError): + return NotImplemented + return python.PyObject_RichCompare(one, other, op) + +MutableMapping.register(_Attrib) + + +@cython.final +@cython.internal +cdef class _AttribIterator: + """Attribute iterator - for internal use only! + """ + # XML attributes must not be removed while running! + cdef _Element _node + cdef xmlAttr* _c_attr + cdef int _keysvalues # 1 - keys, 2 - values, 3 - items (key, value) + def __iter__(self): + return self + + def __next__(self): + cdef xmlAttr* c_attr + if self._node is None: + raise StopIteration + c_attr = self._c_attr + while c_attr is not NULL and c_attr.type != tree.XML_ATTRIBUTE_NODE: + c_attr = c_attr.next + if c_attr is NULL: + self._node = None + raise StopIteration + + self._c_attr = c_attr.next + if self._keysvalues == 1: + return _namespacedName(c_attr) + elif self._keysvalues == 2: + return _attributeValue(self._node._c_node, c_attr) + else: + return (_namespacedName(c_attr), + _attributeValue(self._node._c_node, c_attr)) + +cdef object _attributeIteratorFactory(_Element element, int keysvalues): + cdef _AttribIterator attribs + if element._c_node.properties is NULL: + return ITER_EMPTY + attribs = _AttribIterator() + attribs._node = element + attribs._c_attr = element._c_node.properties + attribs._keysvalues = keysvalues + return attribs + + +cdef public class _ElementTagMatcher [ object LxmlElementTagMatcher, + type LxmlElementTagMatcherType ]: + """ + Dead but public. :) + """ + cdef object _pystrings + cdef int _node_type + cdef char* _href + cdef char* _name + cdef _initTagMatch(self, tag): + self._href = NULL + self._name = NULL + if tag is None: + self._node_type = 0 + elif tag is Comment: + self._node_type = tree.XML_COMMENT_NODE + elif tag is ProcessingInstruction: + self._node_type = tree.XML_PI_NODE + elif tag is Entity: + self._node_type = tree.XML_ENTITY_REF_NODE + elif tag is Element: + self._node_type = tree.XML_ELEMENT_NODE + else: + self._node_type = tree.XML_ELEMENT_NODE + self._pystrings = _getNsTag(tag) + if self._pystrings[0] is not None: + self._href = _cstr(self._pystrings[0]) + self._name = _cstr(self._pystrings[1]) + if self._name[0] == c'*' and self._name[1] == c'\0': + self._name = NULL + +cdef public class _ElementIterator(_ElementTagMatcher) [ + object LxmlElementIterator, type LxmlElementIteratorType ]: + """ + Dead but public. :) + """ + # we keep Python references here to control GC + cdef _Element _node + cdef _node_to_node_function _next_element + def __iter__(self): + return self + + cdef void _storeNext(self, _Element node): + cdef xmlNode* c_node + c_node = self._next_element(node._c_node) + while c_node is not NULL and \ + self._node_type != 0 and \ + (self._node_type != c_node.type or + not _tagMatches(c_node, self._href, self._name)): + c_node = self._next_element(c_node) + if c_node is NULL: + self._node = None + else: + # Python ref: + self._node = _elementFactory(node._doc, c_node) + + def __next__(self): + cdef xmlNode* c_node + cdef _Element current_node + if self._node is None: + raise StopIteration + # Python ref: + current_node = self._node + self._storeNext(current_node) + return current_node + +@cython.final +@cython.internal +cdef class _MultiTagMatcher: + """ + Match an xmlNode against a list of tags. + """ + cdef list _py_tags + cdef qname* _cached_tags + cdef size_t _tag_count + cdef size_t _cached_size + cdef _Document _cached_doc + cdef int _node_types + + def __cinit__(self, tags): + self._py_tags = [] + self.initTagMatch(tags) + + def __dealloc__(self): + self._clear() + + cdef bint rejectsAll(self) noexcept: + return not self._tag_count and not self._node_types + + cdef bint rejectsAllAttributes(self) noexcept: + return not self._tag_count + + cdef bint matchesType(self, int node_type) noexcept: + if node_type == tree.XML_ELEMENT_NODE and self._tag_count: + return True + return self._node_types & (1 << node_type) + + cdef void _clear(self) noexcept: + cdef size_t i, count + count = self._tag_count + self._tag_count = 0 + if self._cached_tags: + for i in range(count): + cpython.ref.Py_XDECREF(self._cached_tags[i].href) + python.lxml_free(self._cached_tags) + self._cached_tags = NULL + + cdef initTagMatch(self, tags): + self._cached_doc = None + del self._py_tags[:] + self._clear() + if tags is None or tags == (): + # no selection in tags argument => match anything + self._node_types = ( + 1 << tree.XML_COMMENT_NODE | + 1 << tree.XML_PI_NODE | + 1 << tree.XML_ENTITY_REF_NODE | + 1 << tree.XML_ELEMENT_NODE) + else: + self._node_types = 0 + self._storeTags(tags, set()) + + cdef _storeTags(self, tag, set seen): + if tag is Comment: + self._node_types |= 1 << tree.XML_COMMENT_NODE + elif tag is ProcessingInstruction: + self._node_types |= 1 << tree.XML_PI_NODE + elif tag is Entity: + self._node_types |= 1 << tree.XML_ENTITY_REF_NODE + elif tag is Element: + self._node_types |= 1 << tree.XML_ELEMENT_NODE + elif python._isString(tag): + if tag in seen: + return + seen.add(tag) + if tag in ('*', '{*}*'): + self._node_types |= 1 << tree.XML_ELEMENT_NODE + else: + href, name = _getNsTag(tag) + if name == b'*': + name = None + if href is None: + href = b'' # no namespace + elif href == b'*': + href = None # wildcard: any namespace, including none + self._py_tags.append((href, name)) + elif isinstance(tag, QName): + self._storeTags(tag.text, seen) + else: + # support a sequence of tags + for item in tag: + self._storeTags(item, seen) + + cdef inline int cacheTags(self, _Document doc, bint force_into_dict=False) except -1: + """ + Look up the tag names in the doc dict to enable string pointer comparisons. + """ + cdef size_t dict_size = tree.xmlDictSize(doc._c_doc.dict) + if doc is self._cached_doc and dict_size == self._cached_size: + # doc and dict didn't change => names already cached + return 0 + self._tag_count = 0 + if not self._py_tags: + self._cached_doc = doc + self._cached_size = dict_size + return 0 + if not self._cached_tags: + self._cached_tags = python.lxml_malloc(len(self._py_tags), sizeof(qname)) + if not self._cached_tags: + self._cached_doc = None + raise MemoryError() + self._tag_count = _mapTagsToQnameMatchArray( + doc._c_doc, self._py_tags, self._cached_tags, force_into_dict) + self._cached_doc = doc + self._cached_size = dict_size + return 0 + + cdef inline bint matches(self, xmlNode* c_node) noexcept: + cdef qname* c_qname + if self._node_types & (1 << c_node.type): + return True + elif c_node.type == tree.XML_ELEMENT_NODE: + for c_qname in self._cached_tags[:self._tag_count]: + if _tagMatchesExactly(c_node, c_qname): + return True + return False + + cdef inline bint matchesNsTag(self, const_xmlChar* c_href, + const_xmlChar* c_name) noexcept: + cdef qname* c_qname + if self._node_types & (1 << tree.XML_ELEMENT_NODE): + return True + for c_qname in self._cached_tags[:self._tag_count]: + if _nsTagMatchesExactly(c_href, c_name, c_qname): + return True + return False + + cdef inline bint matchesAttribute(self, xmlAttr* c_attr) noexcept: + """Attribute matches differ from Element matches in that they do + not care about node types. + """ + cdef qname* c_qname + for c_qname in self._cached_tags[:self._tag_count]: + if _tagMatchesExactly(c_attr, c_qname): + return True + return False + +cdef class _ElementMatchIterator: + cdef _Element _node + cdef _node_to_node_function _next_element + cdef _MultiTagMatcher _matcher + + @cython.final + cdef _initTagMatcher(self, tags): + self._matcher = _MultiTagMatcher.__new__(_MultiTagMatcher, tags) + + def __iter__(self): + return self + + @cython.final + cdef int _storeNext(self, _Element node) except -1: + self._matcher.cacheTags(node._doc) + c_node = self._next_element(node._c_node) + while c_node is not NULL and not self._matcher.matches(c_node): + c_node = self._next_element(c_node) + # store Python ref to next node to make sure it's kept alive + self._node = _elementFactory(node._doc, c_node) if c_node is not NULL else None + return 0 + + def __next__(self): + cdef _Element current_node = self._node + if current_node is None: + raise StopIteration + self._storeNext(current_node) + return current_node + +cdef class ElementChildIterator(_ElementMatchIterator): + """ElementChildIterator(self, node, tag=None, reversed=False) + Iterates over the children of an element. + """ + def __cinit__(self, _Element node not None, tag=None, *, bint reversed=False): + cdef xmlNode* c_node + _assertValidNode(node) + self._initTagMatcher(tag) + if reversed: + c_node = _findChildBackwards(node._c_node, 0) + self._next_element = _previousElement + else: + c_node = _findChildForwards(node._c_node, 0) + self._next_element = _nextElement + self._matcher.cacheTags(node._doc) + while c_node is not NULL and not self._matcher.matches(c_node): + c_node = self._next_element(c_node) + # store Python ref to next node to make sure it's kept alive + self._node = _elementFactory(node._doc, c_node) if c_node is not NULL else None + +cdef class SiblingsIterator(_ElementMatchIterator): + """SiblingsIterator(self, node, tag=None, preceding=False) + Iterates over the siblings of an element. + + You can pass the boolean keyword ``preceding`` to specify the direction. + """ + def __cinit__(self, _Element node not None, tag=None, *, bint preceding=False): + _assertValidNode(node) + self._initTagMatcher(tag) + if preceding: + self._next_element = _previousElement + else: + self._next_element = _nextElement + self._storeNext(node) + +cdef class AncestorsIterator(_ElementMatchIterator): + """AncestorsIterator(self, node, tag=None) + Iterates over the ancestors of an element (from parent to parent). + """ + def __cinit__(self, _Element node not None, tag=None): + _assertValidNode(node) + self._initTagMatcher(tag) + self._next_element = _parentElement + self._storeNext(node) + +cdef class ElementDepthFirstIterator: + """ElementDepthFirstIterator(self, node, tag=None, inclusive=True) + Iterates over an element and its sub-elements in document order (depth + first pre-order). + + Note that this also includes comments, entities and processing + instructions. To filter them out, check if the ``tag`` property + of the returned element is a string (i.e. not None and not a + factory function), or pass the ``Element`` factory for the ``tag`` + argument to receive only Elements. + + If the optional ``tag`` argument is not None, the iterator returns only + the elements that match the respective name and namespace. + + The optional boolean argument 'inclusive' defaults to True and can be set + to False to exclude the start element itself. + + Note that the behaviour of this iterator is completely undefined if the + tree it traverses is modified during iteration. + """ + # we keep Python references here to control GC + # keep the next Element after the one we return, and the (s)top node + cdef _Element _next_node + cdef _Element _top_node + cdef _MultiTagMatcher _matcher + def __cinit__(self, _Element node not None, tag=None, *, bint inclusive=True): + _assertValidNode(node) + self._top_node = node + self._next_node = node + self._matcher = _MultiTagMatcher.__new__(_MultiTagMatcher, tag) + self._matcher.cacheTags(node._doc) + if not inclusive or not self._matcher.matches(node._c_node): + # find start node (this cannot raise StopIteration, self._next_node != None) + next(self) + + def __iter__(self): + return self + + def __next__(self): + cdef xmlNode* c_node + cdef _Element current_node = self._next_node + if current_node is None: + raise StopIteration + c_node = current_node._c_node + self._matcher.cacheTags(current_node._doc) + if not self._matcher._tag_count: + # no tag name was found in the dict => not in document either + # try to match by node type + c_node = self._nextNodeAnyTag(c_node) + else: + c_node = self._nextNodeMatchTag(c_node) + if c_node is NULL: + self._next_node = None + else: + self._next_node = _elementFactory(current_node._doc, c_node) + return current_node + + @cython.final + cdef xmlNode* _nextNodeAnyTag(self, xmlNode* c_node) noexcept: + cdef int node_types = self._matcher._node_types + if not node_types: + return NULL + tree.BEGIN_FOR_EACH_ELEMENT_FROM(self._top_node._c_node, c_node, 0) + if node_types & (1 << c_node.type): + return c_node + tree.END_FOR_EACH_ELEMENT_FROM(c_node) + return NULL + + @cython.final + cdef xmlNode* _nextNodeMatchTag(self, xmlNode* c_node) noexcept: + tree.BEGIN_FOR_EACH_ELEMENT_FROM(self._top_node._c_node, c_node, 0) + if self._matcher.matches(c_node): + return c_node + tree.END_FOR_EACH_ELEMENT_FROM(c_node) + return NULL + + +cdef class ElementTextIterator: + """ElementTextIterator(self, element, tag=None, with_tail=True) + Iterates over the text content of a subtree. + + You can pass the ``tag`` keyword argument to restrict text content to a + specific tag name. + + You can set the ``with_tail`` keyword argument to ``False`` to skip over + tail text (e.g. if you know that it's only whitespace from pretty-printing). + """ + cdef object _events + cdef _Element _start_element + def __cinit__(self, _Element element not None, tag=None, *, bint with_tail=True): + _assertValidNode(element) + if with_tail: + events = ("start", "comment", "pi", "end") + else: + events = ("start",) + self._start_element = element + self._events = iterwalk(element, events=events, tag=tag) + + def __iter__(self): + return self + + def __next__(self): + cdef _Element element + result = None + while result is None: + event, element = next(self._events) # raises StopIteration + if event == "start": + result = element.text + elif element is not self._start_element: + result = element.tail + return result + + +cdef xmlNode* _createElement(xmlDoc* c_doc, object name_utf) except NULL: + cdef xmlNode* c_node + c_node = tree.xmlNewDocNode(c_doc, NULL, _xcstr(name_utf), NULL) + return c_node + +cdef xmlNode* _createComment(xmlDoc* c_doc, const_xmlChar* text) noexcept: + cdef xmlNode* c_node + c_node = tree.xmlNewDocComment(c_doc, text) + return c_node + +cdef xmlNode* _createPI(xmlDoc* c_doc, const_xmlChar* target, const_xmlChar* text) noexcept: + cdef xmlNode* c_node + c_node = tree.xmlNewDocPI(c_doc, target, text) + return c_node + +cdef xmlNode* _createEntity(xmlDoc* c_doc, const_xmlChar* name) noexcept: + cdef xmlNode* c_node + c_node = tree.xmlNewReference(c_doc, name) + return c_node + +# module-level API for ElementTree + +from abc import ABC + +class Element(ABC): + """Element(_tag, attrib=None, nsmap=None, **_extra) + + Element factory, as a class. + + An instance of this class is an object implementing the + Element interface. + + >>> element = Element("test") + >>> type(element) + + >>> isinstance(element, Element) + True + >>> issubclass(_Element, Element) + True + + Also look at the `_Element.makeelement()` and + `_BaseParser.makeelement()` methods, which provide a faster way to + create an Element within a specific document or parser context. + """ + def __new__(cls, _tag, attrib=None, nsmap=None, **_extra): + return _makeElement(_tag, NULL, None, None, None, None, + attrib, nsmap, _extra) + +# Register _Element as a virtual subclass of Element +Element.register(_Element) + + +def Comment(text=None): + """Comment(text=None) + + Comment element factory. This factory function creates a special element that will + be serialized as an XML comment. + """ + cdef _Document doc + cdef xmlNode* c_node + cdef xmlDoc* c_doc + + if text is None: + text = b'' + else: + text = _utf8(text) + if b'--' in text or text.endswith(b'-'): + raise ValueError("Comment may not contain '--' or end with '-'") + + c_doc = _newXMLDoc() + doc = _documentFactory(c_doc, None) + c_node = _createComment(c_doc, _xcstr(text)) + tree.xmlAddChild(c_doc, c_node) + return _elementFactory(doc, c_node) + + +def ProcessingInstruction(target, text=None): + """ProcessingInstruction(target, text=None) + + ProcessingInstruction element factory. This factory function creates a + special element that will be serialized as an XML processing instruction. + """ + cdef _Document doc + cdef xmlNode* c_node + cdef xmlDoc* c_doc + + target = _utf8(target) + _tagValidOrRaise(target) + if target.lower() == b'xml': + raise ValueError, f"Invalid PI name '{target}'" + + if text is None: + text = b'' + else: + text = _utf8(text) + if b'?>' in text: + raise ValueError, "PI text must not contain '?>'" + + c_doc = _newXMLDoc() + doc = _documentFactory(c_doc, None) + c_node = _createPI(c_doc, _xcstr(target), _xcstr(text)) + tree.xmlAddChild(c_doc, c_node) + return _elementFactory(doc, c_node) + +PI = ProcessingInstruction + + +cdef class CDATA: + """CDATA(data) + + CDATA factory. This factory creates an opaque data object that + can be used to set Element text. The usual way to use it is:: + + >>> el = Element('content') + >>> el.text = CDATA('a string') + + >>> print(el.text) + a string + >>> print(tostring(el, encoding="unicode")) + + """ + cdef bytes _utf8_data + def __cinit__(self, data): + self._utf8_data = _utf8(data) + + +def Entity(name): + """Entity(name) + + Entity factory. This factory function creates a special element + that will be serialized as an XML entity reference or character + reference. Note, however, that entities will not be automatically + declared in the document. A document that uses entity references + requires a DTD to define the entities. + """ + cdef _Document doc + cdef xmlNode* c_node + cdef xmlDoc* c_doc + name_utf = _utf8(name) + c_name = _xcstr(name_utf) + if c_name[0] == c'#': + if not _characterReferenceIsValid(c_name + 1): + raise ValueError, f"Invalid character reference: '{name}'" + elif not _xmlNameIsValid(c_name): + raise ValueError, f"Invalid entity reference: '{name}'" + c_doc = _newXMLDoc() + doc = _documentFactory(c_doc, None) + c_node = _createEntity(c_doc, c_name) + tree.xmlAddChild(c_doc, c_node) + return _elementFactory(doc, c_node) + + +def SubElement(_Element _parent not None, _tag, + attrib=None, nsmap=None, **_extra): + """SubElement(_parent, _tag, attrib=None, nsmap=None, **_extra) + + Subelement factory. This function creates an element instance, and + appends it to an existing element. + """ + return _makeSubElement(_parent, _tag, None, None, attrib, nsmap, _extra) + +from typing import Generic, TypeVar + +T = TypeVar("T") + +class ElementTree(ABC, Generic[T]): + def __new__(cls, _Element element=None, *, file=None, _BaseParser parser=None): + """ElementTree(element=None, file=None, parser=None) + + ElementTree wrapper class. + """ + cdef xmlNode* c_next + cdef xmlNode* c_node + cdef xmlNode* c_node_copy + cdef xmlDoc* c_doc + cdef _ElementTree etree + cdef _Document doc + + if element is not None: + doc = element._doc + elif file is not None: + try: + doc = _parseDocument(file, parser, None) + except _TargetParserResult as result_container: + return result_container.result + else: + c_doc = _newXMLDoc() + doc = _documentFactory(c_doc, parser) + + return _elementTreeFactory(doc, element) + +# Register _ElementTree as a virtual subclass of ElementTree +ElementTree.register(_ElementTree) + +# Remove "ABC" and typing helpers from module dict +del ABC, Generic, TypeVar, T + +def HTML(text, _BaseParser parser=None, *, base_url=None): + """HTML(text, parser=None, base_url=None) + + Parses an HTML document from a string constant. Returns the root + node (or the result returned by a parser target). This function + can be used to embed "HTML literals" in Python code. + + To override the parser with a different ``HTMLParser`` you can pass it to + the ``parser`` keyword argument. + + The ``base_url`` keyword argument allows to set the original base URL of + the document to support relative Paths when looking up external entities + (DTD, XInclude, ...). + """ + cdef _Document doc + if parser is None: + parser = __GLOBAL_PARSER_CONTEXT.getDefaultParser() + if not isinstance(parser, HTMLParser): + parser = __DEFAULT_HTML_PARSER + try: + doc = _parseMemoryDocument(text, base_url, parser) + return doc.getroot() + except _TargetParserResult as result_container: + return result_container.result + + +def XML(text, _BaseParser parser=None, *, base_url=None): + """XML(text, parser=None, base_url=None) + + Parses an XML document or fragment from a string constant. + Returns the root node (or the result returned by a parser target). + This function can be used to embed "XML literals" in Python code, + like in + + >>> root = XML("") + >>> print(root.tag) + root + + To override the parser with a different ``XMLParser`` you can pass it to + the ``parser`` keyword argument. + + The ``base_url`` keyword argument allows to set the original base URL of + the document to support relative Paths when looking up external entities + (DTD, XInclude, ...). + """ + cdef _Document doc + if parser is None: + parser = __GLOBAL_PARSER_CONTEXT.getDefaultParser() + if not isinstance(parser, XMLParser): + parser = __DEFAULT_XML_PARSER + try: + doc = _parseMemoryDocument(text, base_url, parser) + return doc.getroot() + except _TargetParserResult as result_container: + return result_container.result + + +def fromstring(text, _BaseParser parser=None, *, base_url=None): + """fromstring(text, parser=None, base_url=None) + + Parses an XML document or fragment from a string. Returns the + root node (or the result returned by a parser target). + + To override the default parser with a different parser you can pass it to + the ``parser`` keyword argument. + + The ``base_url`` keyword argument allows to set the original base URL of + the document to support relative Paths when looking up external entities + (DTD, XInclude, ...). + """ + cdef _Document doc + try: + doc = _parseMemoryDocument(text, base_url, parser) + return doc.getroot() + except _TargetParserResult as result_container: + return result_container.result + + +def fromstringlist(strings, _BaseParser parser=None): + """fromstringlist(strings, parser=None) + + Parses an XML document from a sequence of strings. Returns the + root node (or the result returned by a parser target). + + To override the default parser with a different parser you can pass it to + the ``parser`` keyword argument. + """ + cdef _Document doc + if isinstance(strings, (bytes, unicode)): + raise ValueError("passing a single string into fromstringlist() is not" + " efficient, use fromstring() instead") + if parser is None: + parser = __GLOBAL_PARSER_CONTEXT.getDefaultParser() + feed = parser.feed + for data in strings: + feed(data) + return parser.close() + + +def iselement(element): + """iselement(element) + + Checks if an object appears to be a valid element object. + """ + return isinstance(element, _Element) and (<_Element>element)._c_node is not NULL + + +def indent(tree, space=" ", *, Py_ssize_t level=0): + """indent(tree, space=" ", level=0) + + Indent an XML document by inserting newlines and indentation space + after elements. + + *tree* is the ElementTree or Element to modify. The (root) element + itself will not be changed, but the tail text of all elements in its + subtree will be adapted. + + *space* is the whitespace to insert for each indentation level, two + space characters by default. + + *level* is the initial indentation level. Setting this to a higher + value than 0 can be used for indenting subtrees that are more deeply + nested inside of a document. + """ + root = _rootNodeOrRaise(tree) + if level < 0: + raise ValueError(f"Initial indentation level must be >= 0, got {level}") + if _hasChild(root._c_node): + space = _utf8(space) + indent = b"\n" + level * space + _indent_children(root._c_node, 1, space, [indent, indent + space]) + + +cdef int _indent_children(xmlNode* c_node, Py_ssize_t level, bytes one_space, list indentations) except -1: + # Reuse indentation strings for speed. + if len(indentations) <= level: + indentations.append(indentations[-1] + one_space) + + # Start a new indentation level for the first child. + child_indentation = indentations[level] + if not _hasNonWhitespaceText(c_node): + _setNodeText(c_node, child_indentation) + + # Recursively indent all children. + cdef xmlNode* c_child = _findChildForwards(c_node, 0) + while c_child is not NULL: + if _hasChild(c_child): + _indent_children(c_child, level+1, one_space, indentations) + c_next_child = _nextElement(c_child) + if not _hasNonWhitespaceTail(c_child): + if c_next_child is NULL: + # Dedent after the last child. + child_indentation = indentations[level-1] + _setTailText(c_child, child_indentation) + c_child = c_next_child + return 0 + + +def dump(_Element elem not None, *, bint pretty_print=True, bint with_tail=True): + """dump(elem, pretty_print=True, with_tail=True) + + Writes an element tree or element structure to sys.stdout. This function + should be used for debugging only. + """ + xml = tostring(elem, pretty_print=pretty_print, with_tail=with_tail, encoding='unicode') + if not pretty_print: + xml += '\n' + sys.stdout.write(xml) + + +def tostring(element_or_tree, *, encoding=None, method="xml", + xml_declaration=None, bint pretty_print=False, bint with_tail=True, + standalone=None, doctype=None, + # method='c14n' + bint exclusive=False, inclusive_ns_prefixes=None, + # method='c14n2' + bint with_comments=True, bint strip_text=False, + ): + """tostring(element_or_tree, encoding=None, method="xml", + xml_declaration=None, pretty_print=False, with_tail=True, + standalone=None, doctype=None, + exclusive=False, inclusive_ns_prefixes=None, + with_comments=True, strip_text=False, + ) + + Serialize an element to an encoded string representation of its XML + tree. + + Defaults to ASCII encoding without XML declaration. This + behaviour can be configured with the keyword arguments 'encoding' + (string) and 'xml_declaration' (bool). Note that changing the + encoding to a non UTF-8 compatible encoding will enable a + declaration by default. + + You can also serialise to a Unicode string without declaration by + passing the name ``'unicode'`` as encoding (or the ``str`` function + in Py3 or ``unicode`` in Py2). This changes the return value from + a byte string to an unencoded unicode string. + + The keyword argument 'pretty_print' (bool) enables formatted XML. + + The keyword argument 'method' selects the output method: 'xml', + 'html', plain 'text' (text content without tags), 'c14n' or 'c14n2'. + Default is 'xml'. + + With ``method="c14n"`` (C14N version 1), the options ``exclusive``, + ``with_comments`` and ``inclusive_ns_prefixes`` request exclusive + C14N, include comments, and list the inclusive prefixes respectively. + + With ``method="c14n2"`` (C14N version 2), the ``with_comments`` and + ``strip_text`` options control the output of comments and text space + according to C14N 2.0. + + Passing a boolean value to the ``standalone`` option will output + an XML declaration with the corresponding ``standalone`` flag. + + The ``doctype`` option allows passing in a plain string that will + be serialised before the XML tree. Note that passing in non + well-formed content here will make the XML output non well-formed. + Also, an existing doctype in the document tree will not be removed + when serialising an ElementTree instance. + + You can prevent the tail text of the element from being serialised + by passing the boolean ``with_tail`` option. This has no impact + on the tail text of children, which will always be serialised. + """ + cdef bint write_declaration + cdef int is_standalone + # C14N serialisation + if method in ('c14n', 'c14n2'): + if encoding is not None: + raise ValueError("Cannot specify encoding with C14N") + if xml_declaration: + raise ValueError("Cannot enable XML declaration in C14N") + if method == 'c14n': + return _tostringC14N(element_or_tree, exclusive, with_comments, inclusive_ns_prefixes) + else: + out = BytesIO() + target = C14NWriterTarget( + utf8_writer(out).write, + with_comments=with_comments, strip_text=strip_text) + _tree_to_target(element_or_tree, target) + return out.getvalue() + if not with_comments: + raise ValueError("Can only discard comments in C14N serialisation") + if strip_text: + raise ValueError("Can only strip text in C14N 2.0 serialisation") + if encoding is unicode or (encoding is not None and encoding.lower() == 'unicode'): + if xml_declaration: + raise ValueError, \ + "Serialisation to unicode must not request an XML declaration" + write_declaration = 0 + encoding = unicode + elif xml_declaration is None: + # by default, write an XML declaration only for non-standard encodings + write_declaration = encoding is not None and encoding.upper() not in \ + ('ASCII', 'UTF-8', 'UTF8', 'US-ASCII') + else: + write_declaration = xml_declaration + if encoding is None: + encoding = 'ASCII' + if standalone is None: + is_standalone = -1 + elif standalone: + write_declaration = 1 + is_standalone = 1 + else: + write_declaration = 1 + is_standalone = 0 + + if isinstance(element_or_tree, _Element): + return _tostring(<_Element>element_or_tree, encoding, doctype, method, + write_declaration, 0, pretty_print, with_tail, + is_standalone) + elif isinstance(element_or_tree, _ElementTree): + return _tostring((<_ElementTree>element_or_tree)._context_node, + encoding, doctype, method, write_declaration, 1, + pretty_print, with_tail, is_standalone) + else: + raise TypeError, f"Type '{python._fqtypename(element_or_tree).decode('utf8')}' cannot be serialized." + + + +def tostringlist(element_or_tree, *args, **kwargs): + """tostringlist(element_or_tree, *args, **kwargs) + + Serialize an element to an encoded string representation of its XML + tree, stored in a list of partial strings. + + This is purely for ElementTree 1.3 compatibility. The result is a + single string wrapped in a list. + """ + return [tostring(element_or_tree, *args, **kwargs)] + + +def tounicode(element_or_tree, *, method="xml", bint pretty_print=False, + bint with_tail=True, doctype=None): + """tounicode(element_or_tree, method="xml", pretty_print=False, + with_tail=True, doctype=None) + + Serialize an element to the Python unicode representation of its XML + tree. + + :deprecated: use ``tostring(el, encoding='unicode')`` instead. + + Note that the result does not carry an XML encoding declaration and is + therefore not necessarily suited for serialization to byte streams without + further treatment. + + The boolean keyword argument 'pretty_print' enables formatted XML. + + The keyword argument 'method' selects the output method: 'xml', + 'html' or plain 'text'. + + You can prevent the tail text of the element from being serialised + by passing the boolean ``with_tail`` option. This has no impact + on the tail text of children, which will always be serialised. + """ + if isinstance(element_or_tree, _Element): + return _tostring(<_Element>element_or_tree, unicode, doctype, method, + 0, 0, pretty_print, with_tail, -1) + elif isinstance(element_or_tree, _ElementTree): + return _tostring((<_ElementTree>element_or_tree)._context_node, + unicode, doctype, method, 0, 1, pretty_print, + with_tail, -1) + else: + raise TypeError, f"Type '{type(element_or_tree)}' cannot be serialized." + + +def parse(source, _BaseParser parser=None, *, base_url=None): + """parse(source, parser=None, base_url=None) + + Return an ElementTree object loaded with source elements. If no parser + is provided as second argument, the default parser is used. + + The ``source`` can be any of the following: + + - a file name/path + - a file object + - a file-like object + - a URL using the HTTP or FTP protocol + + To parse from a string, use the ``fromstring()`` function instead. + + Note that it is generally faster to parse from a file path or URL + than from an open file object or file-like object. Transparent + decompression from gzip compressed sources is supported (unless + explicitly disabled in libxml2). + + The ``base_url`` keyword allows setting a URL for the document + when parsing from a file-like object. This is needed when looking + up external entities (DTD, XInclude, ...) with relative paths. + """ + cdef _Document doc + try: + doc = _parseDocument(source, parser, base_url) + return _elementTreeFactory(doc, None) + except _TargetParserResult as result_container: + return result_container.result + + +def adopt_external_document(capsule, _BaseParser parser=None): + """adopt_external_document(capsule, parser=None) + + Unpack a libxml2 document pointer from a PyCapsule and wrap it in an + lxml ElementTree object. + + This allows external libraries to build XML/HTML trees using libxml2 + and then pass them efficiently into lxml for further processing. + + If a ``parser`` is provided, it will be used for configuring the + lxml document. No parsing will be done. + + The capsule must have the name ``"libxml2:xmlDoc"`` and its pointer + value must reference a correct libxml2 document of type ``xmlDoc*``. + The creator of the capsule must take care to correctly clean up the + document using an appropriate capsule destructor. By default, the + libxml2 document will be copied to let lxml safely own the memory + of the internal tree that it uses. + + If the capsule context is non-NULL, it must point to a C string that + can be compared using ``strcmp()``. If the context string equals + ``"destructor:xmlFreeDoc"``, the libxml2 document will not be copied + but the capsule invalidated instead by clearing its destructor and + name. That way, lxml takes ownership of the libxml2 document in memory + without creating a copy first, and the capsule destructor will not be + called. The document will then eventually be cleaned up by lxml using + the libxml2 API function ``xmlFreeDoc()`` once it is no longer used. + + If no copy is made, later modifications of the tree outside of lxml + should not be attempted after transferring the ownership. + """ + cdef xmlDoc* c_doc + cdef bint is_owned = False + c_doc = python.lxml_unpack_xmldoc_capsule(capsule, &is_owned) + doc = _adoptForeignDoc(c_doc, parser, is_owned) + return _elementTreeFactory(doc, None) + + +################################################################################ +# Include submodules + +include "readonlytree.pxi" # Read-only implementation of Element proxies +include "classlookup.pxi" # Element class lookup mechanisms +include "nsclasses.pxi" # Namespace implementation and registry +include "docloader.pxi" # Support for custom document loaders +include "parser.pxi" # XML and HTML parsers +include "saxparser.pxi" # SAX-like Parser interface and tree builder +include "parsertarget.pxi" # ET Parser target +include "serializer.pxi" # XML output functions +include "iterparse.pxi" # incremental XML parsing +include "xmlid.pxi" # XMLID and IDDict +include "xinclude.pxi" # XInclude +include "cleanup.pxi" # Cleanup and recursive element removal functions + + +################################################################################ +# Include submodules for XPath and XSLT + +include "extensions.pxi" # XPath/XSLT extension functions +include "xpath.pxi" # XPath evaluation +include "xslt.pxi" # XSL transformations +include "xsltext.pxi" # XSL extension elements + + +################################################################################ +# Validation + +cdef class DocumentInvalid(LxmlError): + """Validation error. + + Raised by all document validators when their ``assertValid(tree)`` + method fails. + """ + + +cdef class _Validator: + "Base class for XML validators." + cdef _ErrorLog _error_log + def __cinit__(self): + self._error_log = _ErrorLog() + + def validate(self, etree): + """validate(self, etree) + + Validate the document using this schema. + + Returns true if document is valid, false if not. + """ + return self(etree) + + def assertValid(self, etree): + """assertValid(self, etree) + + Raises `DocumentInvalid` if the document does not comply with the schema. + """ + if not self(etree): + raise DocumentInvalid(self._error_log._buildExceptionMessage( + "Document does not comply with schema"), + self._error_log) + + def assert_(self, etree): + """assert_(self, etree) + + Raises `AssertionError` if the document does not comply with the schema. + """ + if not self(etree): + raise AssertionError, self._error_log._buildExceptionMessage( + "Document does not comply with schema") + + cpdef _append_log_message(self, int domain, int type, int level, int line, + message, filename): + self._error_log._receiveGeneric(domain, type, level, line, message, + filename) + + cpdef _clear_error_log(self): + self._error_log.clear() + + @property + def error_log(self): + """The log of validation errors and warnings.""" + assert self._error_log is not None, "XPath evaluator not initialised" + return self._error_log.copy() + +include "dtd.pxi" # DTD +include "relaxng.pxi" # RelaxNG +include "xmlschema.pxi" # XMLSchema +include "schematron.pxi" # Schematron (requires libxml2 2.6.21+) + +################################################################################ +# Public C API + +include "public-api.pxi" + +################################################################################ +# Other stuff + +include "debug.pxi" diff --git a/presentation/.venv/lib/python3.12/site-packages/lxml/etree_api.h b/presentation/.venv/lib/python3.12/site-packages/lxml/etree_api.h new file mode 100644 index 0000000..702fef2 --- /dev/null +++ b/presentation/.venv/lib/python3.12/site-packages/lxml/etree_api.h @@ -0,0 +1,214 @@ +/* Generated by Cython 3.2.4 */ + +#ifndef __PYX_HAVE_API__lxml__etree +#define __PYX_HAVE_API__lxml__etree +#ifdef __MINGW64__ +#define MS_WIN64 +#endif +#include "Python.h" +#include "etree.h" + +static struct LxmlElement *(*__pyx_api_f_4lxml_5etree_deepcopyNodeToDocument)(struct LxmlDocument *, xmlNode *) = 0; +#define deepcopyNodeToDocument __pyx_api_f_4lxml_5etree_deepcopyNodeToDocument +static struct LxmlElementTree *(*__pyx_api_f_4lxml_5etree_elementTreeFactory)(struct LxmlElement *) = 0; +#define elementTreeFactory __pyx_api_f_4lxml_5etree_elementTreeFactory +static struct LxmlElementTree *(*__pyx_api_f_4lxml_5etree_newElementTree)(struct LxmlElement *, PyObject *) = 0; +#define newElementTree __pyx_api_f_4lxml_5etree_newElementTree +static struct LxmlElementTree *(*__pyx_api_f_4lxml_5etree_adoptExternalDocument)(xmlDoc *, PyObject *, int) = 0; +#define adoptExternalDocument __pyx_api_f_4lxml_5etree_adoptExternalDocument +static struct LxmlElement *(*__pyx_api_f_4lxml_5etree_elementFactory)(struct LxmlDocument *, xmlNode *) = 0; +#define elementFactory __pyx_api_f_4lxml_5etree_elementFactory +static struct LxmlElement *(*__pyx_api_f_4lxml_5etree_makeElement)(PyObject *, struct LxmlDocument *, PyObject *, PyObject *, PyObject *, PyObject *, PyObject *) = 0; +#define makeElement __pyx_api_f_4lxml_5etree_makeElement +static struct LxmlElement *(*__pyx_api_f_4lxml_5etree_makeSubElement)(struct LxmlElement *, PyObject *, PyObject *, PyObject *, PyObject *, PyObject *) = 0; +#define makeSubElement __pyx_api_f_4lxml_5etree_makeSubElement +static void (*__pyx_api_f_4lxml_5etree_setElementClassLookupFunction)(_element_class_lookup_function, PyObject *) = 0; +#define setElementClassLookupFunction __pyx_api_f_4lxml_5etree_setElementClassLookupFunction +static PyObject *(*__pyx_api_f_4lxml_5etree_lookupDefaultElementClass)(PyObject *, PyObject *, xmlNode *) = 0; +#define lookupDefaultElementClass __pyx_api_f_4lxml_5etree_lookupDefaultElementClass +static PyObject *(*__pyx_api_f_4lxml_5etree_lookupNamespaceElementClass)(PyObject *, PyObject *, xmlNode *) = 0; +#define lookupNamespaceElementClass __pyx_api_f_4lxml_5etree_lookupNamespaceElementClass +static PyObject *(*__pyx_api_f_4lxml_5etree_callLookupFallback)(struct LxmlFallbackElementClassLookup *, struct LxmlDocument *, xmlNode *) = 0; +#define callLookupFallback __pyx_api_f_4lxml_5etree_callLookupFallback +static int (*__pyx_api_f_4lxml_5etree_tagMatches)(xmlNode *, const xmlChar *, const xmlChar *) = 0; +#define tagMatches __pyx_api_f_4lxml_5etree_tagMatches +static struct LxmlDocument *(*__pyx_api_f_4lxml_5etree_documentOrRaise)(PyObject *) = 0; +#define documentOrRaise __pyx_api_f_4lxml_5etree_documentOrRaise +static struct LxmlElement *(*__pyx_api_f_4lxml_5etree_rootNodeOrRaise)(PyObject *) = 0; +#define rootNodeOrRaise __pyx_api_f_4lxml_5etree_rootNodeOrRaise +static int (*__pyx_api_f_4lxml_5etree_hasText)(xmlNode *) = 0; +#define hasText __pyx_api_f_4lxml_5etree_hasText +static int (*__pyx_api_f_4lxml_5etree_hasTail)(xmlNode *) = 0; +#define hasTail __pyx_api_f_4lxml_5etree_hasTail +static PyObject *(*__pyx_api_f_4lxml_5etree_textOf)(xmlNode *) = 0; +#define textOf __pyx_api_f_4lxml_5etree_textOf +static PyObject *(*__pyx_api_f_4lxml_5etree_tailOf)(xmlNode *) = 0; +#define tailOf __pyx_api_f_4lxml_5etree_tailOf +static int (*__pyx_api_f_4lxml_5etree_setNodeText)(xmlNode *, PyObject *) = 0; +#define setNodeText __pyx_api_f_4lxml_5etree_setNodeText +static int (*__pyx_api_f_4lxml_5etree_setTailText)(xmlNode *, PyObject *) = 0; +#define setTailText __pyx_api_f_4lxml_5etree_setTailText +static PyObject *(*__pyx_api_f_4lxml_5etree_attributeValue)(xmlNode *, xmlAttr *) = 0; +#define attributeValue __pyx_api_f_4lxml_5etree_attributeValue +static PyObject *(*__pyx_api_f_4lxml_5etree_attributeValueFromNsName)(xmlNode *, const xmlChar *, const xmlChar *) = 0; +#define attributeValueFromNsName __pyx_api_f_4lxml_5etree_attributeValueFromNsName +static PyObject *(*__pyx_api_f_4lxml_5etree_getAttributeValue)(struct LxmlElement *, PyObject *, PyObject *) = 0; +#define getAttributeValue __pyx_api_f_4lxml_5etree_getAttributeValue +static PyObject *(*__pyx_api_f_4lxml_5etree_iterattributes)(struct LxmlElement *, int) = 0; +#define iterattributes __pyx_api_f_4lxml_5etree_iterattributes +static PyObject *(*__pyx_api_f_4lxml_5etree_collectAttributes)(xmlNode *, int) = 0; +#define collectAttributes __pyx_api_f_4lxml_5etree_collectAttributes +static int (*__pyx_api_f_4lxml_5etree_setAttributeValue)(struct LxmlElement *, PyObject *, PyObject *) = 0; +#define setAttributeValue __pyx_api_f_4lxml_5etree_setAttributeValue +static int (*__pyx_api_f_4lxml_5etree_delAttribute)(struct LxmlElement *, PyObject *) = 0; +#define delAttribute __pyx_api_f_4lxml_5etree_delAttribute +static int (*__pyx_api_f_4lxml_5etree_delAttributeFromNsName)(xmlNode *, const xmlChar *, const xmlChar *) = 0; +#define delAttributeFromNsName __pyx_api_f_4lxml_5etree_delAttributeFromNsName +static int (*__pyx_api_f_4lxml_5etree_hasChild)(xmlNode *) = 0; +#define hasChild __pyx_api_f_4lxml_5etree_hasChild +static xmlNode *(*__pyx_api_f_4lxml_5etree_findChild)(xmlNode *, Py_ssize_t) = 0; +#define findChild __pyx_api_f_4lxml_5etree_findChild +static xmlNode *(*__pyx_api_f_4lxml_5etree_findChildForwards)(xmlNode *, Py_ssize_t) = 0; +#define findChildForwards __pyx_api_f_4lxml_5etree_findChildForwards +static xmlNode *(*__pyx_api_f_4lxml_5etree_findChildBackwards)(xmlNode *, Py_ssize_t) = 0; +#define findChildBackwards __pyx_api_f_4lxml_5etree_findChildBackwards +static xmlNode *(*__pyx_api_f_4lxml_5etree_nextElement)(xmlNode *) = 0; +#define nextElement __pyx_api_f_4lxml_5etree_nextElement +static xmlNode *(*__pyx_api_f_4lxml_5etree_previousElement)(xmlNode *) = 0; +#define previousElement __pyx_api_f_4lxml_5etree_previousElement +static void (*__pyx_api_f_4lxml_5etree_appendChild)(struct LxmlElement *, struct LxmlElement *) = 0; +#define appendChild __pyx_api_f_4lxml_5etree_appendChild +static int (*__pyx_api_f_4lxml_5etree_appendChildToElement)(struct LxmlElement *, struct LxmlElement *) = 0; +#define appendChildToElement __pyx_api_f_4lxml_5etree_appendChildToElement +static PyObject *(*__pyx_api_f_4lxml_5etree_pyunicode)(const xmlChar *) = 0; +#define pyunicode __pyx_api_f_4lxml_5etree_pyunicode +static PyObject *(*__pyx_api_f_4lxml_5etree_utf8)(PyObject *) = 0; +#define utf8 __pyx_api_f_4lxml_5etree_utf8 +static PyObject *(*__pyx_api_f_4lxml_5etree_getNsTag)(PyObject *) = 0; +#define getNsTag __pyx_api_f_4lxml_5etree_getNsTag +static PyObject *(*__pyx_api_f_4lxml_5etree_getNsTagWithEmptyNs)(PyObject *) = 0; +#define getNsTagWithEmptyNs __pyx_api_f_4lxml_5etree_getNsTagWithEmptyNs +static PyObject *(*__pyx_api_f_4lxml_5etree_namespacedName)(xmlNode *) = 0; +#define namespacedName __pyx_api_f_4lxml_5etree_namespacedName +static PyObject *(*__pyx_api_f_4lxml_5etree_namespacedNameFromNsName)(const xmlChar *, const xmlChar *) = 0; +#define namespacedNameFromNsName __pyx_api_f_4lxml_5etree_namespacedNameFromNsName +static void (*__pyx_api_f_4lxml_5etree_iteratorStoreNext)(struct LxmlElementIterator *, struct LxmlElement *) = 0; +#define iteratorStoreNext __pyx_api_f_4lxml_5etree_iteratorStoreNext +static void (*__pyx_api_f_4lxml_5etree_initTagMatch)(struct LxmlElementTagMatcher *, PyObject *) = 0; +#define initTagMatch __pyx_api_f_4lxml_5etree_initTagMatch +static xmlNs *(*__pyx_api_f_4lxml_5etree_findOrBuildNodeNsPrefix)(struct LxmlDocument *, xmlNode *, const xmlChar *, const xmlChar *) = 0; +#define findOrBuildNodeNsPrefix __pyx_api_f_4lxml_5etree_findOrBuildNodeNsPrefix +static int __Pyx_ImportFunction_3_2_4(PyObject *module, const char *funcname, void (**f)(void), const char *sig); + +#ifndef __PYX_HAVE_RT_ImportFromPxd_3_2_4 +#define __PYX_HAVE_RT_ImportFromPxd_3_2_4 +static int __Pyx_ImportFromPxd_3_2_4(PyObject *module, const char *name, void **p, const char *sig, const char *what) { + PyObject *d = 0; + PyObject *cobj = 0; + d = PyObject_GetAttrString(module, "__pyx_capi__"); + if (!d) + goto bad; +#if (defined(Py_LIMITED_API) && Py_LIMITED_API >= 0x030d0000) || (!defined(Py_LIMITED_API) && PY_VERSION_HEX >= 0x030d0000) + PyDict_GetItemStringRef(d, name, &cobj); +#else + cobj = PyDict_GetItemString(d, name); + Py_XINCREF(cobj); +#endif + if (!cobj) { + PyErr_Format(PyExc_ImportError, + "%.200s does not export expected C %.8s %.200s", + PyModule_GetName(module), what, name); + goto bad; + } + if (!PyCapsule_IsValid(cobj, sig)) { + PyErr_Format(PyExc_TypeError, + "C %.8s %.200s.%.200s has wrong signature (expected %.500s, got %.500s)", + what, PyModule_GetName(module), name, sig, PyCapsule_GetName(cobj)); + goto bad; + } + *p = PyCapsule_GetPointer(cobj, sig); + if (!(*p)) + goto bad; + Py_DECREF(d); + Py_DECREF(cobj); + return 0; +bad: + Py_XDECREF(d); + Py_XDECREF(cobj); + return -1; +} +#endif + +#ifndef __PYX_HAVE_RT_ImportFunction_3_2_4 +#define __PYX_HAVE_RT_ImportFunction_3_2_4 +static int __Pyx_ImportFunction_3_2_4(PyObject *module, const char *funcname, void (**f)(void), const char *sig) { + union { + void (*fp)(void); + void *p; + } tmp; + int result = __Pyx_ImportFromPxd_3_2_4(module, funcname, &tmp.p, sig, "function"); + if (result == 0) { + *f = tmp.fp; + } + return result; +} +#endif + + +static int import_lxml__etree(void) { + PyObject *module = 0; + module = PyImport_ImportModule("lxml.etree"); + if (!module) goto bad; + if (__Pyx_ImportFunction_3_2_4(module, "deepcopyNodeToDocument", (void (**)(void))&__pyx_api_f_4lxml_5etree_deepcopyNodeToDocument, "struct LxmlElement *(struct LxmlDocument *, xmlNode *)") < 0) goto bad; + if (__Pyx_ImportFunction_3_2_4(module, "elementTreeFactory", (void (**)(void))&__pyx_api_f_4lxml_5etree_elementTreeFactory, "struct LxmlElementTree *(struct LxmlElement *)") < 0) goto bad; + if (__Pyx_ImportFunction_3_2_4(module, "newElementTree", (void (**)(void))&__pyx_api_f_4lxml_5etree_newElementTree, "struct LxmlElementTree *(struct LxmlElement *, PyObject *)") < 0) goto bad; + if (__Pyx_ImportFunction_3_2_4(module, "adoptExternalDocument", (void (**)(void))&__pyx_api_f_4lxml_5etree_adoptExternalDocument, "struct LxmlElementTree *(xmlDoc *, PyObject *, int)") < 0) goto bad; + if (__Pyx_ImportFunction_3_2_4(module, "elementFactory", (void (**)(void))&__pyx_api_f_4lxml_5etree_elementFactory, "struct LxmlElement *(struct LxmlDocument *, xmlNode *)") < 0) goto bad; + if (__Pyx_ImportFunction_3_2_4(module, "makeElement", (void (**)(void))&__pyx_api_f_4lxml_5etree_makeElement, "struct LxmlElement *(PyObject *, struct LxmlDocument *, PyObject *, PyObject *, PyObject *, PyObject *, PyObject *)") < 0) goto bad; + if (__Pyx_ImportFunction_3_2_4(module, "makeSubElement", (void (**)(void))&__pyx_api_f_4lxml_5etree_makeSubElement, "struct LxmlElement *(struct LxmlElement *, PyObject *, PyObject *, PyObject *, PyObject *, PyObject *)") < 0) goto bad; + if (__Pyx_ImportFunction_3_2_4(module, "setElementClassLookupFunction", (void (**)(void))&__pyx_api_f_4lxml_5etree_setElementClassLookupFunction, "void (_element_class_lookup_function, PyObject *)") < 0) goto bad; + if (__Pyx_ImportFunction_3_2_4(module, "lookupDefaultElementClass", (void (**)(void))&__pyx_api_f_4lxml_5etree_lookupDefaultElementClass, "PyObject *(PyObject *, PyObject *, xmlNode *)") < 0) goto bad; + if (__Pyx_ImportFunction_3_2_4(module, "lookupNamespaceElementClass", (void (**)(void))&__pyx_api_f_4lxml_5etree_lookupNamespaceElementClass, "PyObject *(PyObject *, PyObject *, xmlNode *)") < 0) goto bad; + if (__Pyx_ImportFunction_3_2_4(module, "callLookupFallback", (void (**)(void))&__pyx_api_f_4lxml_5etree_callLookupFallback, "PyObject *(struct LxmlFallbackElementClassLookup *, struct LxmlDocument *, xmlNode *)") < 0) goto bad; + if (__Pyx_ImportFunction_3_2_4(module, "tagMatches", (void (**)(void))&__pyx_api_f_4lxml_5etree_tagMatches, "int (xmlNode *, const xmlChar *, const xmlChar *)") < 0) goto bad; + if (__Pyx_ImportFunction_3_2_4(module, "documentOrRaise", (void (**)(void))&__pyx_api_f_4lxml_5etree_documentOrRaise, "struct LxmlDocument *(PyObject *)") < 0) goto bad; + if (__Pyx_ImportFunction_3_2_4(module, "rootNodeOrRaise", (void (**)(void))&__pyx_api_f_4lxml_5etree_rootNodeOrRaise, "struct LxmlElement *(PyObject *)") < 0) goto bad; + if (__Pyx_ImportFunction_3_2_4(module, "hasText", (void (**)(void))&__pyx_api_f_4lxml_5etree_hasText, "int (xmlNode *)") < 0) goto bad; + if (__Pyx_ImportFunction_3_2_4(module, "hasTail", (void (**)(void))&__pyx_api_f_4lxml_5etree_hasTail, "int (xmlNode *)") < 0) goto bad; + if (__Pyx_ImportFunction_3_2_4(module, "textOf", (void (**)(void))&__pyx_api_f_4lxml_5etree_textOf, "PyObject *(xmlNode *)") < 0) goto bad; + if (__Pyx_ImportFunction_3_2_4(module, "tailOf", (void (**)(void))&__pyx_api_f_4lxml_5etree_tailOf, "PyObject *(xmlNode *)") < 0) goto bad; + if (__Pyx_ImportFunction_3_2_4(module, "setNodeText", (void (**)(void))&__pyx_api_f_4lxml_5etree_setNodeText, "int (xmlNode *, PyObject *)") < 0) goto bad; + if (__Pyx_ImportFunction_3_2_4(module, "setTailText", (void (**)(void))&__pyx_api_f_4lxml_5etree_setTailText, "int (xmlNode *, PyObject *)") < 0) goto bad; + if (__Pyx_ImportFunction_3_2_4(module, "attributeValue", (void (**)(void))&__pyx_api_f_4lxml_5etree_attributeValue, "PyObject *(xmlNode *, xmlAttr *)") < 0) goto bad; + if (__Pyx_ImportFunction_3_2_4(module, "attributeValueFromNsName", (void (**)(void))&__pyx_api_f_4lxml_5etree_attributeValueFromNsName, "PyObject *(xmlNode *, const xmlChar *, const xmlChar *)") < 0) goto bad; + if (__Pyx_ImportFunction_3_2_4(module, "getAttributeValue", (void (**)(void))&__pyx_api_f_4lxml_5etree_getAttributeValue, "PyObject *(struct LxmlElement *, PyObject *, PyObject *)") < 0) goto bad; + if (__Pyx_ImportFunction_3_2_4(module, "iterattributes", (void (**)(void))&__pyx_api_f_4lxml_5etree_iterattributes, "PyObject *(struct LxmlElement *, int)") < 0) goto bad; + if (__Pyx_ImportFunction_3_2_4(module, "collectAttributes", (void (**)(void))&__pyx_api_f_4lxml_5etree_collectAttributes, "PyObject *(xmlNode *, int)") < 0) goto bad; + if (__Pyx_ImportFunction_3_2_4(module, "setAttributeValue", (void (**)(void))&__pyx_api_f_4lxml_5etree_setAttributeValue, "int (struct LxmlElement *, PyObject *, PyObject *)") < 0) goto bad; + if (__Pyx_ImportFunction_3_2_4(module, "delAttribute", (void (**)(void))&__pyx_api_f_4lxml_5etree_delAttribute, "int (struct LxmlElement *, PyObject *)") < 0) goto bad; + if (__Pyx_ImportFunction_3_2_4(module, "delAttributeFromNsName", (void (**)(void))&__pyx_api_f_4lxml_5etree_delAttributeFromNsName, "int (xmlNode *, const xmlChar *, const xmlChar *)") < 0) goto bad; + if (__Pyx_ImportFunction_3_2_4(module, "hasChild", (void (**)(void))&__pyx_api_f_4lxml_5etree_hasChild, "int (xmlNode *)") < 0) goto bad; + if (__Pyx_ImportFunction_3_2_4(module, "findChild", (void (**)(void))&__pyx_api_f_4lxml_5etree_findChild, "xmlNode *(xmlNode *, Py_ssize_t)") < 0) goto bad; + if (__Pyx_ImportFunction_3_2_4(module, "findChildForwards", (void (**)(void))&__pyx_api_f_4lxml_5etree_findChildForwards, "xmlNode *(xmlNode *, Py_ssize_t)") < 0) goto bad; + if (__Pyx_ImportFunction_3_2_4(module, "findChildBackwards", (void (**)(void))&__pyx_api_f_4lxml_5etree_findChildBackwards, "xmlNode *(xmlNode *, Py_ssize_t)") < 0) goto bad; + if (__Pyx_ImportFunction_3_2_4(module, "nextElement", (void (**)(void))&__pyx_api_f_4lxml_5etree_nextElement, "xmlNode *(xmlNode *)") < 0) goto bad; + if (__Pyx_ImportFunction_3_2_4(module, "previousElement", (void (**)(void))&__pyx_api_f_4lxml_5etree_previousElement, "xmlNode *(xmlNode *)") < 0) goto bad; + if (__Pyx_ImportFunction_3_2_4(module, "appendChild", (void (**)(void))&__pyx_api_f_4lxml_5etree_appendChild, "void (struct LxmlElement *, struct LxmlElement *)") < 0) goto bad; + if (__Pyx_ImportFunction_3_2_4(module, "appendChildToElement", (void (**)(void))&__pyx_api_f_4lxml_5etree_appendChildToElement, "int (struct LxmlElement *, struct LxmlElement *)") < 0) goto bad; + if (__Pyx_ImportFunction_3_2_4(module, "pyunicode", (void (**)(void))&__pyx_api_f_4lxml_5etree_pyunicode, "PyObject *(const xmlChar *)") < 0) goto bad; + if (__Pyx_ImportFunction_3_2_4(module, "utf8", (void (**)(void))&__pyx_api_f_4lxml_5etree_utf8, "PyObject *(PyObject *)") < 0) goto bad; + if (__Pyx_ImportFunction_3_2_4(module, "getNsTag", (void (**)(void))&__pyx_api_f_4lxml_5etree_getNsTag, "PyObject *(PyObject *)") < 0) goto bad; + if (__Pyx_ImportFunction_3_2_4(module, "getNsTagWithEmptyNs", (void (**)(void))&__pyx_api_f_4lxml_5etree_getNsTagWithEmptyNs, "PyObject *(PyObject *)") < 0) goto bad; + if (__Pyx_ImportFunction_3_2_4(module, "namespacedName", (void (**)(void))&__pyx_api_f_4lxml_5etree_namespacedName, "PyObject *(xmlNode *)") < 0) goto bad; + if (__Pyx_ImportFunction_3_2_4(module, "namespacedNameFromNsName", (void (**)(void))&__pyx_api_f_4lxml_5etree_namespacedNameFromNsName, "PyObject *(const xmlChar *, const xmlChar *)") < 0) goto bad; + if (__Pyx_ImportFunction_3_2_4(module, "iteratorStoreNext", (void (**)(void))&__pyx_api_f_4lxml_5etree_iteratorStoreNext, "void (struct LxmlElementIterator *, struct LxmlElement *)") < 0) goto bad; + if (__Pyx_ImportFunction_3_2_4(module, "initTagMatch", (void (**)(void))&__pyx_api_f_4lxml_5etree_initTagMatch, "void (struct LxmlElementTagMatcher *, PyObject *)") < 0) goto bad; + if (__Pyx_ImportFunction_3_2_4(module, "findOrBuildNodeNsPrefix", (void (**)(void))&__pyx_api_f_4lxml_5etree_findOrBuildNodeNsPrefix, "xmlNs *(struct LxmlDocument *, xmlNode *, const xmlChar *, const xmlChar *)") < 0) goto bad; + Py_DECREF(module); module = 0; + return 0; + bad: + Py_XDECREF(module); + return -1; +} + +#endif /* !__PYX_HAVE_API__lxml__etree */ diff --git a/presentation/.venv/lib/python3.12/site-packages/lxml/extensions.pxi b/presentation/.venv/lib/python3.12/site-packages/lxml/extensions.pxi new file mode 100644 index 0000000..5945080 --- /dev/null +++ b/presentation/.venv/lib/python3.12/site-packages/lxml/extensions.pxi @@ -0,0 +1,838 @@ +# support for extension functions in XPath and XSLT + +cdef class XPathError(LxmlError): + """Base class of all XPath errors. + """ + +cdef class XPathEvalError(XPathError): + """Error during XPath evaluation. + """ + +cdef class XPathFunctionError(XPathEvalError): + """Internal error looking up an XPath extension function. + """ + +cdef class XPathResultError(XPathEvalError): + """Error handling an XPath result. + """ + + +# forward declarations + +ctypedef int (*_register_function)(void* ctxt, name_utf, ns_uri_utf) +cdef class _ExsltRegExp + +################################################################################ +# Base class for XSLT and XPath evaluation contexts: functions, namespaces, ... + +@cython.internal +cdef class _BaseContext: + cdef xpath.xmlXPathContext* _xpathCtxt + cdef _Document _doc + cdef dict _extensions + cdef list _namespaces + cdef list _global_namespaces + cdef dict _utf_refs + cdef dict _function_cache + cdef dict _eval_context_dict + cdef bint _build_smart_strings + # for exception handling and temporary reference keeping: + cdef _TempStore _temp_refs + cdef set _temp_documents + cdef _ExceptionContext _exc + cdef _ErrorLog _error_log + + def __init__(self, namespaces, extensions, error_log, enable_regexp, + build_smart_strings): + cdef _ExsltRegExp _regexp + cdef dict new_extensions + cdef list ns + self._utf_refs = {} + self._global_namespaces = [] + self._function_cache = {} + self._eval_context_dict = None + self._error_log = error_log + + if extensions is not None: + # convert extensions to UTF-8 + if isinstance(extensions, dict): + extensions = (extensions,) + # format: [ {(ns, name):function} ] -> {(ns_utf, name_utf):function} + new_extensions = {} + for extension in extensions: + for (ns_uri, name), function in extension.items(): + if name is None: + raise ValueError, "extensions must have non empty names" + ns_utf = self._to_utf(ns_uri) + name_utf = self._to_utf(name) + new_extensions[(ns_utf, name_utf)] = function + extensions = new_extensions or None + + if namespaces is not None: + if isinstance(namespaces, dict): + namespaces = namespaces.items() + if namespaces: + ns = [] + for prefix, ns_uri in namespaces: + if prefix is None or not prefix: + raise TypeError, \ + "empty namespace prefix is not supported in XPath" + if ns_uri is None or not ns_uri: + raise TypeError, \ + "setting default namespace is not supported in XPath" + prefix_utf = self._to_utf(prefix) + ns_uri_utf = self._to_utf(ns_uri) + ns.append( (prefix_utf, ns_uri_utf) ) + namespaces = ns + else: + namespaces = None + + self._doc = None + self._exc = _ExceptionContext() + self._extensions = extensions + self._namespaces = namespaces + self._temp_refs = _TempStore() + self._temp_documents = set() + self._build_smart_strings = build_smart_strings + + if enable_regexp: + _regexp = _ExsltRegExp() + _regexp._register_in_context(self) + + cdef _BaseContext _copy(self): + cdef _BaseContext context + if self._namespaces is not None: + namespaces = self._namespaces[:] + else: + namespaces = None + context = self.__class__(namespaces, None, self._error_log, False, + self._build_smart_strings) + if self._extensions is not None: + context._extensions = self._extensions.copy() + return context + + cdef bytes _to_utf(self, s): + "Convert to UTF-8 and keep a reference to the encoded string" + cdef python.PyObject* dict_result + if s is None: + return None + dict_result = python.PyDict_GetItem(self._utf_refs, s) + if dict_result is not NULL: + return dict_result + utf = _utf8(s) + self._utf_refs[s] = utf + if python.IS_PYPY: + # use C level refs, PyPy refs are not enough! + python.Py_INCREF(utf) + return utf + + cdef void _set_xpath_context(self, xpath.xmlXPathContext* xpathCtxt) noexcept: + self._xpathCtxt = xpathCtxt + xpathCtxt.userData = self + # Need a cast here because older libxml2 releases do not use 'const' in the functype. + xpathCtxt.error = _receiveXPathError + + @cython.final + cdef _register_context(self, _Document doc): + self._doc = doc + self._exc.clear() + + @cython.final + cdef _cleanup_context(self): + #xpath.xmlXPathRegisteredNsCleanup(self._xpathCtxt) + #self.unregisterGlobalNamespaces() + if python.IS_PYPY: + # clean up double refs in PyPy (see "_to_utf()" method) + for ref in self._utf_refs.itervalues(): + python.Py_DECREF(ref) + self._utf_refs.clear() + self._eval_context_dict = None + self._doc = None + + @cython.final + cdef _release_context(self): + if self._xpathCtxt is not NULL: + self._xpathCtxt.userData = NULL + self._xpathCtxt = NULL + + # namespaces (internal UTF-8 methods with leading '_') + + cdef addNamespace(self, prefix, ns_uri): + cdef list namespaces + if prefix is None: + raise TypeError, "empty prefix is not supported in XPath" + prefix_utf = self._to_utf(prefix) + ns_uri_utf = self._to_utf(ns_uri) + new_item = (prefix_utf, ns_uri_utf) + if self._namespaces is None: + self._namespaces = [new_item] + else: + namespaces = [] + for item in self._namespaces: + if item[0] == prefix_utf: + item = new_item + new_item = None + namespaces.append(item) + if new_item is not None: + namespaces.append(new_item) + self._namespaces = namespaces + if self._xpathCtxt is not NULL: + xpath.xmlXPathRegisterNs( + self._xpathCtxt, _xcstr(prefix_utf), _xcstr(ns_uri_utf)) + + cdef registerNamespace(self, prefix, ns_uri): + if prefix is None: + raise TypeError, "empty prefix is not supported in XPath" + prefix_utf = self._to_utf(prefix) + ns_uri_utf = self._to_utf(ns_uri) + self._global_namespaces.append(prefix_utf) + xpath.xmlXPathRegisterNs(self._xpathCtxt, + _xcstr(prefix_utf), _xcstr(ns_uri_utf)) + + cdef registerLocalNamespaces(self): + if self._namespaces is None: + return + for prefix_utf, ns_uri_utf in self._namespaces: + xpath.xmlXPathRegisterNs( + self._xpathCtxt, _xcstr(prefix_utf), _xcstr(ns_uri_utf)) + + cdef registerGlobalNamespaces(self): + cdef list ns_prefixes = _find_all_extension_prefixes() + if python.PyList_GET_SIZE(ns_prefixes) > 0: + for prefix_utf, ns_uri_utf in ns_prefixes: + self._global_namespaces.append(prefix_utf) + xpath.xmlXPathRegisterNs( + self._xpathCtxt, _xcstr(prefix_utf), _xcstr(ns_uri_utf)) + + cdef unregisterGlobalNamespaces(self): + if python.PyList_GET_SIZE(self._global_namespaces) > 0: + for prefix_utf in self._global_namespaces: + xpath.xmlXPathRegisterNs(self._xpathCtxt, + _xcstr(prefix_utf), NULL) + del self._global_namespaces[:] + + cdef void _unregisterNamespace(self, prefix_utf) noexcept: + xpath.xmlXPathRegisterNs(self._xpathCtxt, + _xcstr(prefix_utf), NULL) + + # extension functions + + cdef int _addLocalExtensionFunction(self, ns_utf, name_utf, function) except -1: + if self._extensions is None: + self._extensions = {} + self._extensions[(ns_utf, name_utf)] = function + return 0 + + cdef registerGlobalFunctions(self, void* ctxt, + _register_function reg_func): + cdef python.PyObject* dict_result + cdef dict d + for ns_utf, ns_functions in __FUNCTION_NAMESPACE_REGISTRIES.iteritems(): + dict_result = python.PyDict_GetItem( + self._function_cache, ns_utf) + if dict_result is not NULL: + d = dict_result + else: + d = {} + self._function_cache[ns_utf] = d + for name_utf, function in ns_functions.iteritems(): + d[name_utf] = function + reg_func(ctxt, name_utf, ns_utf) + + cdef registerLocalFunctions(self, void* ctxt, + _register_function reg_func): + cdef python.PyObject* dict_result + cdef dict d + if self._extensions is None: + return # done + last_ns = None + d = None + for (ns_utf, name_utf), function in self._extensions.iteritems(): + if ns_utf is not last_ns or d is None: + last_ns = ns_utf + dict_result = python.PyDict_GetItem( + self._function_cache, ns_utf) + if dict_result is not NULL: + d = dict_result + else: + d = {} + self._function_cache[ns_utf] = d + d[name_utf] = function + reg_func(ctxt, name_utf, ns_utf) + + cdef unregisterAllFunctions(self, void* ctxt, + _register_function unreg_func): + for ns_utf, functions in self._function_cache.iteritems(): + for name_utf in functions: + unreg_func(ctxt, name_utf, ns_utf) + + cdef unregisterGlobalFunctions(self, void* ctxt, + _register_function unreg_func): + for ns_utf, functions in self._function_cache.items(): + for name_utf in functions: + if self._extensions is None or \ + (ns_utf, name_utf) not in self._extensions: + unreg_func(ctxt, name_utf, ns_utf) + + @cython.final + cdef _find_cached_function(self, const_xmlChar* c_ns_uri, const_xmlChar* c_name): + """Lookup an extension function in the cache and return it. + + Parameters: c_ns_uri may be NULL, c_name must not be NULL + """ + cdef python.PyObject* c_dict + cdef python.PyObject* dict_result + c_dict = python.PyDict_GetItem( + self._function_cache, None if c_ns_uri is NULL else c_ns_uri) + if c_dict is not NULL: + dict_result = python.PyDict_GetItem( + c_dict, c_name) + if dict_result is not NULL: + return dict_result + return None + + # Python access to the XPath context for extension functions + + @property + def context_node(self): + cdef xmlNode* c_node + if self._xpathCtxt is NULL: + raise XPathError, \ + "XPath context is only usable during the evaluation" + c_node = self._xpathCtxt.node + if c_node is NULL: + raise XPathError, "no context node" + if c_node.doc != self._xpathCtxt.doc: + raise XPathError, \ + "document-external context nodes are not supported" + if self._doc is None: + raise XPathError, "document context is missing" + return _elementFactory(self._doc, c_node) + + @property + def eval_context(self): + if self._eval_context_dict is None: + self._eval_context_dict = {} + return self._eval_context_dict + + # Python reference keeping during XPath function evaluation + + @cython.final + cdef _release_temp_refs(self): + "Free temporarily referenced objects from this context." + self._temp_refs.clear() + self._temp_documents.clear() + + @cython.final + cdef _hold(self, obj): + """A way to temporarily hold references to nodes in the evaluator. + + This is needed because otherwise nodes created in XPath extension + functions would be reference counted too soon, during the XPath + evaluation. This is most important in the case of exceptions. + """ + cdef _Element element + if isinstance(obj, _Element): + self._temp_refs.add(obj) + self._temp_documents.add((<_Element>obj)._doc) + return + elif _isString(obj) or not python.PySequence_Check(obj): + return + for o in obj: + if isinstance(o, _Element): + #print "Holding element:", element._c_node + self._temp_refs.add(o) + #print "Holding document:", element._doc._c_doc + self._temp_documents.add((<_Element>o)._doc) + + @cython.final + cdef _Document _findDocumentForNode(self, xmlNode* c_node): + """If an XPath expression returns an element from a different + document than the current context document, we call this to + see if it was possibly created by an extension and is a known + document instance. + """ + cdef _Document doc + for doc in self._temp_documents: + if doc is not None and doc._c_doc is c_node.doc: + return doc + return None + + +# libxml2 keeps these error messages in a static array in its code +# and doesn't give us access to them ... + +cdef tuple LIBXML2_XPATH_ERROR_MESSAGES = ( + b"Ok", + b"Number encoding", + b"Unfinished literal", + b"Start of literal", + b"Expected $ for variable reference", + b"Undefined variable", + b"Invalid predicate", + b"Invalid expression", + b"Missing closing curly brace", + b"Unregistered function", + b"Invalid operand", + b"Invalid type", + b"Invalid number of arguments", + b"Invalid context size", + b"Invalid context position", + b"Memory allocation error", + b"Syntax error", + b"Resource error", + b"Sub resource error", + b"Undefined namespace prefix", + b"Encoding error", + b"Char out of XML range", + b"Invalid or incomplete context", + b"Stack usage error", + b"Forbidden variable\n", + b"?? Unknown error ??\n", +) + +cdef void _forwardXPathError(void* c_ctxt, const xmlerror.xmlError* c_error) noexcept with gil: + cdef xmlerror.xmlError error + cdef int xpath_code + if c_error.message is not NULL: + error.message = c_error.message + else: + xpath_code = c_error.code - xmlerror.XML_XPATH_EXPRESSION_OK + if 0 <= xpath_code < len(LIBXML2_XPATH_ERROR_MESSAGES): + error.message = _cstr(LIBXML2_XPATH_ERROR_MESSAGES[xpath_code]) + else: + error.message = b"unknown error" + error.domain = c_error.domain + error.code = c_error.code + error.level = c_error.level + error.line = c_error.line + error.int2 = c_error.int1 # column + error.file = c_error.file + error.node = NULL + + (<_BaseContext>c_ctxt)._error_log._receive(&error) + +cdef void _receiveXPathError(void* c_context, const xmlerror.xmlError* error) noexcept nogil: + if not __DEBUG: + return + if c_context is NULL: + _forwardError(NULL, error) + else: + _forwardXPathError(c_context, error) + + +def Extension(module, function_mapping=None, *, ns=None): + """Extension(module, function_mapping=None, ns=None) + + Build a dictionary of extension functions from the functions + defined in a module or the methods of an object. + + As second argument, you can pass an additional mapping of + attribute names to XPath function names, or a list of function + names that should be taken. + + The ``ns`` keyword argument accepts a namespace URI for the XPath + functions. + """ + cdef dict functions = {} + if isinstance(function_mapping, dict): + for function_name, xpath_name in function_mapping.items(): + functions[(ns, xpath_name)] = getattr(module, function_name) + else: + if function_mapping is None: + function_mapping = [ name for name in dir(module) + if not name.startswith('_') ] + for function_name in function_mapping: + functions[(ns, function_name)] = getattr(module, function_name) + return functions + +################################################################################ +# EXSLT regexp implementation + +@cython.final +@cython.internal +cdef class _ExsltRegExp: + cdef dict _compile_map + def __cinit__(self): + self._compile_map = {} + + cdef _make_string(self, value): + if _isString(value): + return value + elif isinstance(value, list): + # node set: take recursive text concatenation of first element + if python.PyList_GET_SIZE(value) == 0: + return '' + firstnode = value[0] + if _isString(firstnode): + return firstnode + elif isinstance(firstnode, _Element): + c_text = tree.xmlNodeGetContent((<_Element>firstnode)._c_node) + if c_text is NULL: + raise MemoryError() + try: + return funicode(c_text) + finally: + tree.xmlFree(c_text) + else: + return unicode(firstnode) + else: + return unicode(value) + + cdef _compile(self, rexp, ignore_case): + cdef python.PyObject* c_result + rexp = self._make_string(rexp) + key = (rexp, ignore_case) + c_result = python.PyDict_GetItem(self._compile_map, key) + if c_result is not NULL: + return c_result + py_flags = re.UNICODE + if ignore_case: + py_flags = py_flags | re.IGNORECASE + rexp_compiled = re.compile(rexp, py_flags) + self._compile_map[key] = rexp_compiled + return rexp_compiled + + def test(self, ctxt, s, rexp, flags=''): + flags = self._make_string(flags) + s = self._make_string(s) + rexpc = self._compile(rexp, 'i' in flags) + if rexpc.search(s) is None: + return False + else: + return True + + def match(self, ctxt, s, rexp, flags=''): + cdef list result_list + flags = self._make_string(flags) + s = self._make_string(s) + rexpc = self._compile(rexp, 'i' in flags) + if 'g' in flags: + results = rexpc.findall(s) + if not results: + return () + else: + result = rexpc.search(s) + if not result: + return () + results = [ result.group() ] + results.extend( result.groups('') ) + result_list = [] + root = Element('matches') + for s_match in results: + if python.PyTuple_CheckExact(s_match): + s_match = ''.join(s_match) + elem = SubElement(root, 'match') + elem.text = s_match + result_list.append(elem) + return result_list + + def replace(self, ctxt, s, rexp, flags, replacement): + replacement = self._make_string(replacement) + flags = self._make_string(flags) + s = self._make_string(s) + rexpc = self._compile(rexp, 'i' in flags) + count: object = 0 if 'g' in flags else 1 + return rexpc.sub(replacement, s, count) + + cdef _register_in_context(self, _BaseContext context): + ns = b"http://exslt.org/regular-expressions" + context._addLocalExtensionFunction(ns, b"test", self.test) + context._addLocalExtensionFunction(ns, b"match", self.match) + context._addLocalExtensionFunction(ns, b"replace", self.replace) + + +################################################################################ +# helper functions + +cdef xpath.xmlXPathObject* _wrapXPathObject(object obj, _Document doc, + _BaseContext context) except NULL: + cdef xpath.xmlNodeSet* resultSet + cdef _Element fake_node = None + cdef xmlNode* c_node + + if isinstance(obj, unicode): + obj = _utf8(obj) + if isinstance(obj, bytes): + # libxml2 copies the string value + return xpath.xmlXPathNewCString(_cstr(obj)) + if isinstance(obj, bool): + return xpath.xmlXPathNewBoolean(obj) + if python.PyNumber_Check(obj): + return xpath.xmlXPathNewFloat(obj) + if obj is None: + resultSet = xpath.xmlXPathNodeSetCreate(NULL) + elif isinstance(obj, _Element): + resultSet = xpath.xmlXPathNodeSetCreate((<_Element>obj)._c_node) + elif python.PySequence_Check(obj): + resultSet = xpath.xmlXPathNodeSetCreate(NULL) + try: + for value in obj: + if isinstance(value, _Element): + if context is not None: + context._hold(value) + xpath.xmlXPathNodeSetAdd(resultSet, (<_Element>value)._c_node) + else: + if context is None or doc is None: + raise XPathResultError, \ + f"Non-Element values not supported at this point - got {value!r}" + # support strings by appending text nodes to an Element + if isinstance(value, unicode): + value = _utf8(value) + if isinstance(value, bytes): + if fake_node is None: + fake_node = _makeElement("text-root", NULL, doc, None, + None, None, None, None, None) + context._hold(fake_node) + else: + # append a comment node to keep the text nodes separate + c_node = tree.xmlNewDocComment(doc._c_doc, "") + if c_node is NULL: + raise MemoryError() + tree.xmlAddChild(fake_node._c_node, c_node) + context._hold(value) + c_node = tree.xmlNewDocText(doc._c_doc, _xcstr(value)) + if c_node is NULL: + raise MemoryError() + tree.xmlAddChild(fake_node._c_node, c_node) + xpath.xmlXPathNodeSetAdd(resultSet, c_node) + else: + raise XPathResultError, \ + f"This is not a supported node-set result: {value!r}" + except: + xpath.xmlXPathFreeNodeSet(resultSet) + raise + else: + raise XPathResultError, f"Unknown return type: {python._fqtypename(obj).decode('utf8')}" + return xpath.xmlXPathWrapNodeSet(resultSet) + +cdef object _unwrapXPathObject(xpath.xmlXPathObject* xpathObj, + _Document doc, _BaseContext context): + if xpathObj.type == xpath.XPATH_UNDEFINED: + raise XPathResultError, "Undefined xpath result" + elif xpathObj.type == xpath.XPATH_NODESET: + return _createNodeSetResult(xpathObj, doc, context) + elif xpathObj.type == xpath.XPATH_BOOLEAN: + return xpathObj.boolval + elif xpathObj.type == xpath.XPATH_NUMBER: + return xpathObj.floatval + elif xpathObj.type == xpath.XPATH_STRING: + stringval = funicode(xpathObj.stringval) + if context._build_smart_strings: + stringval = _elementStringResultFactory( + stringval, None, None, False) + return stringval + elif xpathObj.type == xpath.XPATH_POINT: + raise NotImplementedError, "XPATH_POINT" + elif xpathObj.type == xpath.XPATH_RANGE: + raise NotImplementedError, "XPATH_RANGE" + elif xpathObj.type == xpath.XPATH_LOCATIONSET: + raise NotImplementedError, "XPATH_LOCATIONSET" + elif xpathObj.type == xpath.XPATH_USERS: + raise NotImplementedError, "XPATH_USERS" + elif xpathObj.type == xpath.XPATH_XSLT_TREE: + return _createNodeSetResult(xpathObj, doc, context) + else: + raise XPathResultError, f"Unknown xpath result {xpathObj.type}" + +cdef object _createNodeSetResult(xpath.xmlXPathObject* xpathObj, _Document doc, + _BaseContext context): + cdef xmlNode* c_node + cdef int i + cdef list result + result = [] + if xpathObj.nodesetval is NULL: + return result + for i in range(xpathObj.nodesetval.nodeNr): + c_node = xpathObj.nodesetval.nodeTab[i] + _unpackNodeSetEntry(result, c_node, doc, context, + xpathObj.type == xpath.XPATH_XSLT_TREE) + return result + +cdef _unpackNodeSetEntry(list results, xmlNode* c_node, _Document doc, + _BaseContext context, bint is_fragment): + cdef xmlNode* c_child + if _isElement(c_node): + if c_node.doc != doc._c_doc and c_node.doc._private is NULL: + # XXX: works, but maybe not always the right thing to do? + # XPath: only runs when extensions create or copy trees + # -> we store Python refs to these, so that is OK + # XSLT: can it leak when merging trees from multiple sources? + c_node = tree.xmlDocCopyNode(c_node, doc._c_doc, 1) + if not c_node: + raise MemoryError() + # FIXME: call _instantiateElementFromXPath() instead? + results.append( + _fakeDocElementFactory(doc, c_node)) + elif c_node.type == tree.XML_TEXT_NODE or \ + c_node.type == tree.XML_CDATA_SECTION_NODE or \ + c_node.type == tree.XML_ATTRIBUTE_NODE: + results.append( + _buildElementStringResult(doc, c_node, context)) + elif c_node.type == tree.XML_NAMESPACE_DECL: + results.append( (funicodeOrNone((c_node).prefix), + funicodeOrNone((c_node).href)) ) + elif c_node.type == tree.XML_DOCUMENT_NODE or \ + c_node.type == tree.XML_HTML_DOCUMENT_NODE: + # ignored for everything but result tree fragments + if is_fragment: + c_child = c_node.children + while c_child is not NULL: + _unpackNodeSetEntry(results, c_child, doc, context, 0) + c_child = c_child.next + elif c_node.type == tree.XML_XINCLUDE_START or \ + c_node.type == tree.XML_XINCLUDE_END: + pass + else: + raise NotImplementedError, \ + f"Not yet implemented result node type: {c_node.type}" + +cdef void _freeXPathObject(xpath.xmlXPathObject* xpathObj) noexcept: + """Free the XPath object, but *never* free the *content* of node sets. + Python dealloc will do that for us. + """ + if xpathObj.nodesetval is not NULL: + xpath.xmlXPathFreeNodeSet(xpathObj.nodesetval) + xpathObj.nodesetval = NULL + xpath.xmlXPathFreeObject(xpathObj) + +cdef _Element _instantiateElementFromXPath(xmlNode* c_node, _Document doc, + _BaseContext context): + # NOTE: this may copy the element - only call this when it can't leak + if c_node.doc != doc._c_doc and c_node.doc._private is NULL: + # not from the context document and not from a fake document + # either => may still be from a known document, e.g. one + # created by an extension function + node_doc = context._findDocumentForNode(c_node) + if node_doc is None: + # not from a known document at all! => can only make a + # safety copy here + c_node = tree.xmlDocCopyNode(c_node, doc._c_doc, 1) + if not c_node: + raise MemoryError() + else: + doc = node_doc + return _fakeDocElementFactory(doc, c_node) + +################################################################################ +# special str/unicode subclasses + +@cython.final +cdef class _ElementUnicodeResult(unicode): + cdef _Element _parent + cdef readonly object attrname + cdef readonly bint is_tail + + def getparent(self): + return self._parent + + @property + def is_text(self): + return self._parent is not None and not (self.is_tail or self.attrname is not None) + + @property + def is_attribute(self): + return self.attrname is not None + +cdef object _elementStringResultFactory(string_value, _Element parent, + attrname, bint is_tail): + result = _ElementUnicodeResult(string_value) + result._parent = parent + result.is_tail = is_tail + result.attrname = attrname + return result + +cdef object _buildElementStringResult(_Document doc, xmlNode* c_node, + _BaseContext context): + cdef _Element parent = None + cdef object attrname = None + cdef xmlNode* c_element + cdef bint is_tail + + if c_node.type == tree.XML_ATTRIBUTE_NODE: + attrname = _namespacedName(c_node) + is_tail = 0 + s = tree.xmlNodeGetContent(c_node) + if s is NULL: + raise MemoryError() + try: + value = funicode(s) + finally: + tree.xmlFree(s) + c_element = NULL + else: + #assert c_node.type == tree.XML_TEXT_NODE or c_node.type == tree.XML_CDATA_SECTION_NODE, "invalid node type" + # may be tail text or normal text + value = funicode(c_node.content) + c_element = _previousElement(c_node) + is_tail = c_element is not NULL + + if not context._build_smart_strings: + return value + + if c_element is NULL: + # non-tail text or attribute text + c_element = c_node.parent + while c_element is not NULL and not _isElement(c_element): + c_element = c_element.parent + + if c_element is not NULL: + parent = _instantiateElementFromXPath(c_element, doc, context) + + return _elementStringResultFactory( + value, parent, attrname, is_tail) + +################################################################################ +# callbacks for XPath/XSLT extension functions + +cdef void _extension_function_call(_BaseContext context, function, + xpath.xmlXPathParserContext* ctxt, int nargs) noexcept: + cdef _Document doc + cdef xpath.xmlXPathObject* obj + cdef list args + cdef int i + doc = context._doc + try: + args = [] + for i in range(nargs): + obj = xpath.valuePop(ctxt) + try: + o = _unwrapXPathObject(obj, doc, context) + finally: + _freeXPathObject(obj) + args.append(o) + args.reverse() + + res = function(context, *args) + # wrap result for XPath consumption + obj = _wrapXPathObject(res, doc, context) + # prevent Python from deallocating elements handed to libxml2 + context._hold(res) + xpath.valuePush(ctxt, obj) + except: + xpath.xmlXPathErr(ctxt, xpath.XPATH_EXPR_ERROR) + context._exc._store_raised() + finally: + return # swallow any further exceptions + +# lookup the function by name and call it + +cdef void _xpath_function_call(xpath.xmlXPathParserContext* ctxt, + int nargs) noexcept with gil: + cdef _BaseContext context + cdef xpath.xmlXPathContext* rctxt = ctxt.context + context = <_BaseContext> rctxt.userData + try: + function = context._find_cached_function(rctxt.functionURI, rctxt.function) + if function is not None: + _extension_function_call(context, function, ctxt, nargs) + else: + xpath.xmlXPathErr(ctxt, xpath.XPATH_UNKNOWN_FUNC_ERROR) + context._exc._store_exception(XPathFunctionError( + f"XPath function '{_namespacedNameFromNsName(rctxt.functionURI, rctxt.function)}' not found")) + except: + # may not be the right error, but we need to tell libxml2 *something* + xpath.xmlXPathErr(ctxt, xpath.XPATH_UNKNOWN_FUNC_ERROR) + context._exc._store_raised() + finally: + return # swallow any further exceptions diff --git a/presentation/.venv/lib/python3.12/site-packages/lxml/html/ElementSoup.py b/presentation/.venv/lib/python3.12/site-packages/lxml/html/ElementSoup.py new file mode 100644 index 0000000..c35365d --- /dev/null +++ b/presentation/.venv/lib/python3.12/site-packages/lxml/html/ElementSoup.py @@ -0,0 +1,10 @@ +__doc__ = """Legacy interface to the BeautifulSoup HTML parser. +""" + +__all__ = ["parse", "convert_tree"] + +from .soupparser import convert_tree, parse as _parse + +def parse(file, beautifulsoup=None, makeelement=None): + root = _parse(file, beautifulsoup=beautifulsoup, makeelement=makeelement) + return root.getroot() diff --git a/presentation/.venv/lib/python3.12/site-packages/lxml/html/__init__.py b/presentation/.venv/lib/python3.12/site-packages/lxml/html/__init__.py new file mode 100644 index 0000000..2cee9f4 --- /dev/null +++ b/presentation/.venv/lib/python3.12/site-packages/lxml/html/__init__.py @@ -0,0 +1,1927 @@ +# Copyright (c) 2004 Ian Bicking. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. +# +# 3. Neither the name of Ian Bicking nor the names of its contributors may +# be used to endorse or promote products derived from this software +# without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL IAN BICKING OR +# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +# PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +# LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +# NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +"""The ``lxml.html`` tool set for HTML handling. +""" + + +__all__ = [ + 'document_fromstring', 'fragment_fromstring', 'fragments_fromstring', 'fromstring', + 'tostring', 'Element', 'defs', 'open_in_browser', 'submit_form', + 'find_rel_links', 'find_class', 'make_links_absolute', + 'resolve_base_href', 'iterlinks', 'rewrite_links', 'parse'] + + +import copy +import re + +from collections.abc import MutableMapping, MutableSet +from functools import partial +from urllib.parse import urljoin + +from .. import etree +from . import defs +from ._setmixin import SetMixin + + +def __fix_docstring(s): + # TODO: remove and clean up doctests + if not s: + return s + sub = re.compile(r"^(\s*)u'", re.M).sub + return sub(r"\1'", s) + + +XHTML_NAMESPACE = "http://www.w3.org/1999/xhtml" + +_rel_links_xpath = etree.XPath("descendant-or-self::a[@rel]|descendant-or-self::x:a[@rel]", + namespaces={'x':XHTML_NAMESPACE}) +_options_xpath = etree.XPath("descendant-or-self::option|descendant-or-self::x:option", + namespaces={'x':XHTML_NAMESPACE}) +_forms_xpath = etree.XPath("descendant-or-self::form|descendant-or-self::x:form", + namespaces={'x':XHTML_NAMESPACE}) +#_class_xpath = etree.XPath(r"descendant-or-self::*[regexp:match(@class, concat('\b', $class_name, '\b'))]", {'regexp': 'http://exslt.org/regular-expressions'}) +_class_xpath = etree.XPath("descendant-or-self::*[@class and contains(concat(' ', normalize-space(@class), ' '), concat(' ', $class_name, ' '))]") +_id_xpath = etree.XPath("descendant-or-self::*[@id=$id]") +_collect_string_content = etree.XPath("string()", smart_strings=False) +_iter_css_urls = re.compile(r'url\(('+'["][^"]*["]|'+"['][^']*[']|"+r'[^)]*)\)', re.I).finditer +_iter_css_imports = re.compile(r'@import "(.*?)"').finditer +_label_xpath = etree.XPath("//label[@for=$id]|//x:label[@for=$id]", + namespaces={'x':XHTML_NAMESPACE}) +_archive_re = re.compile(r'[^ ]+') +_parse_meta_refresh_url = re.compile( + r'[^;=]*;\s*(?:url\s*=\s*)?(?P.*)$', re.I).search + + +def _unquote_match(s, pos): + if s[:1] == '"' and s[-1:] == '"' or s[:1] == "'" and s[-1:] == "'": + return s[1:-1], pos+1 + else: + return s,pos + + +def _transform_result(typ, result): + """Convert the result back into the input type. + """ + if issubclass(typ, bytes): + return tostring(result, encoding='utf-8') + elif issubclass(typ, str): + return tostring(result, encoding='unicode') + else: + return result + + +def _nons(tag): + if isinstance(tag, str): + if tag[0] == '{' and tag[1:len(XHTML_NAMESPACE)+1] == XHTML_NAMESPACE: + return tag.split('}')[-1] + return tag + + +class Classes(MutableSet): + """Provides access to an element's class attribute as a set-like collection. + Usage:: + + >>> el = fromstring('') + >>> classes = el.classes # or: classes = Classes(el.attrib) + >>> classes |= ['block', 'paragraph'] + >>> el.get('class') + 'hidden large block paragraph' + >>> classes.toggle('hidden') + False + >>> el.get('class') + 'large block paragraph' + >>> classes -= ('some', 'classes', 'block') + >>> el.get('class') + 'large paragraph' + """ + def __init__(self, attributes): + self._attributes = attributes + self._get_class_value = partial(attributes.get, 'class', '') + + def add(self, value): + """ + Add a class. + + This has no effect if the class is already present. + """ + if not value or re.search(r'\s', value): + raise ValueError("Invalid class name: %r" % value) + classes = self._get_class_value().split() + if value in classes: + return + classes.append(value) + self._attributes['class'] = ' '.join(classes) + + def discard(self, value): + """ + Remove a class if it is currently present. + + If the class is not present, do nothing. + """ + if not value or re.search(r'\s', value): + raise ValueError("Invalid class name: %r" % value) + classes = [name for name in self._get_class_value().split() + if name != value] + if classes: + self._attributes['class'] = ' '.join(classes) + elif 'class' in self._attributes: + del self._attributes['class'] + + def remove(self, value): + """ + Remove a class; it must currently be present. + + If the class is not present, raise a KeyError. + """ + if not value or re.search(r'\s', value): + raise ValueError("Invalid class name: %r" % value) + super().remove(value) + + def __contains__(self, name): + classes = self._get_class_value() + return name in classes and name in classes.split() + + def __iter__(self): + return iter(self._get_class_value().split()) + + def __len__(self): + return len(self._get_class_value().split()) + + # non-standard methods + + def update(self, values): + """ + Add all names from 'values'. + """ + classes = self._get_class_value().split() + extended = False + for value in values: + if value not in classes: + classes.append(value) + extended = True + if extended: + self._attributes['class'] = ' '.join(classes) + + def toggle(self, value): + """ + Add a class name if it isn't there yet, or remove it if it exists. + + Returns true if the class was added (and is now enabled) and + false if it was removed (and is now disabled). + """ + if not value or re.search(r'\s', value): + raise ValueError("Invalid class name: %r" % value) + classes = self._get_class_value().split() + try: + classes.remove(value) + enabled = False + except ValueError: + classes.append(value) + enabled = True + if classes: + self._attributes['class'] = ' '.join(classes) + else: + del self._attributes['class'] + return enabled + + +class HtmlMixin: + + def set(self, key, value=None): + """set(self, key, value=None) + + Sets an element attribute. If no value is provided, or if the value is None, + creates a 'boolean' attribute without value, e.g. "
" + for ``form.set('novalidate')``. + """ + super().set(key, value) + + @property + def classes(self): + """ + A set-like wrapper around the 'class' attribute. + """ + return Classes(self.attrib) + + @classes.setter + def classes(self, classes): + assert isinstance(classes, Classes) # only allow "el.classes |= ..." etc. + value = classes._get_class_value() + if value: + self.set('class', value) + elif self.get('class') is not None: + del self.attrib['class'] + + @property + def base_url(self): + """ + Returns the base URL, given when the page was parsed. + + Use with ``urlparse.urljoin(el.base_url, href)`` to get + absolute URLs. + """ + return self.getroottree().docinfo.URL + + @property + def forms(self): + """ + Return a list of all the forms + """ + return _forms_xpath(self) + + @property + def body(self): + """ + Return the element. Can be called from a child element + to get the document's head. + """ + for element in self.getroottree().iter("body", f"{{{XHTML_NAMESPACE}}}body"): + return element + return None + + @property + def head(self): + """ + Returns the element. Can be called from a child + element to get the document's head. + """ + for element in self.getroottree().iter("head", f"{{{XHTML_NAMESPACE}}}head"): + return element + return None + + @property + def label(self): + """ + Get or set any