├── .all-contributorsrc ├── .devcontainer ├── Dockerfile └── devcontainer.json ├── .github ├── dev-requirements.txt └── workflows │ ├── auth-tests.yaml │ ├── build-deploy.yaml │ ├── docs.yml │ ├── main.yaml │ ├── release.yaml │ └── update-contributors.yaml ├── .gitignore ├── .pre-commit-config.yaml ├── .tributors ├── CHANGELOG.md ├── CODEOWNERS ├── Dockerfile ├── LICENSE ├── MANIFEST.in ├── Makefile ├── OWNERS.md ├── README.md ├── SECURITY.md ├── docs ├── .gitignore ├── Makefile ├── _static │ ├── custom.css │ ├── images │ │ └── favicon.ico │ └── versions.json ├── _templates │ ├── autosummary │ │ ├── attribute.rst │ │ ├── class.rst │ │ ├── member.rst │ │ ├── method.rst │ │ └── minimal_module.rst │ ├── header.html │ ├── hero.html │ ├── layout.html │ └── repo.html ├── about │ └── license.md ├── apidoc.sh ├── conf.py ├── contributing.md ├── getting_started │ ├── developer-guide.md │ ├── index.md │ ├── installation.md │ └── user-guide.md ├── images │ ├── favicon.ico │ └── oras.png ├── index.md ├── make.bat ├── requirements.txt └── source │ ├── modules.rst │ ├── oras.main.rst │ ├── oras.rst │ ├── oras.tests.rst │ └── oras.utils.rst ├── examples ├── README.md ├── conda-mirror.py ├── follow-image-index.py └── simple │ ├── login.py │ ├── logout.py │ ├── pull.py │ └── push.py ├── mypy.ini ├── oras ├── __init__.py ├── auth │ ├── __init__.py │ ├── base.py │ ├── basic.py │ ├── token.py │ └── utils.py ├── client.py ├── container.py ├── decorator.py ├── defaults.py ├── logger.py ├── main │ ├── __init__.py │ └── login.py ├── oci.py ├── provider.py ├── schemas.py ├── tests │ ├── __init__.py │ ├── annotations.json │ ├── artifact.txt │ ├── conftest.py │ ├── run_registry.sh │ ├── snakeoil.crt │ ├── test_oci.py │ ├── test_oras.py │ ├── test_provider.py │ ├── test_utils.py │ └── upload_data │ │ └── artifact.txt ├── types.py ├── utils │ ├── __init__.py │ ├── fileio.py │ └── request.py └── version.py ├── pyproject.toml ├── scripts ├── lint.sh └── test.sh ├── setup.cfg └── setup.py /.all-contributorsrc: -------------------------------------------------------------------------------- 1 | { 2 | "projectName": "oras-py", 3 | "projectOwner": "oras-project", 4 | "repoType": "github", 5 | "repoHost": "https://github.com", 6 | "files": [ 7 | "README.md" 8 | ], 9 | "imageSize": 100, 10 | "commit": true, 11 | "commitConvention": "none", 12 | "contributors": [ 13 | { 14 | "login": "vsoch", 15 | "name": "Vanessasaurus", 16 | "contributions": [ 17 | "code" 18 | ], 19 | "profile": "https://vsoch.github.io", 20 | "avatar_url": [ 21 | "https://avatars.githubusercontent.com/u/814322?v=4" 22 | ] 23 | }, 24 | { 25 | "login": "lachie83", 26 | "name": "Lachlan Evenson", 27 | "contributions": [ 28 | "code" 29 | ], 30 | "profile": "youtube.com/lachlanevenson", 31 | "avatar_url": [ 32 | "https://avatars.githubusercontent.com/u/6912984?v=4" 33 | ] 34 | }, 35 | { 36 | "login": "SteveLasker", 37 | "name": "Steve Lasker", 38 | "contributions": [ 39 | "code" 40 | ], 41 | "profile": "http://SteveLasker.blog", 42 | "avatar_url": [ 43 | "https://avatars.githubusercontent.com/u/7647382?v=4" 44 | ] 45 | }, 46 | { 47 | "login": "jdolitsky", 48 | "name": "Josh Dolitsky", 49 | "contributions": [ 50 | "code" 51 | ], 52 | "profile": "https://dolit.ski", 53 | "avatar_url": [ 54 | "https://avatars.githubusercontent.com/u/393494?v=4" 55 | ] 56 | }, 57 | { 58 | "login": "bridgetkromhout", 59 | "name": "Bridget Kromhout", 60 | "contributions": [ 61 | "code" 62 | ], 63 | "profile": "https://github.com/bridgetkromhout", 64 | "avatar_url": [ 65 | "https://avatars.githubusercontent.com/u/2104453?v=4" 66 | ] 67 | }, 68 | { 69 | "login": "magelisk", 70 | "name": "Matt Warner", 71 | "contributions": [ 72 | "code" 73 | ], 74 | "profile": "https://github.com/magelisk", 75 | "avatar_url": [ 76 | "https://avatars.githubusercontent.com/u/18201513?v=4" 77 | ] 78 | }, 79 | { 80 | "login": "wolfv", 81 | "name": "Wolf Vollprecht", 82 | "contributions": [ 83 | "code" 84 | ], 85 | "profile": "wolfv.github.io", 86 | "avatar_url": [ 87 | "https://avatars.githubusercontent.com/u/885054?v=4" 88 | ] 89 | }, 90 | { 91 | "login": "shizhMSFT", 92 | "name": "Shiwei Zhang", 93 | "contributions": [ 94 | "code" 95 | ], 96 | "profile": "https://github.com/shizhMSFT", 97 | "avatar_url": [ 98 | "https://avatars.githubusercontent.com/u/32161882?v=4" 99 | ] 100 | }, 101 | { 102 | "login": "jhlmco", 103 | "name": "jhlmco", 104 | "contributions": [ 105 | "code" 106 | ], 107 | "profile": "https://github.com/jhlmco", 108 | "avatar_url": [ 109 | "https://avatars.githubusercontent.com/u/126677738?v=4" 110 | ] 111 | }, 112 | { 113 | "login": "Ananya2003Gupta", 114 | "name": "Ananya Gupta", 115 | "contributions": [ 116 | "code" 117 | ], 118 | "profile": "https://github.com/Ananya2003Gupta", 119 | "avatar_url": [ 120 | "https://avatars.githubusercontent.com/u/90386813?v=4" 121 | ] 122 | }, 123 | { 124 | "login": "sunnycarter", 125 | "name": "sunnycarter", 126 | "contributions": [ 127 | "code" 128 | ], 129 | "profile": "https://github.com/sunnycarter", 130 | "avatar_url": [ 131 | "https://avatars.githubusercontent.com/u/36891339?v=4" 132 | ] 133 | }, 134 | { 135 | "login": "mariusbertram", 136 | "name": "Marius Bertram", 137 | "contributions": [ 138 | "code" 139 | ], 140 | "profile": "https://github.com/mariusbertram", 141 | "avatar_url": [ 142 | "https://avatars.githubusercontent.com/u/10505884?v=4" 143 | ] 144 | }, 145 | { 146 | "login": "dev-zero", 147 | "name": "Tiziano Müller", 148 | "contributions": [ 149 | "code" 150 | ], 151 | "profile": "https://dev-zero.ch", 152 | "avatar_url": [ 153 | "https://avatars.githubusercontent.com/u/11307?v=4" 154 | ] 155 | }, 156 | { 157 | "login": "TerryHowe", 158 | "name": "Terry Howe", 159 | "contributions": [ 160 | "code" 161 | ], 162 | "profile": "https://terryhowe.wordpress.com/", 163 | "avatar_url": [ 164 | "https://avatars.githubusercontent.com/u/104113?v=4" 165 | ] 166 | }, 167 | { 168 | "login": "saketjajoo", 169 | "name": "Saket Jajoo", 170 | "contributions": [ 171 | "code" 172 | ], 173 | "profile": "https://saketjajoo.github.io", 174 | "avatar_url": [ 175 | "https://avatars.githubusercontent.com/u/23132557?v=4" 176 | ] 177 | }, 178 | { 179 | "login": "miker985", 180 | "name": "Mike", 181 | "contributions": [ 182 | "code" 183 | ], 184 | "profile": "https://github.com/miker985", 185 | "avatar_url": [ 186 | "https://avatars.githubusercontent.com/u/26555712?v=4" 187 | ] 188 | }, 189 | { 190 | "login": "linshokaku", 191 | "name": "deoxy", 192 | "contributions": [ 193 | "code" 194 | ], 195 | "profile": "https://github.com/linshokaku", 196 | "avatar_url": [ 197 | "https://avatars.githubusercontent.com/u/18627646?v=4" 198 | ] 199 | }, 200 | { 201 | "login": "kavish-p", 202 | "name": "Kavish Punchoo", 203 | "contributions": [ 204 | "code" 205 | ], 206 | "profile": "https://github.com/kavish-p", 207 | "avatar_url": [ 208 | "https://avatars.githubusercontent.com/u/29086148?v=4" 209 | ] 210 | }, 211 | { 212 | "login": "my5cents", 213 | "name": "my5cents", 214 | "contributions": [ 215 | "code" 216 | ], 217 | "profile": "https://github.com/my5cents", 218 | "avatar_url": [ 219 | "https://avatars.githubusercontent.com/u/4820203?v=4" 220 | ] 221 | }, 222 | { 223 | "login": "tumido", 224 | "name": "Tom Coufal", 225 | "contributions": [ 226 | "code" 227 | ], 228 | "profile": "https://github.com/tumido", 229 | "avatar_url": [ 230 | "https://avatars.githubusercontent.com/u/7453394?v=4" 231 | ] 232 | }, 233 | { 234 | "login": "tarilabs", 235 | "name": "Matteo Mortari", 236 | "contributions": [ 237 | "code" 238 | ], 239 | "profile": "https://youtube.com/@MatteoMortari", 240 | "avatar_url": [ 241 | "https://avatars.githubusercontent.com/u/1699252?v=4" 242 | ] 243 | }, 244 | { 245 | "login": "isinyaaa", 246 | "name": "Isabella Basso", 247 | "contributions": [ 248 | "code" 249 | ], 250 | "profile": "crosscat.me", 251 | "avatar_url": [ 252 | "https://avatars.githubusercontent.com/u/39812919?v=4" 253 | ] 254 | }, 255 | { 256 | "login": "xarses", 257 | "name": "Andrew Woodward", 258 | "contributions": [ 259 | "code" 260 | ], 261 | "profile": "https://github.com/xarses", 262 | "avatar_url": [ 263 | "https://avatars.githubusercontent.com/u/2107834?v=4" 264 | ] 265 | }, 266 | { 267 | "login": "ccronca", 268 | "name": "Camilo Cota", 269 | "contributions": [ 270 | "code" 271 | ], 272 | "profile": "https://github.com/ccronca", 273 | "avatar_url": [ 274 | "https://avatars.githubusercontent.com/u/1499184?v=4" 275 | ] 276 | }, 277 | { 278 | "login": "rhatdan", 279 | "name": "Daniel J Walsh", 280 | "contributions": [ 281 | "code" 282 | ], 283 | "profile": "http://danwalsh.livejournal.com", 284 | "avatar_url": [ 285 | "https://avatars.githubusercontent.com/u/2000835?v=4" 286 | ] 287 | }, 288 | { 289 | "login": "MichaelKopfMkf", 290 | "name": "MichaelKopfMkf", 291 | "contributions": [ 292 | "code" 293 | ], 294 | "profile": "https://github.com/MichaelKopfMkf", 295 | "avatar_url": [ 296 | "https://avatars.githubusercontent.com/u/189326443?v=4" 297 | ] 298 | }, 299 | { 300 | "login": "natefaerber", 301 | "name": "Nate Faerber", 302 | "contributions": [ 303 | "code" 304 | ], 305 | "profile": "https://github.com/natefaerber", 306 | "avatar_url": [ 307 | "https://avatars.githubusercontent.com/u/3720207?v=4" 308 | ] 309 | }, 310 | { 311 | "login": "Meallia", 312 | "name": "Jonathan Gayvallet", 313 | "contributions": [ 314 | "code" 315 | ], 316 | "profile": "https://github.com/Meallia", 317 | "avatar_url": [ 318 | "https://avatars.githubusercontent.com/u/7398724?v=4" 319 | ] 320 | }, 321 | { 322 | "login": "Sojamann", 323 | "name": "Sojamann", 324 | "contributions": [ 325 | "code" 326 | ], 327 | "profile": "https://github.com/Sojamann", 328 | "avatar_url": [ 329 | "https://avatars.githubusercontent.com/u/10118597?v=4" 330 | ] 331 | }, 332 | { 333 | "login": "joseacl", 334 | "name": "José Antonio Cortés López", 335 | "contributions": [ 336 | "code" 337 | ], 338 | "profile": "https://github.com/joseacl", 339 | "avatar_url": [ 340 | "https://avatars.githubusercontent.com/u/8399784?v=4" 341 | ] 342 | }, 343 | { 344 | "login": "diverger", 345 | "name": "diverger", 346 | "contributions": [ 347 | "code" 348 | ], 349 | "profile": "https://github.com/diverger", 350 | "avatar_url": [ 351 | "https://avatars.githubusercontent.com/u/335566?v=4" 352 | ] 353 | } 354 | ], 355 | "contributorsPerLine": 7 356 | } 357 | -------------------------------------------------------------------------------- /.devcontainer/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ghcr.io/oras-project/registry:latest 2 | 3 | LABEL maintainer="Vanessasaurus <@vsoch>" 4 | 5 | RUN apk update && apk add python3 py3-pip git make apache2-utils bash && \ 6 | pip install --upgrade pip setuptools 7 | 8 | ENV registry_host=localhost 9 | ENV registry_port=5000 10 | ENV with_auth=true 11 | ENV REGISTRY_AUTH: "{htpasswd: {realm: localhost, path: /etc/docker/registry/auth.htpasswd}}" 12 | ENV REGISTRY_STORAGE_DELETE_ENABLED="true" 13 | RUN htpasswd -cB -b auth.htpasswd myuser mypass && \ 14 | cp auth.htpasswd /etc/docker/registry/auth.htpasswd && \ 15 | registry serve /etc/docker/registry/config.yml & sleep 5 && \ 16 | echo $PWD && ls $PWD 17 | 18 | # Match the default user id for a single system so we aren't root 19 | ARG USERNAME=vscode 20 | ARG USER_UID=1000 21 | ARG USER_GID=1000 22 | ENV USERNAME=${USERNAME} 23 | ENV USER_UID=${USER_UID} 24 | ENV USER_GID=${USER_GID} 25 | ENV GO_VERSION=1.21.9 26 | 27 | 28 | # Add the group and user that match our ids 29 | RUN addgroup -S ${USERNAME} && adduser -S ${USERNAME} -G ${USERNAME} && \ 30 | echo "${USERNAME} ALL=(ALL) NOPASSWD: ALL" > /etc/sudoers 31 | 32 | USER $USERNAME 33 | # make install 34 | # make test 35 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Oras Python with Auth Development Environment", 3 | "dockerFile": "Dockerfile", 4 | "context": "../", 5 | 6 | "customizations": { 7 | "vscode": { 8 | "settings": { 9 | "terminal.integrated.defaultProfile.linux": "bash" 10 | }, 11 | "extensions": [ 12 | ] 13 | } 14 | }, 15 | "postStartCommand": "git config --global --add safe.directory /workspaces/oras-py" 16 | } 17 | -------------------------------------------------------------------------------- /.github/dev-requirements.txt: -------------------------------------------------------------------------------- 1 | pre-commit 2 | black==24.3.0 3 | isort 4 | flake8 5 | mypy==0.961 6 | types-requests 7 | -------------------------------------------------------------------------------- /.github/workflows/auth-tests.yaml: -------------------------------------------------------------------------------- 1 | name: Oras Auth Tests 2 | on: 3 | pull_request: [] 4 | 5 | jobs: 6 | test-auth: 7 | runs-on: ubuntu-latest 8 | container: ghcr.io/oras-project/registry:latest 9 | steps: 10 | - uses: actions/checkout@v3 11 | - name: Install Python 12 | run: | 13 | apk update && apk add python3 make apache2-utils bash 14 | wget https://bootstrap.pypa.io/get-pip.py 15 | python3 get-pip.py 16 | rm get-pip.py 17 | pip install --upgrade pip setuptools 18 | make install 19 | 20 | - name: Test Oras Python with Auth 21 | env: 22 | registry_host: localhost 23 | registry_port: 5000 24 | with_auth: true 25 | REGISTRY_AUTH: "{htpasswd: {realm: localhost, path: /etc/docker/registry/auth.htpasswd}}" 26 | REGISTRY_HTTP_TLS_CERTIFICATE: "/etc/docker/registry/server.cert" 27 | REGISTRY_HTTP_TLS_KEY: "/etc/docker/registry/server.key" 28 | REGISTRY_STORAGE_DELETE_ENABLED: "true" 29 | run: | 30 | htpasswd -cB -b auth.htpasswd myuser mypass 31 | cp auth.htpasswd /etc/docker/registry/auth.htpasswd 32 | apk add openssl 33 | openssl req -newkey rsa:4096 -nodes -sha256 -keyout /etc/docker/registry/server.key -x509 -days 365 -subj "/C=IT/ST=Lombardy/L=Milan/O=Acme Org/OU=IT Department/CN=example.com" -out /etc/docker/registry/server.cert 34 | registry serve /etc/docker/registry/config.yml & sleep 5 35 | echo $PWD && ls $PWD && make test 36 | -------------------------------------------------------------------------------- /.github/workflows/build-deploy.yaml: -------------------------------------------------------------------------------- 1 | name: Build and Deploy Container 2 | 3 | on: 4 | # Publish packages on release 5 | release: 6 | types: [published] 7 | 8 | pull_request: [] 9 | 10 | # On push to main we build and deploy images 11 | push: 12 | branches: 13 | - main 14 | 15 | jobs: 16 | build: 17 | permissions: 18 | packages: write 19 | 20 | runs-on: ubuntu-latest 21 | name: Build 22 | steps: 23 | - name: Checkout 24 | uses: actions/checkout@v3 25 | 26 | - name: Build Container 27 | run: docker build -t ghcr.io/oras-project/oras-py:latest . 28 | 29 | - name: GHCR Login 30 | if: (github.event_name != 'pull_request') 31 | uses: docker/login-action@v2 32 | with: 33 | registry: ghcr.io 34 | username: ${{ github.actor }} 35 | password: ${{ secrets.GITHUB_TOKEN }} 36 | 37 | - name: Tag and Push Release Image 38 | if: (github.event_name == 'release') 39 | run: | 40 | tag=${GITHUB_REF#refs/tags/} 41 | echo "Tagging and releasing ghcr.io/oras-project/oras-py:${tag}" 42 | docker tag ghcr.io/oras-project/oras-py:latest ghcr.io/oras-project/oras-py:${tag} 43 | docker push ghcr.io/oras-project/oras-py:${tag} 44 | 45 | - name: Deploy 46 | if: (github.event_name != 'pull_request') 47 | run: docker push ghcr.io/oras-project/oras-py:latest 48 | -------------------------------------------------------------------------------- /.github/workflows/docs.yml: -------------------------------------------------------------------------------- 1 | name: Update Docs 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | 7 | jobs: 8 | generate-docs: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | with: 13 | ref: gh-pages 14 | 15 | - name: Set up Python 16 | uses: actions/setup-python@v4 17 | with: 18 | python-version: 3.11 19 | 20 | - name: Install Oras and Dependencies 21 | run: | 22 | root=$PWD 23 | cd /tmp 24 | git clone https://github.com/oras-project/oras-py 25 | cd oras-py 26 | pip install -e . 27 | cd docs/ 28 | pip install -r requirements.txt 29 | make html 30 | cp -R $root/.git _build/html/.git 31 | rm -rf $root 32 | mv _build/html $root 33 | cd $root 34 | touch .nojekyll 35 | ls 36 | 37 | - name: Deploy 🚀 38 | uses: JamesIves/github-pages-deploy-action@v4 39 | with: 40 | branch: gh-pages 41 | folder: . 42 | -------------------------------------------------------------------------------- /.github/workflows/main.yaml: -------------------------------------------------------------------------------- 1 | name: Oras Python Tests 2 | on: 3 | pull_request: 4 | 5 | jobs: 6 | formatting: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v4 10 | - name: Check Spelling 11 | uses: crate-ci/typos@7ad296c72fa8265059cc03d1eda562fbdfcd6df2 # v1.9.0 12 | with: 13 | files: ./docs ./README.md 14 | 15 | - name: Set up Python 16 | uses: actions/setup-python@v4 17 | with: 18 | python-version: 3.11 19 | - name: Lint Oras Python 20 | run: | 21 | python --version 22 | python3 -m pip install pre-commit 23 | python3 -m pip install black 24 | make develop 25 | make lint 26 | 27 | test-oras-py: 28 | runs-on: ubuntu-latest 29 | services: 30 | registry: 31 | image: ghcr.io/oras-project/registry:latest 32 | ports: 33 | - 5000:5000 34 | steps: 35 | - uses: actions/checkout@v4 36 | - name: Set up Python 37 | uses: actions/setup-python@v4 38 | with: 39 | python-version: 3.11 40 | - name: Make space for large files 41 | run: | 42 | sudo rm -rf /usr/share/dotnet 43 | sudo rm -rf /usr/local/lib/android 44 | sudo rm -rf /opt/ghc 45 | sudo apt-get remove -y firefox || true 46 | sudo apt-get remove -y google-chrome-stable || true 47 | sudo apt purge openjdk-* || echo "OpenJDK is not installed" 48 | sudo apt remove --autoremove openjdk-* || echo "OpenJDK is not installed" 49 | sudo apt purge oracle-java* || echo "Oracle Java is not installed" 50 | sudo apt remove --autoremove adoptopenjdk-* || echo "Adopt open JDK is not installed" 51 | sudo apt-get remove -y ant || echo "ant is not installed" 52 | sudo rm -rf /opt/hostedtoolcache/Java_Adopt_jdk || true 53 | sudo apt-get remove -y podman || echo "Podman is not installed" 54 | sudo apt-get remove -y buildah || echo "Buidah is not installed" 55 | sudo apt-get remove -y esl-erlang || echo "erlang is not installed" 56 | sudo rm -rf /opt/google 57 | sudo rm -rf /usr/share/az* /opt/az || true 58 | sudo rm -rf /opt/microsoft 59 | sudo rm -rf /opt/hostedtoolcache/Ruby 60 | sudo apt-get remove -y swift || echo "swift is not installed" 61 | sudo apt-get remove -y swig || echo "swig is not installed" 62 | sudo apt-get remove -y texinfo || echo "texinfo is not installed" 63 | sudo apt-get remove -y texlive || echo "texlive is not installed" 64 | sudo apt-get remove -y r-base-core r-base || echo "R is not installed" 65 | sudo rm -rf /opt/R 66 | sudo rm -rf /usr/share/R 67 | sudo rm -rf /opt/*.zip 68 | sudo rm -rf /opt/*.tar.gz 69 | sudo rm -rf /usr/share/*.zip 70 | sudo rm -rf /usr/share/*.tar.gz 71 | sudo rm -rf /opt/hhvm 72 | sudo rm -rf /opt/hostedtoolcache/CodeQL 73 | sudo rm -rf /opt/hostedtoolcache/node 74 | sudo apt-get autoremove 75 | - name: Test Oras Python 76 | env: 77 | registry_host: localhost 78 | registry_port: ${{ job.services.registry.ports[5000] }} 79 | REGISTRY_STORAGE_DELETE_ENABLED: "true" 80 | run: | 81 | make install 82 | make test 83 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: Release Python Package 2 | 3 | on: 4 | workflow_dispatch: 5 | release: 6 | types: [created] 7 | 8 | jobs: 9 | deploy: 10 | runs-on: ubuntu-22.04 11 | 12 | steps: 13 | - uses: actions/checkout@v4 14 | - name: Set up Python 15 | uses: actions/setup-python@v4 16 | with: 17 | python-version: 3.11 18 | 19 | - name: Install dependencies 20 | run: | 21 | pip install -e . 22 | pip install setuptools wheel twine 23 | - name: Build and publish 24 | env: 25 | TWINE_USERNAME: ${{ secrets.PYPI_USER }} 26 | TWINE_PASSWORD: ${{ secrets.PYPI_PASS }} 27 | run: | 28 | python setup.py sdist bdist_wheel 29 | twine upload dist/* 30 | -------------------------------------------------------------------------------- /.github/workflows/update-contributors.yaml: -------------------------------------------------------------------------------- 1 | name: Update Contributors 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | Update: 10 | name: Generate 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout Repository 14 | uses: actions/checkout@v4 15 | - name: Tributors Update 16 | uses: con/tributors@master 17 | env: 18 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 19 | with: 20 | parsers: unset 21 | update_lookup: github 22 | log_level: DEBUG 23 | force: true 24 | threshold: 1 25 | 26 | - name: Checkout New Branch 27 | env: 28 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 29 | BRANCH_AGAINST: main 30 | run: | 31 | printf "GitHub Actor: ${GITHUB_ACTOR}\n" 32 | export BRANCH_FROM="contributors/update-$(date '+%Y-%m-%d')" 33 | git remote set-url origin "https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" 34 | 35 | BRANCH_EXISTS=$(git ls-remote --heads origin ${BRANCH_FROM}) 36 | if [[ -z ${BRANCH_EXISTS} ]]; then 37 | printf "Branch does not exist in remote.\n" 38 | else 39 | printf "Branch already exists in remote.\n" 40 | exit 1 41 | fi 42 | git branch 43 | git checkout -b "${BRANCH_FROM}" || git checkout "${BRANCH_FROM}" 44 | git fetch --unshallow origin 45 | git branch 46 | 47 | git config --global user.name "github-actions" 48 | git config --global user.email "github-actions@users.noreply.github.com" 49 | git status 50 | 51 | if git diff-index --quiet HEAD --; then 52 | export OPEN_PULL_REQUEST=0 53 | printf "No changes\n" 54 | else 55 | export OPEN_PULL_REQUEST=1 56 | printf "Changes\n" 57 | today=$(date '+%Y-%m-%d') 58 | git add .tributors 59 | git add .all-contributorsrc 60 | git add README.md 61 | git commit -a -m "Automated deployment to update contributors ${today}" -m "Signed-off-by: github-actions " 62 | git push origin "${BRANCH_FROM}" 63 | fi 64 | 65 | echo "OPEN_PULL_REQUEST=${OPEN_PULL_REQUEST}" >> $GITHUB_ENV 66 | echo "PULL_REQUEST_FROM_BRANCH=${BRANCH_FROM}" >> $GITHUB_ENV 67 | echo "PULL_REQUEST_TITLE=[tributors] ${BRANCH_FROM}" >> $GITHUB_ENV 68 | echo "PULL_REQUEST_BODY=Tributors update automated pull request." >> $GITHUB_ENV 69 | 70 | - name: Open Pull Request 71 | uses: vsoch/pull-request-action@master 72 | if: ${{ env.OPEN_PULL_REQUEST == '1' }} 73 | env: 74 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 75 | PULL_REQUEST_BRANCH: main 76 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .eggs 2 | auth.htpasswd 3 | artifact.txt 4 | oras.egg-info/ 5 | .env 6 | env 7 | __pycache__ 8 | .python-version 9 | .venv 10 | .vscode 11 | build 12 | dist 13 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | exclude: ".all-contributorsrc|.tributors" 2 | repos: 3 | - repo: https://github.com/pre-commit/pre-commit-hooks 4 | rev: v4.4.0 5 | hooks: 6 | - id: check-added-large-files 7 | - id: check-case-conflict 8 | - id: check-docstring-first 9 | - id: end-of-file-fixer 10 | - id: trailing-whitespace 11 | - id: mixed-line-ending 12 | - repo: https://github.com/pycqa/isort 13 | rev: 5.12.0 14 | hooks: 15 | - id: isort 16 | - repo: https://github.com/psf/black 17 | rev: 24.3.0 18 | hooks: 19 | - id: black 20 | language_version: python3.11 21 | - repo: https://github.com/pycqa/flake8 22 | rev: 6.1.0 23 | hooks: 24 | - id: flake8 25 | -------------------------------------------------------------------------------- /.tributors: -------------------------------------------------------------------------------- 1 | { 2 | "SteveLasker": { 3 | "name": "Steve Lasker", 4 | "bio": "OCI TOB Member, PM Architect for Azure Container Registry, balancing tech and the great outdoors", 5 | "blog": "http://SteveLasker.blog", 6 | "orcid": "0000-0003-2999-4873" 7 | }, 8 | "vsoch": { 9 | "name": "Vanessasaurus", 10 | "bio": "I'm the Vanessasaurus!", 11 | "blog": "https://vsoch.github.io" 12 | }, 13 | "jdolitsky": { 14 | "name": "Josh Dolitsky", 15 | "blog": "https://dolit.ski" 16 | }, 17 | "bridgetkromhout": { 18 | "name": "Bridget Kromhout", 19 | "bio": "bridgetkromhout.com", 20 | "blog": "https://github.com/bridgetkromhout" 21 | }, 22 | "lachie83": { 23 | "name": "Lachlan Evenson", 24 | "bio": "Cloud builder", 25 | "blog": "youtube.com/lachlanevenson" 26 | }, 27 | "magelisk": { 28 | "name": "Matt Warner", 29 | "bio": "Matt Warner", 30 | "blog": "https://github.com/magelisk", 31 | "orcid": "0009-0003-7184-3099", 32 | "affiliation": "British Antarctic Survey Medical Unit" 33 | }, 34 | "wolfv": { 35 | "name": "Wolf Vollprecht", 36 | "email": "w.vollprecht@gmail.com", 37 | "bio": "prefix.dev |\r\nPackage Management, Robotics and Jupyter", 38 | "blog": "wolfv.github.io", 39 | "orcid": "0009-0005-5928-2713" 40 | }, 41 | "shizhMSFT": { 42 | "name": "Shiwei Zhang", 43 | "email": "shizh@microsoft.com", 44 | "bio": "Happy Hacking", 45 | "blog": "https://github.com/shizhMSFT" 46 | }, 47 | "jhlmco": { 48 | "name": "jhlmco", 49 | "blog": "https://github.com/jhlmco" 50 | }, 51 | "Ananya2003Gupta": { 52 | "name": "Ananya Gupta", 53 | "bio": " A tech geek who is trying and testing different programming languages, frameworks as well as technologies and playing around with them to create something new.", 54 | "blog": "https://github.com/Ananya2003Gupta" 55 | }, 56 | "sunnycarter": { 57 | "name": "sunnycarter", 58 | "blog": "https://github.com/sunnycarter" 59 | }, 60 | "mariusbertram": { 61 | "name": "Marius Bertram", 62 | "email": "marius@brtrm.de", 63 | "blog": "https://github.com/mariusbertram", 64 | "orcid": "0009-0006-8522-2287", 65 | "affiliation": "University of Copenhagen" 66 | }, 67 | "dev-zero": { 68 | "name": "Tiziano Müller", 69 | "blog": "https://dev-zero.ch", 70 | "orcid": "0000-0002-1387-5717" 71 | }, 72 | "TerryHowe": { 73 | "name": "Terry Howe", 74 | "blog": "https://terryhowe.wordpress.com/" 75 | }, 76 | "saketjajoo": { 77 | "name": "Saket Jajoo", 78 | "email": "saketjajoo77@gmail.com", 79 | "blog": "https://saketjajoo.github.io" 80 | }, 81 | "miker985": { 82 | "name": "Mike", 83 | "blog": "https://github.com/miker985" 84 | }, 85 | "linshokaku": { 86 | "name": "deoxy", 87 | "blog": "https://github.com/linshokaku" 88 | }, 89 | "kavish-p": { 90 | "name": "Kavish Punchoo", 91 | "email": "kavish.punchoo@gmail.com", 92 | "bio": "Problem Solving Enthusiast", 93 | "blog": "https://github.com/kavish-p" 94 | }, 95 | "my5cents": { 96 | "name": "my5cents", 97 | "blog": "https://github.com/my5cents" 98 | }, 99 | "tumido": { 100 | "name": "Tom Coufal", 101 | "bio": "Pushing buttons at @redhat-et", 102 | "blog": "https://github.com/tumido" 103 | }, 104 | "tarilabs": { 105 | "name": "Matteo Mortari", 106 | "blog": "https://youtube.com/@MatteoMortari" 107 | }, 108 | "isinyaaa": { 109 | "name": "Isabella Basso", 110 | "email": "idoamara@redhat.com", 111 | "bio": "wibbly-wobbly mathsy-computesey stuff", 112 | "blog": "crosscat.me", 113 | "orcid": "0000-0001-5549-1220" 114 | }, 115 | "xarses": { 116 | "name": "Andrew Woodward", 117 | "blog": "https://github.com/xarses" 118 | }, 119 | "ccronca": { 120 | "name": "Camilo Cota", 121 | "blog": "https://github.com/ccronca" 122 | }, 123 | "rhatdan": { 124 | "name": "Daniel J Walsh", 125 | "email": "dwalsh@redhat.com", 126 | "bio": "Mr SELinux, Red Hat Senior Distinguished Engineer. Works on @containers projects: ai-lab-recipes, Podman, Skopeo, Buildah, image, storage...", 127 | "blog": "http://danwalsh.livejournal.com", 128 | "orcid": "0000-0002-7479-5016", 129 | "affiliation": "Limerick Institute of Technology" 130 | }, 131 | "MichaelKopfMkf": { 132 | "name": "MichaelKopfMkf", 133 | "blog": "https://github.com/MichaelKopfMkf" 134 | }, 135 | "natefaerber": { 136 | "name": "Nate Faerber", 137 | "email": "natefaerber@gmail.com", 138 | "blog": "https://github.com/natefaerber" 139 | }, 140 | "Meallia": { 141 | "name": "Jonathan Gayvallet", 142 | "blog": "https://github.com/Meallia" 143 | }, 144 | "Sojamann": { 145 | "name": "Sojamann", 146 | "blog": "https://github.com/Sojamann" 147 | }, 148 | "joseacl": { 149 | "name": "José Antonio Cortés López", 150 | "email": "joseacl@gmail.com", 151 | "blog": "https://github.com/joseacl" 152 | }, 153 | "diverger": { 154 | "name": "diverger", 155 | "blog": "https://github.com/diverger" 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # CHANGELOG 2 | 3 | This is a manually generated log to track changes to the repository for each release. 4 | Each section should include general headers such as **Implemented enhancements** 5 | and **Merged pull requests**. Critical items to know are: 6 | 7 | - renamed commands 8 | - deprecated / removed commands 9 | - changed defaults 10 | - backward incompatible changes (recipe file format? image file format?) 11 | - migration guidance (how to convert images?) 12 | - changed behaviour (recipe sections work differently) 13 | 14 | The versions coincide with releases on pip. Only major versions will be released as tags on Github. 15 | 16 | ## [0.0.x](https://github.com/oras-project/oras-py/tree/main) (0.0.x) 17 | - fix 'get_manifest()' method with adding 'load_configs()' calling (0.2.33) 18 | - fix 'Provider' method signature to allow custom CA-Bundles (0.2.32) 19 | - initialize headers variable in do_request (0.2.31) 20 | - Make reproducible targz without mimetype (0.2.30) 21 | - don't include Content-Length header in upload_manifest (0.2.29) 22 | - propagate the tls_verify parameter to auth backends (0.2.28) 23 | - don't add an Authorization header is there is no token (0.2.27), closes issue [182](https://github.com/oras-project/oras-py/issues/182) 24 | - check for blob existence before uploading (0.2.26) 25 | - fix get_tags for ECR when limit is None, closes issue [173](https://github.com/oras-project/oras-py/issues/173) 26 | - fix empty token for anon tokens to work, closes issue [167](https://github.com/oras-project/oras-py/issues/167) 27 | - retry on 500 (0.2.25) 28 | - align provider config_path type annotations (0.2.24) 29 | - add missing prefix property to auth backend (0.2.23) 30 | - allow for filepaths to include `:` (0.2.22) 31 | - release request (0.2.21) 32 | - add missing basic auth data for request token function in token auth backend (0.2.2) 33 | - re-enable chunked upload (0.2.1) 34 | - refactor of auth to be provided by backend modules (0.2.0) 35 | - bugfix maintain requests's verify valorization for all invocations, augment basic auth header to existing headers 36 | - Allow generating a Subject from a pre-existing Manifest (0.1.30) 37 | - add option to not refresh headers during the pushing flow, useful for push with basic auth (0.1.29) 38 | - enable additionalProperties in schema validation (0.1.28) 39 | - Introduce the option to not refresh headers when fetching manifests when pulling artifacts (0.1.27) 40 | - To make it available for more OCI registries, the value of config used when `manifest_config` is not specified in `client.push()` has been changed from a pure empty string to `{}` (0.1.26) 41 | - refactor tests using fixtures and rework pre-commit configuration (0.1.25) 42 | - eliminate the additional subdirectory creation while pulling an image to a custom output directory (0.1.24) 43 | - updating the exclude string in the pyproject.toml file to match the [data type black expects](https://black.readthedocs.io/en/stable/usage_and_configuration/the_basics.html#configuration-format) 44 | - patch fix for pulling artifacts by digest (0.1.23) 45 | - patch fix to reject cookies as this could trigger registries into handling the lib as a web client 46 | - patch fix for proper validation and specification of the subject element 47 | - add tls_verify to provider class for optional disable tls verification (0.1.22) 48 | - Allow to pull exactly to PWD (0.1.21) 49 | - Ensure insecure is passed to provider class (0.1.20) 50 | - patch fix for blob upload Windows, closes issue [93](https://github.com/oras-project/oras-py/issues/93) (0.1.19) 51 | - patch fix for empty manifest config on Windows, closes issue [90](https://github.com/oras-project/oras-py/issues/90) (0.1.18) 52 | - patch fix to correct session url pattern, closes issue [78](https://github.com/oras-project/oras-py/issues/78) (0.1.17) 53 | - add support for tag deletion and retry decorators (0.1.16) 54 | - bugfix that pagination sets upper limit of 10K (0.1.15) 55 | - pagination for tags (and general function for pagination) (0.1.14) 56 | - expose upload_blob function to be consistent (0.1.13) 57 | - ensure we always strip path separators before pull/push (0.1.12) 58 | - exposing download_blob to the user since it uses streaming (0.1.11) 59 | - adding developer examples for pull. 60 | - start deprecation for _download_blob, _put_upload, _chunked_upload, _upload_manifest 61 | in favor of equivalent public functions. 62 | - moving of docs to fully be here with extended examples (0.1.1) 63 | - addition of oras.utils.workdir to provide local context 64 | - clients are removed from Python SDK in favor of examples (0.1.0) 65 | - login refactored to be part of the basic client 66 | - ecr and others do not require a formatted namespace (0.0.19) 67 | - relaxing manifest requirements - ECR has extra field "subject" 68 | - relaxing manifest requirements - ECR has annotations with None 69 | - cutting out early for asking for token if Authorization header set. 70 | - logger should only exit in command line client, not in API (0.0.18) 71 | - raising exceptions allows the calling using to catch the error 72 | - support for requesting anonymous token from registry 73 | - Added expected header for authentication to gitlab registry (0.0.17) 74 | - safe extraction for targz extractions (0.0.16) 75 | - disable chunked upload for now (not supported by all registries) (0.0.15) 76 | - support for adding one-off annotations for a manifest (0.0.14) 77 | - add debug if location header returned is empty (0.0.13) 78 | - docker is an optional dependency, to minimize dependencies (0.0.12) 79 | - Removing runtime dependency pytest-runner 80 | - bug fixes for GitHub packages 81 | - Adding authenticated login tests, fixing bugs with login/logout (0.0.11) 82 | - First draft release with basic functionality (0.0.1) 83 | - Initial skeleton of project (0.0.0) 84 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | # Derived from OWNERS.md 2 | * @vsoch @stevelasker 3 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ubuntu:22.04 2 | 3 | # docker build -t oras-py . 4 | 5 | LABEL MAINTAINER @vsoch 6 | ENV PATH /opt/conda/bin:${PATH} 7 | ENV LANG C.UTF-8 8 | RUN apt-get update && \ 9 | apt-get install -y wget && \ 10 | wget https://repo.continuum.io/miniconda/Miniconda3-latest-Linux-x86_64.sh && \ 11 | bash Miniconda3-latest-Linux-x86_64.sh -b -p /opt/conda && \ 12 | rm Miniconda3-latest-Linux-x86_64.sh 13 | 14 | RUN pip install ipython 15 | WORKDIR /code 16 | COPY . /code 17 | RUN pip install -e .[all] 18 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2021 ORAS Authors. 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md LICENSE 2 | recursive-include oras *.png 3 | recursive-include oras *.sh 4 | recursive-include oras * 5 | recursive-include * *.png 6 | prune env* 7 | global-exclude .env 8 | global-exclude *.py[co] 9 | recursive-exclude .git * 10 | global-exclude __pycache__ 11 | prune docs* 12 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: test 2 | test: 3 | /bin/bash scripts/test.sh 4 | 5 | .PHONY: install 6 | install: 7 | pip install -e .[all] 8 | 9 | .PHONY: lint 10 | lint: 11 | pre-commit run --all-files 12 | 13 | .PHONY: testreqs 14 | testreqs: 15 | pip install -e .[tests] 16 | 17 | .PHONY: develop 18 | develop: 19 | pip install -e .[all] 20 | pip install -r .github/dev-requirements.txt 21 | -------------------------------------------------------------------------------- /OWNERS.md: -------------------------------------------------------------------------------- 1 | # Owners 2 | 3 | Owners: 4 | - Vanessasaurus (@vsoch) 5 | - Steve Lasker (@stevelasker) 6 | 7 | Emeritus: 8 | - Josh Dolitsky (@jdolitsky) 9 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ORAS Python 2 | 3 | 4 | [![All Contributors](https://img.shields.io/badge/all_contributors-31-orange.svg?style=flat-square)](#contributors-) 5 | 6 | 7 | ![ORAS Logo](https://raw.githubusercontent.com/oras-project/oras-www/main/static/img/oras.png) 8 | 9 | OCI Registry as Storage enables libraries to push OCI Artifacts to [OCI Conformant](https://github.com/opencontainers/oci-conformance) registries. This is a Python SDK for Python developers to empower them to do this in their applications. 10 | 11 | See our ⭐️ [Documentation](https://oras-project.github.io/oras-py/) ⭐️ to get started. 12 | 13 | 14 | ## Code of Conduct 15 | 16 | Please note that this project has adopted the [CNCF Code of Conduct](https://github.com/cncf/foundation/blob/master/code-of-conduct.md). 17 | Please follow it in all your interactions with the project members and users. 18 | 19 | ## Contributing 20 | 21 | To contribute to oras python, if you want to have discussion about a change, feature, or fix, you can open an issue first. We then ask that you open a pull request against the main branch. In the description please include the details of your change, e.g., why it is needed, what you did, and any further points for discussion. In addition: 22 | 23 | - For changes to the code: 24 | - Please bump the version in the `oras/version.py` file 25 | - Please also make a corresponding note in the `CHANGELOG.md` 26 | 27 | For any changes to functionality or code that are not tested, please add one or more tests. Thank you for your contributions! 28 | 29 | ## 😁️ Contributors 😁️ 30 | 31 | We use the [all-contributors](https://github.com/all-contributors/all-contributors) 32 | tool to generate a contributors graphic below. 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 |
Vanessasaurus
Vanessasaurus

💻
Lachlan Evenson
Lachlan Evenson

💻
Steve Lasker
Steve Lasker

💻
Josh Dolitsky
Josh Dolitsky

💻
Bridget Kromhout
Bridget Kromhout

💻
Matt Warner
Matt Warner

💻
Wolf Vollprecht
Wolf Vollprecht

💻
Shiwei Zhang
Shiwei Zhang

💻
jhlmco
jhlmco

💻
Ananya Gupta
Ananya Gupta

💻
sunnycarter
sunnycarter

💻
Marius Bertram
Marius Bertram

💻
Tiziano Müller
Tiziano Müller

💻
Terry Howe
Terry Howe

💻
Saket Jajoo
Saket Jajoo

💻
Mike
Mike

💻
deoxy
deoxy

💻
Kavish Punchoo
Kavish Punchoo

💻
my5cents
my5cents

💻
Tom Coufal
Tom Coufal

💻
Matteo Mortari
Matteo Mortari

💻
Isabella Basso
Isabella Basso

💻
Andrew Woodward
Andrew Woodward

💻
Camilo Cota
Camilo Cota

💻
Daniel J Walsh
Daniel J Walsh

💻
MichaelKopfMkf
MichaelKopfMkf

💻
Nate Faerber
Nate Faerber

💻
Jonathan Gayvallet
Jonathan Gayvallet

💻
Sojamann
Sojamann

💻
José Antonio Cortés López
José Antonio Cortés López

💻
diverger
diverger

💻
82 | 83 | 84 | 85 | 86 | 87 | 88 | ## License 89 | 90 | This code is licensed under the Apache 2.0 [LICENSE](LICENSE). 91 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | Please follow the [security policy](https://oras.land/docs/community/reporting_security_concerns) to report a security vulnerability or concern. 4 | -------------------------------------------------------------------------------- /docs/.gitignore: -------------------------------------------------------------------------------- 1 | env 2 | _build 3 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= -j auto 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" -j auto $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /docs/_static/custom.css: -------------------------------------------------------------------------------- 1 | .o-tooltip--left:after{ 2 | font-size: 0.8rem; 3 | } 4 | 5 | .md-hero__inner { 6 | padding: 0px !important; 7 | } 8 | 9 | /* Fix ugly mustard buttons! */ 10 | div.sphx-glr-download a { 11 | background-color: #FFF; 12 | background-image: linear-gradient(to bottom, #FFF, #FFF); 13 | border: 1px solid #cccc; 14 | } 15 | 16 | 17 | div.sphx-glr-download a:hover { 18 | background-color: gold !important; 19 | } 20 | -------------------------------------------------------------------------------- /docs/_static/images/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oras-project/oras-py/b63ede0287865d5884812312f7e90ca44724296f/docs/_static/images/favicon.ico -------------------------------------------------------------------------------- /docs/_static/versions.json: -------------------------------------------------------------------------------- 1 | {"release": "", "development": "devel"} 2 | -------------------------------------------------------------------------------- /docs/_templates/autosummary/attribute.rst: -------------------------------------------------------------------------------- 1 | :orphan: 2 | 3 | {{ fullname | escape | underline}} 4 | 5 | .. currentmodule:: {{ module }} 6 | 7 | attribute 8 | 9 | .. auto{{ objtype }}:: {{ objname }} 10 | -------------------------------------------------------------------------------- /docs/_templates/autosummary/class.rst: -------------------------------------------------------------------------------- 1 | {{ fullname | escape | underline}} 2 | 3 | .. currentmodule:: {{ module }} 4 | 5 | .. autoclass:: {{ objname }} 6 | :exclude-members: 7 | 8 | {% block methods %} 9 | {% if methods %} 10 | .. rubric:: Methods 11 | 12 | .. autosummary:: 13 | :toctree: generated/ 14 | 15 | {% for item in methods %} 16 | {%- if not item.startswith('_') or item in ['__call__'] %} ~{{ name }}.{{ item }} 17 | {% endif %} 18 | {%- endfor %} 19 | {% endif %} 20 | {% endblock %} 21 | {% block attributes %} 22 | {% if attributes %} 23 | .. rubric:: Properties 24 | 25 | .. autosummary:: 26 | :toctree: generated/ 27 | 28 | {% for item in attributes %} 29 | {%- if not item.startswith('_') or item in ['__call__'] %} ~{{ name }}.{{ item }} 30 | {% endif %} 31 | {%- endfor %} 32 | {% endif %} 33 | {% endblock %} 34 | -------------------------------------------------------------------------------- /docs/_templates/autosummary/member.rst: -------------------------------------------------------------------------------- 1 | :orphan: 2 | 3 | {{ fullname | escape | underline}} 4 | 5 | .. currentmodule:: {{ module }} 6 | 7 | member 8 | 9 | .. auto{{ objtype }}:: {{ objname }} 10 | -------------------------------------------------------------------------------- /docs/_templates/autosummary/method.rst: -------------------------------------------------------------------------------- 1 | :orphan: 2 | 3 | {{ fullname | escape | underline}} 4 | 5 | .. currentmodule:: {{ module }} 6 | 7 | method 8 | 9 | .. auto{{ objtype }}:: {{ objname }} 10 | -------------------------------------------------------------------------------- /docs/_templates/autosummary/minimal_module.rst: -------------------------------------------------------------------------------- 1 | {{ fullname | escape | underline}} 2 | 3 | .. automodule:: {{ fullname }} 4 | 5 | {% block docstring %} 6 | {% endblock %} 7 | -------------------------------------------------------------------------------- /docs/_templates/header.html: -------------------------------------------------------------------------------- 1 |
2 | 40 |
41 | -------------------------------------------------------------------------------- /docs/_templates/hero.html: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oras-project/oras-py/b63ede0287865d5884812312f7e90ca44724296f/docs/_templates/hero.html -------------------------------------------------------------------------------- /docs/_templates/layout.html: -------------------------------------------------------------------------------- 1 | {# Import the theme's layout. #} 2 | {% extends '!layout.html' %} 3 | 4 | {%- block extrahead %} 5 | {# Extra custom things to the head HTML tag #} 6 | 7 | 8 | Oras Python 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 33 | {# Call the parent block #} 34 | {{ super() }} 35 | {%- endblock %} 36 | -------------------------------------------------------------------------------- /docs/_templates/repo.html: -------------------------------------------------------------------------------- 1 | 2 | {% if theme_repo_type %} 3 |
4 | 5 | 8 | 9 | 10 |
11 | {% endif %} 12 |
13 | {{ theme_repo_name }} 14 |
15 |
16 | -------------------------------------------------------------------------------- /docs/about/license.md: -------------------------------------------------------------------------------- 1 | # Apache License 2 | 3 | \_Version 2.0, January [2004]() 4 | \_<\<\>>\_ 5 | 6 | \### Terms and Conditions for use, reproduction, and distribution 7 | 8 | \#### 1. Definitions 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, and 11 | distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by the 14 | copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all other 17 | entities that control, are controlled by, or are under common control 18 | with that entity. For the purposes of this definition, "control" means 19 | **(i)** the power, direct or indirect, to cause the direction or 20 | management of such entity, whether by contract or otherwise, or **(ii)** 21 | ownership of fifty percent (50%) or more of the outstanding shares, or 22 | **(iii)** beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity exercising 25 | permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation source, 29 | and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but not 33 | limited to compiled object code, generated documentation, and 34 | conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or Object 37 | form, made available under the License, as indicated by a copyright 38 | notice that is included in or attached to the work (an example is 39 | provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including the original 50 | version of the Work and any modifications or additions to that Work or 51 | Derivative Works thereof, that is intentionally submitted to Licensor 52 | for inclusion in the Work by the copyright owner or by an individual or 53 | Legal Entity authorized to submit on behalf of the copyright owner. For 54 | the purposes of this definition, "submitted" means any form of 55 | electronic, verbal, or written communication sent to the Licensor or its 56 | representatives, including but not limited to communication on 57 | electronic mailing lists, source code control systems, and issue 58 | tracking systems that are managed by, or on behalf of, the Licensor for 59 | the purpose of discussing and improving the Work, but excluding 60 | communication that is conspicuously marked or otherwise designated in 61 | writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity on 64 | behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | \#### 2. Grant of Copyright License 68 | 69 | Subject to the terms and conditions of this License, each Contributor 70 | hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, 71 | royalty-free, irrevocable copyright license to reproduce, prepare 72 | Derivative Works of, publicly display, publicly perform, sublicense, and 73 | distribute the Work and such Derivative Works in Source or Object form. 74 | 75 | \#### 3. Grant of Patent License 76 | 77 | Subject to the terms and conditions of this License, each Contributor 78 | hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, 79 | royalty-free, irrevocable (except as stated in this section) patent 80 | license to make, have made, use, offer to sell, sell, import, and 81 | otherwise transfer the Work, where such license applies only to those 82 | patent claims licensable by such Contributor that are necessarily 83 | infringed by their Contribution(s) alone or by combination of their 84 | Contribution(s) with the Work to which such Contribution(s) was 85 | submitted. If You institute patent litigation against any entity 86 | (including a cross-claim or counterclaim in a lawsuit) alleging that the 87 | Work or a Contribution incorporated within the Work constitutes direct 88 | or contributory patent infringement, then any patent licenses granted to 89 | You under this License for that Work shall terminate as of the date such 90 | litigation is filed. 91 | 92 | \#### 4. Redistribution 93 | 94 | You may reproduce and distribute copies of the Work or Derivative Works 95 | thereof in any medium, with or without modifications, and in Source or 96 | Object form, provided that You meet the following conditions: 97 | 98 | \* **(a)** You must give any other recipients of the Work or Derivative 99 | Works a copy of this License; and \* **(b)** You must cause any modified 100 | files to carry prominent notices stating that You changed the files; and 101 | \* **(c)** You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and attribution 103 | notices from the Source form of the Work, excluding those notices that 104 | do not pertain to any part of the Derivative Works; and \* **(d)** If 105 | the Work includes a "NOTICE" text file as part of its distribution, then 106 | any Derivative Works that You distribute must include a readable copy of 107 | the attribution notices contained within such NOTICE file, excluding 108 | those notices that do not pertain to any part of the Derivative Works, 109 | in at least one of the following places: within a NOTICE text file 110 | distributed as part of the Derivative Works; within the Source form or 111 | documentation, if provided along with the Derivative Works; or, within a 112 | display generated by the Derivative Works, if and wherever such 113 | third-party notices normally appear. The contents of the NOTICE file are 114 | for informational purposes only and do not modify the License. You may 115 | add Your own attribution notices within Derivative Works that You 116 | distribute, alongside or as an addendum to the NOTICE text from the 117 | Work, provided that such additional attribution notices cannot be 118 | construed as modifying the License. 119 | 120 | You may add Your own copyright statement to Your modifications and may 121 | provide additional or different license terms and conditions for use, 122 | reproduction, or distribution of Your modifications, or for any such 123 | Derivative Works as a whole, provided Your use, reproduction, and 124 | distribution of the Work otherwise complies with the conditions stated 125 | in this License. 126 | 127 | \#### 5. Submission of Contributions 128 | 129 | Unless You explicitly state otherwise, any Contribution intentionally 130 | submitted for inclusion in the Work by You to the Licensor shall be 131 | under the terms and conditions of this License, without any additional 132 | terms or conditions. Notwithstanding the above, nothing herein shall 133 | supersede or modify the terms of any separate license agreement you may 134 | have executed with Licensor regarding such Contributions. 135 | 136 | \#### 6. Trademarks 137 | 138 | This License does not grant permission to use the trade names, 139 | trademarks, service marks, or product names of the Licensor, except as 140 | required for reasonable and customary use in describing the origin of 141 | the Work and reproducing the content of the NOTICE file. 142 | 143 | \#### 7. Disclaimer of Warranty 144 | 145 | Unless required by applicable law or agreed to in writing, Licensor 146 | provides the Work (and each Contributor provides its Contributions) on 147 | an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either 148 | express or implied, including, without limitation, any warranties or 149 | conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any risks 152 | associated with Your exercise of permissions under this License. 153 | 154 | \#### 8. Limitation of Liability 155 | 156 | In no event and under no legal theory, whether in tort (including 157 | negligence), contract, or otherwise, unless required by applicable law 158 | (such as deliberate and grossly negligent acts) or agreed to in writing, 159 | shall any Contributor be liable to You for damages, including any 160 | direct, indirect, special, incidental, or consequential damages of any 161 | character arising as a result of this License or out of the use or 162 | inability to use the Work (including but not limited to damages for loss 163 | of goodwill, work stoppage, computer failure or malfunction, or any and 164 | all other commercial damages or losses), even if such Contributor has 165 | been advised of the possibility of such damages. 166 | 167 | \#### 9. Accepting Warranty or Additional Liability 168 | 169 | While redistributing the Work or Derivative Works thereof, You may 170 | choose to offer, and charge a fee for, acceptance of support, warranty, 171 | indemnity, or other liability obligations and/or rights consistent with 172 | this License. However, in accepting such obligations, You may act only 173 | on Your own behalf and on Your sole responsibility, not on behalf of any 174 | other Contributor, and only if You agree to indemnify, defend, and hold 175 | each Contributor harmless for any liability incurred by, or claims 176 | asserted against, such Contributor by reason of your accepting any such 177 | warranty or additional liability. 178 | 179 | \_END OF TERMS AND [CONDITIONS]() 180 | 181 | \### APPENDIX: How to apply the Apache License to your work 182 | 183 | To apply the Apache License to your work, attach the following 184 | boilerplate notice, with the fields enclosed by brackets 185 | [\[\]]{.title-ref} replaced with your own identifying information. 186 | (Don\'t include the brackets!) The text should be enclosed in the 187 | appropriate comment syntax for the file format. We also recommend that a 188 | file or class name and description of purpose be included on the same 189 | "printed page" as the copyright notice for easier identification within 190 | third-party archives. 191 | 192 | > Copyright \[yyyy\] \[name of copyright owner\] 193 | > 194 | > Licensed under the Apache License, Version 2.0 (the \"License\"); you 195 | > may not use this file except in compliance with the License. You may 196 | > obtain a copy of the License at 197 | > 198 | > > 199 | > 200 | > Unless required by applicable law or agreed to in writing, software 201 | > distributed under the License is distributed on an \"AS IS\" BASIS, 202 | > WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 203 | > implied. See the License for the specific language governing 204 | > permissions and limitations under the License. 205 | -------------------------------------------------------------------------------- /docs/apidoc.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # If the modules changed, the content of "source" should be backed up and 3 | # new files generated (to update) by doing: 4 | # 5 | # 6 | rm source/oras*.rst 7 | sphinx-apidoc -o source/ ../oras 8 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # This file only contains a selection of the most common options. For a full 4 | # list see the documentation: 5 | # http://www.sphinx-doc.org/en/master/config 6 | 7 | # -- Path setup -------------------------------------------------------------- 8 | 9 | import os 10 | 11 | # If extensions (or modules to document with autodoc) are in another directory, 12 | # add these directories to sys.path here. If the directory is relative to the 13 | # documentation root, use os.path.abspath to make it absolute, like shown here. 14 | # 15 | # import os 16 | # import sys 17 | # sys.path.insert(0, os.path.abspath('.')) 18 | from distutils.version import LooseVersion 19 | 20 | import sphinx_material 21 | from recommonmark.transform import AutoStructify 22 | 23 | FORCE_CLASSIC = os.environ.get("SPHINX_MATERIAL_FORCE_CLASSIC", False) 24 | FORCE_CLASSIC = FORCE_CLASSIC in ("1", "true") 25 | 26 | # -- Project information ----------------------------------------------------- 27 | 28 | project = "Oras Python" 29 | html_title = "Oras Python" 30 | 31 | copyright = "2023, Oras Python Developers" 32 | author = "@vsoch" 33 | 34 | # The full version, including alpha/beta/rc tags 35 | release = LooseVersion(sphinx_material.__version__).vstring 36 | 37 | # -- General configuration --------------------------------------------------- 38 | 39 | # Add any Sphinx extension module names here, as strings. They can be 40 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 41 | # ones. 42 | extensions = [ 43 | "myst_parser", 44 | "sphinx.ext.autosummary", 45 | "sphinx.ext.autodoc", 46 | "sphinx.ext.doctest", 47 | "sphinx.ext.extlinks", 48 | "sphinx.ext.intersphinx", 49 | "sphinx.ext.todo", 50 | "sphinx.ext.mathjax", 51 | "sphinx.ext.viewcode", 52 | "nbsphinx", 53 | "sphinx_markdown_tables", 54 | "sphinx_copybutton", 55 | "sphinx_search.extension", 56 | ] 57 | 58 | 59 | autosummary_generate = True 60 | autoclass_content = "class" 61 | 62 | # Add any paths that contain templates here, relative to this directory. 63 | templates_path = ["_templates"] 64 | 65 | # List of patterns, relative to source directory, that match files and 66 | # directories to ignore when looking for source files. 67 | # This pattern also affects html_static_path and html_extra_path. 68 | exclude_patterns = [ 69 | "_build", 70 | "Thumbs.db", 71 | ".DS_Store", 72 | "env", 73 | "README.md", 74 | ".github", 75 | ".circleci", 76 | ] 77 | 78 | # -- Options for HTML output ------------------------------------------------- 79 | 80 | # The theme to use for HTML and HTML Help pages. See the documentation for 81 | # a list of builtin themes. 82 | # 83 | # Add any paths that contain custom static files (such as style sheets) here, 84 | # relative to this directory. They are copied after the builtin static files, 85 | # so a file named 'default.css' will overwrite the builtin 'default.css'. 86 | html_static_path = ["_static"] 87 | 88 | # -- HTML theme settings ------------------------------------------------ 89 | 90 | html_show_sourcelink = True 91 | html_sidebars = { 92 | "**": ["logo-text.html", "globaltoc.html", "localtoc.html", "searchbox.html"] 93 | } 94 | 95 | # Allows us to add to the default template 96 | templates_path = ["_templates"] 97 | 98 | extensions.append("sphinx_material") 99 | html_theme_path = sphinx_material.html_theme_path() 100 | html_context = sphinx_material.get_html_context() 101 | html_theme = "sphinx_material" 102 | html_css_files = ["custom.css"] 103 | 104 | # Custom sphinx material variables 105 | theme_logo_icon = "images/oras.png" 106 | 107 | 108 | html_theme_options = { 109 | "base_url": "http://oras-project.github.io/oras-py/", 110 | "repo_url": "https://github.com/oras-project/oras-py/", 111 | "repo_name": "Oras Python", 112 | "html_minify": False, 113 | "html_prettify": True, 114 | "css_minify": False, 115 | # icons at https://gist.github.com/albionselimaj/14fabdb89d7893c116ee4b48fdfdc7ae 116 | # https://fonts.google.com/icons 117 | "logo_icon": "", 118 | "repo_type": "github", 119 | "globaltoc_depth": 2, 120 | # red, pink, purple, deep-purple, indigo, blue, light-blue, cyan, teal, green, light-green, lime, yellow, amber, orange, deep-orange, brown, grey, blue-grey, and white. 121 | "color_primary": "indigo", 122 | # red, pink, purple, deep-purple, indigo, blue, light-blue, cyan, teal, green, light-green, lime, yellow, amber, orange, and deep-orange. 123 | "color_accent": "cyan", 124 | "touch_icon": "images/oras.png", 125 | "theme_color": "#4051b5", 126 | "master_doc": False, 127 | "nav_links": [ 128 | { 129 | "href": "https://oras.land", 130 | "internal": False, 131 | "title": "oras.land", 132 | }, 133 | { 134 | "href": "https://github.com/oras-project", 135 | "internal": False, 136 | "title": "Oras on GitHub", 137 | }, 138 | { 139 | "href": "https://github.com/oras-project/oras-py", 140 | "internal": False, 141 | "title": "Oras Python on GitHub", 142 | }, 143 | ], 144 | "heroes": { 145 | "index": "Oras Python", 146 | "customization": "Oras Python", 147 | }, 148 | # Include the version dropdown top right? (e.g., if we use readthedocs) 149 | "version_dropdown": False, 150 | # Format of this is dict with [label,path] 151 | # Since we are rendering on gh-pages without readthedocs, we don't 152 | # have versions 153 | # "version_json": "_static/versions.json", 154 | # "version_info": { 155 | # "Release": "https://online-ml.github.io/viz/", 156 | # "Development": "https://online-ml.github.io/viz/devel/", 157 | # "Release (rel)": "/viz/", 158 | # "Development (rel)": "/viz/devel/", 159 | # }, 160 | # Do NOT strip these classes from tables! 161 | "table_classes": ["plain"], 162 | } 163 | 164 | if FORCE_CLASSIC: 165 | print("!!!!!!!!! Forcing classic !!!!!!!!!!!") 166 | html_theme = "classic" 167 | html_theme_options = {} 168 | html_sidebars = {"**": ["globaltoc.html", "localtoc.html", "searchbox.html"]} 169 | 170 | language = "en" 171 | html_last_updated_fmt = "" 172 | 173 | todo_include_todos = True 174 | html_favicon = "images/favicon.ico" 175 | 176 | html_use_index = True 177 | html_domain_indices = True 178 | 179 | nbsphinx_execute = "always" 180 | nbsphinx_kernel_name = "python3" 181 | 182 | extlinks = { 183 | "duref": ( 184 | "http://docutils.sourceforge.net/docs/ref/rst/" "restructuredtext.html#%s", 185 | "", 186 | ), 187 | "durole": ("http://docutils.sourceforge.net/docs/ref/rst/" "roles.html#%s", ""), 188 | "dudir": ("http://docutils.sourceforge.net/docs/ref/rst/" "directives.html#%s", ""), 189 | } 190 | 191 | 192 | # Enable eval_rst in markdown 193 | def setup(app): 194 | app.add_config_value( 195 | "recommonmark_config", 196 | {"enable_math": True, "enable_inline_math": True, "enable_eval_rst": True}, 197 | True, 198 | ) 199 | app.add_transform(AutoStructify) 200 | app.add_object_type( 201 | "confval", 202 | "confval", 203 | objname="configuration value", 204 | indextemplate="pair: %s; configuration value", 205 | ) 206 | -------------------------------------------------------------------------------- /docs/contributing.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | When contributing to Oras Python, it is important to properly communicate the gist of the contribution. 4 | If it is a simple code or editorial fix, simply explaining this within the GitHub Pull Request (PR) will suffice. But if this is a larger 5 | fix or Enhancement, it should be first discussed with the project leader or developers. 6 | You can look at the [OWNERS](https://github.com/oras-project/oras-py/blob/main/OWNERS.md) file of the repository to see who might be best to ping, 7 | or jump into the [#oras](https://cloud-native.slack.com/archives/CJ1KHJM5Z) channel in the 8 | CNCF slack. 9 | 10 | Please note that this project has adopted the [CNCF Code of Conduct](https://github.com/cncf/foundation/blob/master/code-of-conduct.md). 11 | Please follow it in all your interactions with the project members and users. 12 | 13 | ## Pull Request Process 14 | 15 | 1. All pull requests should go to the main branch. 16 | 2. Follow the existing code style precedent. The testing includes linting that will help, but generally we use black, isort, mypy, and pyflakes. 17 | 3. Test your PR locally, and provide the steps necessary to test for the reviewers. 18 | 4. The project's default copyright and header have been included in any new source files. 19 | 5. All (major) changes must be documented in the CHANGELOG.md in the root of the repository, and documentation updated here. 20 | 6. If necessary, update the README.md. 21 | 7. The pull request will be reviewed by others, and the final merge must be done by an OWNER. 22 | 23 | For contributing to documentation, see our [Developer Guide](https://oras-project.github.io/oras-py/getting_started/developer-guide.html#documentation). 24 | If you have any questions, please don't hesitate to [open an issue](https://github.com/oras-project/oras-py/issues). 25 | -------------------------------------------------------------------------------- /docs/getting_started/developer-guide.md: -------------------------------------------------------------------------------- 1 | # Developer Guide 2 | 3 | This developer guide includes more complex interactions like 4 | contributing registry entries and building containers. If you haven't 5 | read [the installation guide](installation.md) you 6 | should do that first. If you want to see a more general user guide with examples 7 | for using the SDK and writing clients, see our [user guide](user-guide.md). 8 | 9 | ## Running Tests 10 | 11 | You'll want to create an environment to install to, and then install: 12 | 13 | ```bash 14 | $ make install 15 | ``` 16 | 17 | We recommend a local registry without auth for tests. 18 | 19 | ```bash 20 | $ docker run -it --rm -p 5000:5000 ghcr.io/oras-project/registry:latest 21 | ``` 22 | 23 | Zot is a good solution too: 24 | 25 | ```bash 26 | docker run -d -p 5000:5000 --name oras-quickstart ghcr.io/project-zot/zot-linux-amd64:latest 27 | ``` 28 | 29 | For quick auth, you can use the included Developer container and do: 30 | 31 | ```bash 32 | make install 33 | make test 34 | ``` 35 | 36 | And then when you run `make test`, the tests will run. This ultimately 37 | runs the file [scripts/test.sh](https://github.com/oras-project/oras-py/blob/main/scripts/test.sh). 38 | If you want to test interactively, add an IPython import statement somewhere in the tests: 39 | 40 | ```bash 41 | # pip install IPython 42 | import IPython 43 | IPython.embed() 44 | ``` 45 | 46 | And then change the last line of the file to be: 47 | 48 | ```diff 49 | - pytest oras/ 50 | + pytest -xs oras/ 51 | ``` 52 | 53 | And then you should be able to interactively run (and debug) the same tests 54 | that run in GitHub actions. 55 | 56 | 57 | ## Code Linting 58 | 59 | We use [pre-commit](https://pre-commit.com/) to handle code linting and formatting, including: 60 | 61 | - black 62 | - isort 63 | - flake8 64 | - mypy 65 | 66 | Our setup also handles line endings and ensuring that you don't add large files! 67 | Using the tools is easy. After installing oras-py to a local environment, 68 | you can use pre-commit as follows: 69 | 70 | 71 | ```bash 72 | $ pip install -r .github/dev-requirements.txt 73 | ``` 74 | 75 | Then to do a manual run: 76 | 77 | ```bash 78 | $ pre-commit run --all-files 79 | ``` 80 | ```console 81 | check for added large files..............................................Passed 82 | check for case conflicts.................................................Passed 83 | check docstring is first.................................................Passed 84 | fix end of files.........................................................Passed 85 | trim trailing whitespace.................................................Passed 86 | mixed line ending........................................................Passed 87 | black....................................................................Passed 88 | isort....................................................................Passed 89 | flake8...................................................................Passed 90 | ``` 91 | 92 | And to install as a hook (recommended so you never commit with linting flaws!) 93 | 94 | ```bash 95 | $ pre-commit install 96 | ``` 97 | 98 | The above are provided as courtesy commands via: 99 | 100 | ```bash 101 | $ make develop 102 | $ make lint 103 | ``` 104 | 105 | ## Documentation 106 | 107 | The documentation is provided in the `docs` folder of the repository, 108 | and generally most content that you might want to add is under 109 | `getting_started`. For ease of contribution, files that are likely to be 110 | updated by contributors (e.g., mostly everything but the module generated files) 111 | are written in markdown. If you need to use [toctree](https://www.sphinx-doc.org/en/master/usage/restructuredtext/directives.html#table-of-contents) you should not use extra newlines or spaces (see index.md files for exampls). 112 | Markdown is the chosen language for the oras community, and this is why we chose to 113 | use it over restructured syntax - it makes it easier to contribute documentation. 114 | 115 | 116 | ### Install Dependencies and Build 117 | 118 | The documentation is built using sphinx, and generally you can install 119 | dependencies: 120 | 121 | ```console 122 | # In main oras-py folder (oras-py is needed to build docs) 123 | $ pip install -e . 124 | 125 | # Now docs dependencies 126 | cd docs 127 | pip install -r requrements.txt 128 | 129 | # Build the docs into _build/html 130 | make html 131 | ``` 132 | 133 | ### Preview Documentation 134 | 135 | After `make html` you can enter into `_build/html` and start a local web 136 | server to preview: 137 | 138 | ```console 139 | $ python -m http.server 9999 140 | ``` 141 | 142 | And open your browser to `localhost:9999` 143 | 144 | ### Docstrings 145 | 146 | To render our Python API into the docs, we keep an updated restructured 147 | syntax in the `docs/source` folder that you can update on demand as 148 | follows: 149 | 150 | ```console 151 | $ ./apidoc.sh 152 | ``` 153 | 154 | This should only be required if you change any docstrings or add/remove 155 | functions from oras-py source code. 156 | -------------------------------------------------------------------------------- /docs/getting_started/index.md: -------------------------------------------------------------------------------- 1 | # Guides 2 | 3 | Oras Python is a Python SDK for OCI registry as Storage (ORAS). You 4 | should be able to easily pull and push artifacts from Python and build 5 | your own custom interactions. The primary user guide and developer docs 6 | can be found here. If you have 7 | any questions or issues, please [let us know](https://github.com/oras-project/oras-py/issues) 8 | 9 | ```{toctree} 10 | :maxdepth: 3 11 | installation 12 | user-guide 13 | developer-guide 14 | ``` 15 | -------------------------------------------------------------------------------- /docs/getting_started/installation.md: -------------------------------------------------------------------------------- 1 | # Installation 2 | 3 | ## Pypi 4 | 5 | The module is available in pypi as 6 | [oras](https://pypi.org/project/oras/), and you can install as follows: 7 | 8 | ``` console 9 | $ pip install oras 10 | ``` 11 | 12 | You can also clone the repository and install locally: 13 | 14 | ``` console 15 | $ git clone https://github.com/oras-project/oras-py 16 | $ cd oras-py 17 | $ pip install . 18 | ``` 19 | 20 | Note that we have several extra modes for installation: 21 | 22 | ```console 23 | # Interactions are done via the docker client instead of manual 24 | $ pip install oras[docker] 25 | 26 | # Install dependencies for linting and tests 27 | $ pip install oras[tests] 28 | 29 | # Install everything 30 | $ pip install oras[all] 31 | ``` 32 | 33 | Or in development mode, add `-e`: 34 | 35 | ```console 36 | $ pip install -e . 37 | ``` 38 | 39 | Development mode means that the install is done from where you've 40 | cloned the library, so any changes you make are immediately "live" for 41 | testing. 42 | 43 | ## Docker Container 44 | 45 | We provide a 46 | [Dockerfile](https://github.com/oras-project/oras-py/blob/main/Dockerfile) 47 | to build a container with the client. 48 | 49 | ```console 50 | $ docker build -t oras-py . 51 | 52 | $ docker run -it oras-py 53 | $ ipython 54 | ``` 55 | ```python 56 | > import oras 57 | ``` 58 | -------------------------------------------------------------------------------- /docs/images/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oras-project/oras-py/b63ede0287865d5884812312f7e90ca44724296f/docs/images/favicon.ico -------------------------------------------------------------------------------- /docs/images/oras.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oras-project/oras-py/b63ede0287865d5884812312f7e90ca44724296f/docs/images/oras.png -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # Oras Python 2 | 3 | ![Oras Python Logo](https://raw.githubusercontent.com/oras-project/oras-py/main/docs/images/oras.png) 4 | 5 | Welcome to Oras Python! 6 | 7 | OCI Registry as Storage enables client libraries to push OCI Artifacts 8 | to [OCI Conformant](https://github.com/opencontainers/oci-conformance) 9 | registries. This is a Python SDK for that. 10 | 11 | ```console 12 | # Install the client 13 | $ pip install oras 14 | ``` 15 | 16 | And then within Python: 17 | 18 | ```python 19 | # Create a basic client 20 | import oras.client 21 | client = oras.client.OrasClient() 22 | client.login(password="myuser", username="myuser") 23 | 24 | # Push 25 | client.push(files=["artifact.txt"], target="ghcr.io/avocados/dinosaur/artifact:v1") 26 | Successfully pushed ghcr.io/avocados/dinosaur/artifact:v1 27 | Out[4]: 28 | 29 | # Pull 30 | res = client.pull(target="localhost:5000/dinosaur/artifact:v1") 31 | ['/tmp/oras-tmp.e5itvzfi/artifact.txt'] 32 | ``` 33 | 34 | To get started, see the links below. Would you like to 35 | request a feature or contribute? [Open an issue](https://github.com/oras-project/oras-py/issues). 36 | 37 | ```{toctree} 38 | :maxdepth: 2 39 | getting_started/index.md 40 | contributing.md 41 | about/license 42 | ``` 43 | 44 | ```{toctree} 45 | :caption: API 46 | :maxdepth: 1 47 | source/modules.rst 48 | ``` 49 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=. 11 | set BUILDDIR=_build 12 | 13 | if "%1" == "" goto help 14 | 15 | %SPHINXBUILD% >NUL 2>NUL 16 | if errorlevel 9009 ( 17 | echo. 18 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 19 | echo.installed, then set the SPHINXBUILD environment variable to point 20 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 21 | echo.may add the Sphinx directory to PATH. 22 | echo. 23 | echo.If you don't have Sphinx installed, grab it from 24 | echo.http://www.sphinx-doc.org/ 25 | exit /b 1 26 | ) 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | sphinx_material 2 | numpy>=1.16 3 | pandas 4 | matplotlib 5 | nbsphinx 6 | jupyter_client 7 | notebook 8 | nbconvert 9 | ipykernel 10 | recommonmark 11 | sphinx_markdown_tables 12 | sphinx-copybutton 13 | readthedocs-sphinx-search 14 | sphinx-gallery 15 | myst-parser 16 | pytest 17 | -------------------------------------------------------------------------------- /docs/source/modules.rst: -------------------------------------------------------------------------------- 1 | oras 2 | ==== 3 | 4 | .. toctree:: 5 | :maxdepth: 4 6 | 7 | oras 8 | -------------------------------------------------------------------------------- /docs/source/oras.main.rst: -------------------------------------------------------------------------------- 1 | oras.main package 2 | ================= 3 | 4 | Submodules 5 | ---------- 6 | 7 | oras.main.login module 8 | ---------------------- 9 | 10 | .. automodule:: oras.main.login 11 | :members: 12 | :undoc-members: 13 | :show-inheritance: 14 | 15 | Module contents 16 | --------------- 17 | 18 | .. automodule:: oras.main 19 | :members: 20 | :undoc-members: 21 | :show-inheritance: 22 | -------------------------------------------------------------------------------- /docs/source/oras.rst: -------------------------------------------------------------------------------- 1 | oras package 2 | ============ 3 | 4 | Subpackages 5 | ----------- 6 | 7 | .. toctree:: 8 | :maxdepth: 4 9 | 10 | oras.main 11 | oras.tests 12 | oras.utils 13 | 14 | Submodules 15 | ---------- 16 | 17 | oras.auth module 18 | ---------------- 19 | 20 | .. automodule:: oras.auth 21 | :members: 22 | :undoc-members: 23 | :show-inheritance: 24 | 25 | oras.client module 26 | ------------------ 27 | 28 | .. automodule:: oras.client 29 | :members: 30 | :undoc-members: 31 | :show-inheritance: 32 | 33 | oras.container module 34 | --------------------- 35 | 36 | .. automodule:: oras.container 37 | :members: 38 | :undoc-members: 39 | :show-inheritance: 40 | 41 | oras.decorator module 42 | --------------------- 43 | 44 | .. automodule:: oras.decorator 45 | :members: 46 | :undoc-members: 47 | :show-inheritance: 48 | 49 | oras.defaults module 50 | -------------------- 51 | 52 | .. automodule:: oras.defaults 53 | :members: 54 | :undoc-members: 55 | :show-inheritance: 56 | 57 | oras.logger module 58 | ------------------ 59 | 60 | .. automodule:: oras.logger 61 | :members: 62 | :undoc-members: 63 | :show-inheritance: 64 | 65 | oras.oci module 66 | --------------- 67 | 68 | .. automodule:: oras.oci 69 | :members: 70 | :undoc-members: 71 | :show-inheritance: 72 | 73 | oras.provider module 74 | -------------------- 75 | 76 | .. automodule:: oras.provider 77 | :members: 78 | :undoc-members: 79 | :show-inheritance: 80 | 81 | oras.schemas module 82 | ------------------- 83 | 84 | .. automodule:: oras.schemas 85 | :members: 86 | :undoc-members: 87 | :show-inheritance: 88 | 89 | oras.version module 90 | ------------------- 91 | 92 | .. automodule:: oras.version 93 | :members: 94 | :undoc-members: 95 | :show-inheritance: 96 | 97 | Module contents 98 | --------------- 99 | 100 | .. automodule:: oras 101 | :members: 102 | :undoc-members: 103 | :show-inheritance: 104 | -------------------------------------------------------------------------------- /docs/source/oras.tests.rst: -------------------------------------------------------------------------------- 1 | oras.tests package 2 | ================== 3 | 4 | Submodules 5 | ---------- 6 | 7 | oras.tests.test\_oras module 8 | ---------------------------- 9 | 10 | .. automodule:: oras.tests.test_oras 11 | :members: 12 | :undoc-members: 13 | :show-inheritance: 14 | 15 | oras.tests.test\_provider module 16 | -------------------------------- 17 | 18 | .. automodule:: oras.tests.test_provider 19 | :members: 20 | :undoc-members: 21 | :show-inheritance: 22 | 23 | oras.tests.test\_utils module 24 | ----------------------------- 25 | 26 | .. automodule:: oras.tests.test_utils 27 | :members: 28 | :undoc-members: 29 | :show-inheritance: 30 | 31 | Module contents 32 | --------------- 33 | 34 | .. automodule:: oras.tests 35 | :members: 36 | :undoc-members: 37 | :show-inheritance: 38 | -------------------------------------------------------------------------------- /docs/source/oras.utils.rst: -------------------------------------------------------------------------------- 1 | oras.utils package 2 | ================== 3 | 4 | Submodules 5 | ---------- 6 | 7 | oras.utils.fileio module 8 | ------------------------ 9 | 10 | .. automodule:: oras.utils.fileio 11 | :members: 12 | :undoc-members: 13 | :show-inheritance: 14 | 15 | oras.utils.request module 16 | ------------------------- 17 | 18 | .. automodule:: oras.utils.request 19 | :members: 20 | :undoc-members: 21 | :show-inheritance: 22 | 23 | Module contents 24 | --------------- 25 | 26 | .. automodule:: oras.utils 27 | :members: 28 | :undoc-members: 29 | :show-inheritance: 30 | -------------------------------------------------------------------------------- /examples/README.md: -------------------------------------------------------------------------------- 1 | # Oras Python SDK Examples 2 | 3 | The directory here has the following examples: 4 | 5 | ## Local Examples 6 | 7 | - [simple](simple): simple examples for individual commands adopted from oras-py [before the client was removed](https://github.com/oras-project/oras-py/tree/3b4e6d74d49b8c6a5d8180e646d52fcc50b3508a). 8 | - [conda-mirror.py](conda-mirror.py): upload to a conda mirror with ORAS with a manifest and custom content types. 9 | - [follow-image-index.py](follow-image-index.py): Download a homebrew image index and select a platform-specific image. 10 | 11 | ## In the Wild Examples 12 | 13 | The following examples are found in other projects! If you have used oras in 14 | your project, we encourage you to add it to the list here. 15 | 16 | - **cloud-select**: this tool [creates a list of custom layers](https://github.com/converged-computing/cloud-select/blob/main/cloud_select/main/cache.py#L81-L107) and content types to store via ORAS and a [custom client](https://github.com/converged-computing/cloud-select/blob/db02c4378f06bfbbe9df6b8a83c885cc9238a04e/cloud_select/main/cache.py#L81). 17 | - **pakages**: uses a [custom client](https://github.com/syspack/pakages/blob/main/pakages/oras.py) to store Spack or pypi (or other artifacts) in GitHub packages. 18 | 19 | 20 | Know a project that uses the ORAS Python SDK and want to add to this list? 21 | Please open a pull request or [let us know](https://github.com/oras-project/oras-py/issues). 22 | -------------------------------------------------------------------------------- /examples/conda-mirror.py: -------------------------------------------------------------------------------- 1 | # This is an example of a custom client described in: 2 | # https://github.com/oras-project/oras-py/issues/11 that wants to use oras 3 | # to upload multiple different objects in different layers of a manifest, 4 | # and then have a custom filter for those layers. 5 | 6 | import io 7 | import json 8 | import os 9 | import sys 10 | import tarfile 11 | 12 | import oras.client 13 | import oras.provider 14 | 15 | 16 | class CondaMirror(oras.provider.Registry): 17 | """ 18 | A CondaMirror is a custom remote to push three layers per conda package: 19 | 1. .tar.bz2 package file 20 | 2. index.json file containing metadata and package dependencies 21 | 3. .tar.gz of the info directory 22 | 23 | For the third, we want to be able to get interesting metadata about the 24 | package without actually needing to download it. 25 | """ 26 | 27 | # We can use media types to organize layers 28 | media_types = { 29 | "application/vnd.conda.info.v1.tar+gzip": "info_archive", 30 | "application/vnd.conda.info.index.v1+json": "info_index", 31 | "application/vnd.conda.package.v1": "package_tarbz2", 32 | "application/vnd.conda.package.v2": "package_conda", 33 | } 34 | 35 | def inspect(self, name): 36 | # Parse the name into a container 37 | container = self.get_container(name) 38 | 39 | # Get the manifest with the three layers 40 | manifest = self.get_manifest(container) 41 | 42 | # Organize layers based on media_types 43 | layers = self._organize_layers(manifest) 44 | 45 | # Get the index (the function will check the success of the response) 46 | if "info_index" in layers: 47 | index = self.get_blob(container, layers["info_index"]["digest"]).json() 48 | print(json.dumps(index, indent=4)) 49 | 50 | # The compressed index 51 | if "info_archive" in layers: 52 | archive = self.get_blob(container, layers["info_archive"]["digest"]) 53 | archive = tarfile.open(fileobj=io.BytesIO(archive.content), mode="r:gz") 54 | print(archive.members) 55 | 56 | if "package_tarbz2" in layers: 57 | print( 58 | "Found layer %s that could be extracted to %s." 59 | % ( 60 | layers["package_tarbz2"]["digest"], 61 | layers["package_tarbz2"]["annotations"][ 62 | "org.opencontainers.image.title" 63 | ], 64 | ) 65 | ) 66 | 67 | def _organize_layers(self, manifest: dict) -> dict: 68 | """ 69 | Given a manifest, organize based on the media type. 70 | """ 71 | layers = {} 72 | for layer in manifest.get("layers", []): 73 | if layer["mediaType"] in self.media_types: 74 | layers[self.media_types[layer["mediaType"]]] = layer 75 | return layers 76 | 77 | 78 | # We will need GitHub personal access token or token 79 | token = os.environ.get("GITHUB_TOKEN") 80 | user = os.environ.get("GITHUB_USER") 81 | 82 | if not token or not user: 83 | sys.exit("GITHUB_TOKEN and GITHUB_USER are required in the environment.") 84 | 85 | 86 | def main(): 87 | mirror = CondaMirror() 88 | mirror.inspect("ghcr.io/wolfv/conda-forge/linux-64/xtensor:0.9.0-0") 89 | 90 | 91 | if __name__ == "__main__": 92 | main() 93 | -------------------------------------------------------------------------------- /examples/follow-image-index.py: -------------------------------------------------------------------------------- 1 | """ 2 | Follow homebrew image index to get the 'hello' bottle specific to your platform 3 | """ 4 | 5 | import re 6 | 7 | import oras.client 8 | import oras.provider 9 | from oras import decorator 10 | 11 | 12 | class MyRegistry(oras.provider.Registry): 13 | """ 14 | Oras registry with support for image indexes. 15 | """ 16 | 17 | @decorator.ensure_container 18 | def get_image_index(self, container, allowed_media_type=None): 19 | """ 20 | Get an image index as a manifest. 21 | 22 | This is basically Registry.get_manifest with the following changes 23 | 24 | - different default allowed_media_type 25 | - no JSON schema validation 26 | """ 27 | if not allowed_media_type: 28 | default_image_index_media_type = "application/vnd.oci.image.index.v1+json" 29 | allowed_media_type = [default_image_index_media_type] 30 | 31 | headers = {"Accept": ";".join(allowed_media_type)} 32 | 33 | manifest_url = f"{self.prefix}://{container.manifest_url()}" 34 | response = self.do_request(manifest_url, "GET", headers=headers) 35 | self._check_200_response(response) 36 | manifest = response.json() 37 | # this would be a good point to validate the schema of the manifest 38 | # jsonschema.validate(manifest, schema=...) 39 | return manifest 40 | 41 | 42 | def get_uri_for_digest(uri, digest): 43 | """ 44 | Given a URI for an image, return a URI for the related digest. 45 | 46 | URI may be in any of the following forms: 47 | 48 | ghcr.io/homebrew/core/hello 49 | ghcr.io/homebrew/core/hello:2.10 50 | ghcr.io/homebrew/core/hello@sha256:ff81...47a 51 | """ 52 | base_uri = re.split(r"[@:]", uri, maxsplit=1)[0] 53 | return f"{base_uri}@{digest}" 54 | 55 | 56 | def get_image_for_platform(client, uri, download_to, platform_details): 57 | def matches_platform(manifest): 58 | platform = manifest.get("platform", {}) 59 | return all( 60 | platform.get(key) == requested_value 61 | for key, requested_value in platform_details.items() 62 | ) 63 | 64 | index_manifest = client.remote.get_image_index(container=uri) 65 | # use first compatible manifest. YMMV and a tie-breaker may be more suitable 66 | for manifest in index_manifest["manifests"]: 67 | if matches_platform(manifest): 68 | break 69 | else: 70 | raise RuntimeError( 71 | f"No manifest definition matched platform {platform_details}" 72 | ) 73 | 74 | platform_image_uri = get_uri_for_digest(uri, manifest["digest"]) 75 | client.pull(target=platform_image_uri, outdir=download_to) 76 | 77 | 78 | if __name__ == "__main__": 79 | client = oras.client.OrasClient(registry=MyRegistry()) 80 | platform_details = { 81 | "architecture": "amd64", 82 | "os": "darwin", 83 | "os.version": "macOS 10.14", 84 | } 85 | get_image_for_platform( 86 | client, 87 | "ghcr.io/homebrew/core/hello:2.10", 88 | download_to="downloads", 89 | platform_details=platform_details, 90 | ) 91 | -------------------------------------------------------------------------------- /examples/simple/login.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | # This shows an example client. You might need to modify the underlying client 4 | # or provider for your use case. See the oras/client.py (client here) and 5 | # oras.provider.py for what is used here (and you might want to customize 6 | # these classes for your needs). 7 | 8 | import argparse 9 | 10 | import oras.client 11 | from oras.logger import logger, setup_logger 12 | 13 | 14 | def get_parser(): 15 | parser = argparse.ArgumentParser( 16 | description="OCI Python SDK Example Login", 17 | formatter_class=argparse.RawTextHelpFormatter, 18 | ) 19 | 20 | parser.add_argument( 21 | "--quiet", 22 | dest="quiet", 23 | help="suppress additional output.", 24 | default=False, 25 | action="store_true", 26 | ) 27 | 28 | parser.add_argument( 29 | "--password-stdin", 30 | dest="password_stdin", 31 | help="read password or identity token from stdin", 32 | default=False, 33 | action="store_true", 34 | ) 35 | 36 | # Login and logout share config and hostname arguments 37 | parser.add_argument("hostname", help="hostname") 38 | parser.add_argument( 39 | "--debug", 40 | dest="debug", 41 | help="debug mode", 42 | default=False, 43 | action="store_true", 44 | ) 45 | parser.add_argument( 46 | "-c", 47 | "--config", 48 | dest="config", 49 | help="auth config path", 50 | action="append", 51 | ) 52 | 53 | parser.add_argument("-u", "--username", dest="username", help="registry username") 54 | parser.add_argument( 55 | "-p", 56 | "--password", 57 | dest="password", 58 | help="registry password or identity token", 59 | ) 60 | parser.add_argument( 61 | "-i", 62 | "--insecure", 63 | dest="insecure", 64 | help="allow connections to SSL registry without certs", 65 | default=False, 66 | action="store_true", 67 | ) 68 | return parser 69 | 70 | 71 | def main(args): 72 | """ 73 | This is an example of running login, and catching errors. 74 | 75 | We handle login by doing the following: 76 | 77 | 1. We default to the login function of the basic client. If a custom 78 | provider is initiated with the client, we use it instead (self.remote.logic) 79 | 2. For the default, we ask for password / username if they are not provided. 80 | 3. We cut out early if password and username are not defined. 81 | 4. If defined, we set basic auth using them (so the client is ready) 82 | 5. We first try using the docker-py login. 83 | 6. If it fails we fall back to custom setting of credentials. 84 | """ 85 | client = oras.client.OrasClient(insecure=args.insecure) 86 | print(client.version()) 87 | 88 | # Other ways to handle login: 89 | # client.set_basic_auth(username, password) 90 | # client.set_token_auth(token) 91 | 92 | try: 93 | result = client.login( 94 | password=args.password, 95 | username=args.username, 96 | config_path=args.config, 97 | hostname=args.hostname, 98 | password_stdin=args.password_stdin, 99 | ) 100 | logger.info(result) 101 | except Exception as e: 102 | logger.exit(str(e)) 103 | 104 | 105 | if __name__ == "__main__": 106 | parser = get_parser() 107 | args, _ = parser.parse_known_args() 108 | setup_logger(quiet=args.quiet, debug=args.debug) 109 | main(args) 110 | -------------------------------------------------------------------------------- /examples/simple/logout.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | # This shows an example client. You might need to modify the underlying client 4 | # or provider for your use case. See the oras/client.py (client here) and 5 | # oras.provider.py for what is used here (and you might want to customize 6 | # these classes for your needs). 7 | 8 | import argparse 9 | 10 | import oras.client 11 | from oras.logger import setup_logger 12 | 13 | 14 | def main(args): 15 | """ 16 | Main is a light wrapper around the logout command. 17 | """ 18 | client = oras.client.OrasClient() 19 | print(client.version()) 20 | client.logout(args.hostname) 21 | 22 | 23 | def get_parser(): 24 | parser = argparse.ArgumentParser( 25 | description="OCI Python SDK Example Logout", 26 | formatter_class=argparse.RawTextHelpFormatter, 27 | ) 28 | 29 | parser.add_argument( 30 | "--quiet", 31 | dest="quiet", 32 | help="suppress additional output.", 33 | default=False, 34 | action="store_true", 35 | ) 36 | 37 | # Login and logout share config and hostname arguments 38 | parser.add_argument("hostname", help="hostname") 39 | parser.add_argument( 40 | "--debug", 41 | dest="debug", 42 | help="debug mode", 43 | default=False, 44 | action="store_true", 45 | ) 46 | return parser 47 | 48 | 49 | if __name__ == "__main__": 50 | parser = get_parser() 51 | args, _ = parser.parse_known_args() 52 | setup_logger(quiet=args.quiet, debug=args.debug) 53 | main(args) 54 | -------------------------------------------------------------------------------- /examples/simple/pull.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | 4 | # This shows an example client. You might need to modify the underlying client 5 | # or provider for your use case. See the oras/client.py (client here) and 6 | # oras.provider.py for what is used here (and you might want to customize 7 | # these classes for your needs). 8 | 9 | import argparse 10 | import os 11 | 12 | import oras.client 13 | from oras.logger import logger, setup_logger 14 | 15 | 16 | def main(args): 17 | """ 18 | A wrapper around an oras client pull. 19 | """ 20 | client = oras.client.OrasClient(insecure=args.insecure) 21 | print(client.version()) 22 | try: 23 | client.pull( 24 | config_path=args.config, 25 | allowed_media_type=( 26 | args.allowed_media_type if not args.allow_all_media_types else [] 27 | ), 28 | overwrite=not args.keep_old_files, 29 | outdir=args.output, 30 | target=args.target, 31 | ) 32 | except Exception as e: 33 | logger.exit(str(e)) 34 | 35 | 36 | def get_parser(): 37 | parser = argparse.ArgumentParser( 38 | description="OCI Registry as Storage Python SDK example pull client", 39 | formatter_class=argparse.RawTextHelpFormatter, 40 | ) 41 | 42 | parser.add_argument( 43 | "--quiet", 44 | dest="quiet", 45 | help="suppress additional output.", 46 | default=False, 47 | action="store_true", 48 | ) 49 | 50 | parser.add_argument( 51 | "--version", 52 | dest="version", 53 | help="Show the oras version information.", 54 | default=False, 55 | action="store_true", 56 | ) 57 | 58 | parser.add_argument( 59 | "--allowed-media-type", help="add an allowed media type.", action="append" 60 | ) 61 | parser.add_argument( 62 | "--allow-all-media-types", 63 | help="allow all media types", 64 | default=False, 65 | action="store_true", 66 | ) 67 | parser.add_argument( 68 | "-k", 69 | "--keep-old-files", 70 | help="do not overwrite existing files.", 71 | default=False, 72 | action="store_true", 73 | ) 74 | parser.add_argument("--output", help="output directory.", default=os.getcwd()) 75 | parser.add_argument("target", help="target") 76 | parser.add_argument( 77 | "--debug", 78 | dest="debug", 79 | help="debug mode", 80 | default=False, 81 | action="store_true", 82 | ) 83 | 84 | parser.add_argument( 85 | "-c", 86 | "--config", 87 | dest="config", 88 | help="auth config path", 89 | action="append", 90 | ) 91 | 92 | parser.add_argument("-u", "--username", dest="username", help="registry username") 93 | parser.add_argument( 94 | "-p", 95 | "--password", 96 | dest="password", 97 | help="registry password or identity token", 98 | ) 99 | parser.add_argument( 100 | "-i", 101 | "--insecure", 102 | dest="insecure", 103 | help="allow connections to SSL registry without certs", 104 | default=False, 105 | action="store_true", 106 | ) 107 | 108 | return parser 109 | 110 | 111 | if __name__ == "__main__": 112 | parser = get_parser() 113 | args, _ = parser.parse_known_args() 114 | setup_logger(quiet=args.quiet, debug=args.debug) 115 | main(args) 116 | -------------------------------------------------------------------------------- /examples/simple/push.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | 4 | # This shows an example client. You might need to modify the underlying client 5 | # or provider for your use case. See the oras/client.py (client here) and 6 | # oras.provider.py for what is used here (and you might want to customize 7 | # these classes for your needs). 8 | 9 | import argparse 10 | import os 11 | 12 | import oras.client 13 | import oras.utils 14 | from oras.logger import logger, setup_logger 15 | 16 | 17 | def load_manifest_annotations(annotation_file, annotations): 18 | """ 19 | Disambiguate annotations. 20 | """ 21 | annotations = annotations or [] 22 | if annotation_file and not os.path.exists(annotation_file): 23 | logger.exit(f"Annotation file {annotation_file} does not exist.") 24 | if annotation_file: 25 | lookup = oras.utils.read_json(annotation_file) 26 | 27 | # not allowed to define both, mirroring oras-go 28 | if "$manifest" in lookup and lookup["$manifest"]: 29 | raise ValueError( 30 | "`--annotation` and `--annotation-file` with $manifest cannot be both specified." 31 | ) 32 | 33 | # Finally, parse the list of annotations 34 | parsed = {} 35 | for annot in annotations: 36 | if "=" not in annot: 37 | logger.exit( 38 | "Annotation {annot} invalid format, needs to be key=value pair." 39 | ) 40 | key, value = annot.split("=", 1) 41 | parsed[key.strip()] = value.strip() 42 | return parsed 43 | 44 | 45 | def main(args): 46 | """ 47 | A wrapper around an oras client push. 48 | """ 49 | manifest_annotations = load_manifest_annotations( 50 | args.annotation_file, args.annotation 51 | ) 52 | client = oras.client.OrasClient(insecure=args.insecure) 53 | try: 54 | client.push( 55 | config_path=args.config, 56 | disable_path_validation=args.disable_path_validation, 57 | files=args.filerefs, 58 | manifest_config=args.manifest_config, 59 | annotation_file=args.annotation_file, 60 | manifest_annotations=manifest_annotations, 61 | quiet=args.quiet, 62 | target=args.target, 63 | ) 64 | except Exception as e: 65 | logger.exit(str(e)) 66 | 67 | 68 | def get_parser(): 69 | parser = argparse.ArgumentParser( 70 | description="OCI Registry as Storage Python SDK example push client", 71 | formatter_class=argparse.RawTextHelpFormatter, 72 | ) 73 | 74 | parser.add_argument( 75 | "--quiet", 76 | dest="quiet", 77 | help="suppress additional output.", 78 | default=False, 79 | action="store_true", 80 | ) 81 | 82 | parser.add_argument( 83 | "--version", 84 | dest="version", 85 | help="Show the oras version information.", 86 | default=False, 87 | action="store_true", 88 | ) 89 | 90 | parser.add_argument("--annotation-file", help="manifest annotation file") 91 | parser.add_argument( 92 | "--annotation", 93 | help="single manifest annotation (e.g., key=value)", 94 | action="append", 95 | ) 96 | parser.add_argument("--manifest-config", help="manifest config file") 97 | parser.add_argument( 98 | "--disable-path-validation", 99 | help="skip path validation", 100 | default=False, 101 | action="store_true", 102 | ) 103 | parser.add_argument("target", help="target") 104 | parser.add_argument("filerefs", help="file references", nargs="+") 105 | 106 | # Debug is added on the level of the command 107 | parser.add_argument( 108 | "--debug", 109 | dest="debug", 110 | help="debug mode", 111 | default=False, 112 | action="store_true", 113 | ) 114 | 115 | parser.add_argument( 116 | "-c", 117 | "--config", 118 | dest="config", 119 | help="auth config path", 120 | action="append", 121 | ) 122 | 123 | parser.add_argument("-u", "--username", dest="username", help="registry username") 124 | parser.add_argument( 125 | "-p", 126 | "--password", 127 | dest="password", 128 | help="registry password or identity token", 129 | ) 130 | parser.add_argument( 131 | "-i", 132 | "--insecure", 133 | dest="insecure", 134 | help="allow connections to SSL registry without certs", 135 | default=False, 136 | action="store_true", 137 | ) 138 | return parser 139 | 140 | 141 | if __name__ == "__main__": 142 | parser = get_parser() 143 | args, _ = parser.parse_known_args() 144 | setup_logger(quiet=args.quiet, debug=args.debug) 145 | main(args) 146 | -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | ignore_missing_imports = True 3 | -------------------------------------------------------------------------------- /oras/__init__.py: -------------------------------------------------------------------------------- 1 | from oras.version import __version__ 2 | -------------------------------------------------------------------------------- /oras/auth/__init__.py: -------------------------------------------------------------------------------- 1 | import requests 2 | 3 | from .basic import BasicAuth 4 | from .token import TokenAuth 5 | 6 | auth_backends = {"token": TokenAuth, "basic": BasicAuth} 7 | 8 | 9 | class AuthenticationException(Exception): 10 | """ 11 | An exception to raise when Authentication errors are fatal 12 | """ 13 | 14 | pass 15 | 16 | 17 | def get_auth_backend( 18 | name="token", session=None, insecure=False, tls_verify=True, **kwargs 19 | ): 20 | backend = auth_backends.get(name) 21 | if not backend: 22 | raise ValueError(f"Authentication backend {backend} is not known.") 23 | backend = backend(**kwargs) 24 | backend.session = session or requests.Session() 25 | backend.prefix = "http" if insecure else "https" 26 | backend._tls_verify = tls_verify 27 | return backend 28 | -------------------------------------------------------------------------------- /oras/auth/base.py: -------------------------------------------------------------------------------- 1 | __author__ = "Vanessa Sochat" 2 | __copyright__ = "Copyright The ORAS Authors." 3 | __license__ = "Apache-2.0" 4 | 5 | 6 | from typing import Optional 7 | 8 | import requests 9 | 10 | import oras.auth.utils as auth_utils 11 | import oras.container 12 | import oras.decorator as decorator 13 | from oras.logger import logger 14 | from oras.types import container_type 15 | 16 | 17 | class AuthBackend: 18 | """ 19 | Generic (and default) auth backend. 20 | """ 21 | 22 | session: requests.Session 23 | _tls_verify: bool 24 | 25 | def __init__(self, *args, **kwargs): 26 | self._auths: dict = {} 27 | self.prefix: str = "https" 28 | 29 | def get_auth_header(self): 30 | raise NotImplementedError 31 | 32 | def get_container(self, name: container_type) -> oras.container.Container: 33 | """ 34 | Courtesy function to get a container from a URI. 35 | 36 | :param name: unique resource identifier to parse 37 | :type name: oras.container.Container or str 38 | """ 39 | if isinstance(name, oras.container.Container): 40 | return name 41 | return oras.container.Container(name, registry=self.hostname) 42 | 43 | def logout(self, hostname: str): 44 | """ 45 | If auths are loaded, remove a hostname. 46 | 47 | :param hostname: the registry hostname to remove 48 | :type hostname: str 49 | """ 50 | self._logout() 51 | if not self._auths: 52 | logger.info(f"You are not logged in to {hostname}") 53 | return 54 | 55 | for host in oras.utils.iter_localhosts(hostname): 56 | if host in self._auths: 57 | del self._auths[host] 58 | logger.info(f"You have successfully logged out of {hostname}") 59 | return 60 | logger.info(f"You are not logged in to {hostname}") 61 | 62 | def _logout(self): 63 | pass 64 | 65 | def _load_auth(self, hostname: str) -> bool: 66 | """ 67 | Look for and load a named authentication token. 68 | 69 | :param hostname: the registry hostname to look for 70 | :type hostname: str 71 | """ 72 | # Note that the hostname can be defined without a token 73 | if hostname in self._auths: 74 | auth = self._auths[hostname].get("auth") 75 | 76 | # Case 1: they use a credsStore we don't know how to read 77 | if not auth and "credsStore" in self._auths[hostname]: 78 | logger.warning( 79 | '"credsStore" found in your ~/.docker/config.json, which is not supported by oras-py. Remove it, docker login, and try again.' 80 | ) 81 | return False 82 | 83 | # Case 2: no auth there (wonky file) 84 | elif not auth: 85 | return False 86 | self._basic_auth = auth 87 | return True 88 | return False 89 | 90 | @decorator.ensure_container 91 | def load_configs(self, container: container_type, configs: Optional[list] = None): 92 | """ 93 | Load configs to discover credentials for a specific container. 94 | 95 | This is typically just called once. We always add the default Docker 96 | config to the set.s 97 | 98 | :param container: the parsed container URI with components 99 | :type container: oras.container.Container 100 | :param configs: list of configs to read (optional) 101 | :type configs: list 102 | """ 103 | if not self._auths: 104 | self._auths = auth_utils.load_configs(configs) 105 | for registry in oras.utils.iter_localhosts(container.registry): # type: ignore 106 | if self._load_auth(registry): 107 | return 108 | 109 | def set_token_auth(self, token: str): 110 | """ 111 | Set token authentication. 112 | 113 | :param token: the bearer token 114 | :type token: str 115 | """ 116 | self.token = token 117 | self.set_header("Authorization", "Bearer %s" % token) 118 | 119 | def set_basic_auth(self, username: str, password: str): 120 | """ 121 | Set basic authentication. 122 | 123 | :param username: the user account name 124 | :type username: str 125 | :param password: the user account password 126 | :type password: str 127 | """ 128 | self._basic_auth = auth_utils.get_basic_auth(username, password) 129 | 130 | def request_anonymous_token(self, h: auth_utils.authHeader, headers: dict) -> bool: 131 | """ 132 | Given no basic auth, fall back to trying to request an anonymous token. 133 | 134 | Returns: boolean if headers have been updated with token. 135 | """ 136 | if not h.realm: 137 | logger.debug("Request anonymous token: no realm provided, exiting early") 138 | return headers, False 139 | 140 | params = {} 141 | if h.service: 142 | params["service"] = h.service 143 | if h.scope: 144 | params["scope"] = h.scope 145 | 146 | logger.debug(f"Final params are {params}") 147 | response = self.session.request("GET", h.realm, params=params) 148 | if response.status_code != 200: 149 | logger.debug(f"Response for anon token failed: {response.text}") 150 | return headers, False 151 | 152 | # From https://docs.docker.com/registry/spec/auth/token/ section 153 | # We can get token OR access_token OR both (when both they are identical) 154 | data = response.json() 155 | token = data.get("token") or data.get("access_token") 156 | 157 | # Update the headers but not self.token (expects Basic) 158 | if token: 159 | headers["Authorization"] = {"Authorization": "Bearer %s" % token} 160 | 161 | logger.debug("Warning: no token or access_token present in response.") 162 | return headers, False 163 | -------------------------------------------------------------------------------- /oras/auth/basic.py: -------------------------------------------------------------------------------- 1 | __author__ = "Vanessa Sochat" 2 | __copyright__ = "Copyright The ORAS Authors." 3 | __license__ = "Apache-2.0" 4 | 5 | import os 6 | 7 | import requests 8 | 9 | from .base import AuthBackend 10 | 11 | 12 | class BasicAuth(AuthBackend): 13 | """ 14 | Generic (and default) auth backend. 15 | """ 16 | 17 | def __init__(self): 18 | username = os.environ.get("ORAS_USER") 19 | password = os.environ.get("ORAS_PASS") 20 | super().__init__() 21 | if username and password: 22 | self.set_basic_auth(username, password) 23 | 24 | def _logout(self): 25 | self._basic_auth = None 26 | 27 | def get_auth_header(self): 28 | return {"Authorization": "Basic %s" % self._basic_auth} 29 | 30 | def authenticate_request( 31 | self, original: requests.Response, headers: dict, refresh=False 32 | ): 33 | """ 34 | Authenticate Request 35 | Given a response, look for a Www-Authenticate header to parse. 36 | 37 | We return True/False to indicate if the request should be retried. 38 | 39 | :param originalResponse: original response to get the Www-Authenticate header 40 | :type originalResponse: requests.Response 41 | """ 42 | result = {} 43 | if headers is not None: 44 | result.update(headers) 45 | result.update(self.get_auth_header()) 46 | return result, True 47 | -------------------------------------------------------------------------------- /oras/auth/token.py: -------------------------------------------------------------------------------- 1 | __author__ = "Vanessa Sochat" 2 | __copyright__ = "Copyright The ORAS Authors." 3 | __license__ = "Apache-2.0" 4 | 5 | import requests 6 | 7 | import oras.auth.utils as auth_utils 8 | from oras.logger import logger 9 | 10 | from .base import AuthBackend 11 | 12 | 13 | class TokenAuth(AuthBackend): 14 | """ 15 | Token (OAuth2) style auth. 16 | """ 17 | 18 | def __init__(self): 19 | self.token = None 20 | super().__init__() 21 | 22 | def _logout(self): 23 | self.token = None 24 | 25 | def set_token_auth(self, token: str): 26 | """ 27 | Set token authentication. 28 | 29 | :param token: the bearer token 30 | :type token: str 31 | """ 32 | self.token = token 33 | 34 | def get_auth_header(self): 35 | if self.token: 36 | return {"Authorization": "Bearer %s" % self.token} 37 | return {} 38 | 39 | def reset_basic_auth(self): 40 | """ 41 | Given we have basic auth, reset it. 42 | """ 43 | if "Authorization" in self.headers: 44 | del self.headers["Authorization"] 45 | if self._basic_auth: 46 | self.set_header("Authorization", "Basic %s" % self._basic_auth) 47 | 48 | def authenticate_request( 49 | self, original: requests.Response, headers: dict, refresh=False 50 | ): 51 | """ 52 | Authenticate Request 53 | Given a response, look for a Www-Authenticate header to parse. 54 | 55 | We return True/False to indicate if the request should be retried. 56 | 57 | :param original: original response to get the Www-Authenticate header 58 | :type original: requests.Response 59 | """ 60 | headers = headers or {} 61 | if refresh: 62 | self.token = None 63 | authHeaderRaw = original.headers.get("Www-Authenticate") 64 | if not authHeaderRaw: 65 | logger.debug( 66 | "Www-Authenticate not found in original response, cannot authenticate." 67 | ) 68 | return headers, False 69 | 70 | # If we have a token, set auth header (base64 encoded user/pass) 71 | if self.token: 72 | headers["Authorization"] = "Bearer %s" % self.token 73 | return headers, True 74 | 75 | h = auth_utils.parse_auth_header(authHeaderRaw) 76 | 77 | # if no basic auth, try by request an anonymous token 78 | if not hasattr(self, "_basic_auth"): 79 | anon_token = self.request_anonymous_token(h) 80 | if anon_token: 81 | logger.debug("Successfully obtained anonymous token!") 82 | self.token = anon_token 83 | headers["Authorization"] = "Bearer %s" % self.token 84 | return headers, True 85 | 86 | # basic auth is available, try using auth token 87 | token = self.request_token(h) 88 | if token: 89 | self.token = token 90 | headers["Authorization"] = "Bearer %s" % self.token 91 | return headers, True 92 | 93 | logger.error( 94 | "This endpoint requires a token. Please use " 95 | "basic auth with a username or password." 96 | ) 97 | return headers, False 98 | 99 | def request_token(self, h: auth_utils.authHeader) -> bool: 100 | """ 101 | Request an authenticated token and save for later. 102 | """ 103 | params = {} 104 | headers = {} 105 | 106 | # Prepare request to retry 107 | if h.service: 108 | logger.debug(f"Service: {h.service}") 109 | params["service"] = h.service 110 | headers.update( 111 | { 112 | "Service": h.service, 113 | "Accept": "application/json", 114 | "User-Agent": "oras-py", 115 | } 116 | ) 117 | 118 | # Ensure the realm starts with http 119 | if not h.realm.startswith("http"): # type: ignore 120 | h.realm = f"{self.prefix}://{h.realm}" 121 | 122 | # If the www-authenticate included a scope, honor it! 123 | if h.scope: 124 | logger.debug(f"Scope: {h.scope}") 125 | params["scope"] = h.scope 126 | 127 | # Set Basic Auth to receive token, if available 128 | if hasattr(self, "_basic_auth") and self._basic_auth: 129 | headers["Authorization"] = "Basic %s" % self._basic_auth 130 | logger.debug("Using Basic Auth for token request.") 131 | else: 132 | logger.debug( 133 | "No Basic Auth available or configured for token request. Proceeding without Basic Auth header for token endpoint." 134 | ) 135 | 136 | logger.debug( 137 | f"Requesting auth token for: {h} with header keys: {list(headers.keys())}" 138 | ) 139 | authResponse = self.session.get(h.realm, headers=headers, params=params, verify=self._tls_verify) # type: ignore 140 | 141 | if authResponse.status_code != 200: 142 | logger.debug(f"Auth response was not successful: {authResponse.text}") 143 | return 144 | 145 | # Request the token 146 | info = authResponse.json() 147 | return info.get("token") or info.get("access_token") 148 | 149 | def request_anonymous_token(self, h: auth_utils.authHeader) -> bool: 150 | """ 151 | Given no basic auth, fall back to trying to request an anonymous token. 152 | 153 | Returns: boolean if headers have been updated with token. 154 | """ 155 | if not h.realm: 156 | logger.debug("Request anonymous token: no realm provided, exiting early") 157 | return 158 | 159 | params = {} 160 | if h.service: 161 | params["service"] = h.service 162 | if h.scope: 163 | params["scope"] = h.scope 164 | 165 | logger.debug(f"Requesting anon token with params: {params}") 166 | response = self.session.request( 167 | "GET", h.realm, params=params, verify=self._tls_verify 168 | ) 169 | if response.status_code != 200: 170 | logger.debug(f"Response for anon token failed: {response.text}") 171 | return 172 | 173 | # From https://docs.docker.com/registry/spec/auth/token/ section 174 | # We can get token OR access_token OR both (when both they are identical) 175 | data = response.json() 176 | token = data.get("token") or data.get("access_token") 177 | 178 | # Update the headers but not self.token (expects Basic) 179 | if token: 180 | return token 181 | logger.debug("Warning: no token or access_token present in response.") 182 | -------------------------------------------------------------------------------- /oras/auth/utils.py: -------------------------------------------------------------------------------- 1 | __author__ = "Vanessa Sochat" 2 | __copyright__ = "Copyright The ORAS Authors." 3 | __license__ = "Apache-2.0" 4 | 5 | import base64 6 | import os 7 | import re 8 | from typing import List, Optional 9 | 10 | import oras.utils 11 | from oras.logger import logger 12 | 13 | 14 | def load_configs(configs: Optional[List[str]] = None): 15 | """ 16 | Load one or more configs with credentials from the filesystem. 17 | 18 | :param configs: list of configuration paths to load, defaults to None 19 | :type configs: optional list 20 | """ 21 | configs = configs or [] 22 | default_config = oras.utils.find_docker_config() 23 | 24 | # Add the default docker config 25 | if default_config: 26 | configs.append(default_config) 27 | configs = set(configs) # type: ignore 28 | 29 | # Load configs until we find our registry hostname 30 | auths = {} 31 | for config in configs: 32 | if not os.path.exists(config): 33 | logger.warning(f"{config} does not exist.") 34 | continue 35 | cfg = oras.utils.read_json(config) 36 | auths.update(cfg.get("auths", {})) 37 | 38 | return auths 39 | 40 | 41 | def get_basic_auth(username: str, password: str): 42 | """ 43 | Prepare basic auth from a username and password. 44 | 45 | :param username: the user account name 46 | :type username: str 47 | :param password: the user account password 48 | :type password: str 49 | """ 50 | auth_str = "%s:%s" % (username, password) 51 | return base64.b64encode(auth_str.encode("utf-8")).decode("utf-8") 52 | 53 | 54 | class authHeader: 55 | def __init__(self, lookup: dict): 56 | """ 57 | Given a dictionary of values, match them to class attributes 58 | 59 | :param lookup : dictionary of key,value pairs to parse into auth header 60 | :type lookup: dict 61 | """ 62 | self.service: Optional[str] = None 63 | self.realm: Optional[str] = None 64 | self.scope: Optional[str] = None 65 | for key in lookup: 66 | if key in ["realm", "service", "scope"]: 67 | setattr(self, key, lookup[key]) 68 | 69 | def __repr__(self): 70 | return f"authHeader(lookup={{'service': {repr(self.service)}, 'realm': {repr(self.realm)}, 'scope': {repr(self.scope)}}})" 71 | 72 | 73 | def parse_auth_header(authHeaderRaw: str) -> authHeader: 74 | """ 75 | Parse authentication header into pieces 76 | 77 | :param username: the user account name 78 | :type username: str 79 | :param password: the user account password 80 | :type password: str 81 | """ 82 | regex = re.compile('([a-zA-z]+)="(.+?)"') 83 | matches = regex.findall(authHeaderRaw) 84 | lookup = dict() 85 | for match in matches: 86 | lookup[match[0]] = match[1] 87 | return authHeader(lookup) 88 | -------------------------------------------------------------------------------- /oras/client.py: -------------------------------------------------------------------------------- 1 | __author__ = "Vanessa Sochat" 2 | __copyright__ = "Copyright The ORAS Authors." 3 | __license__ = "Apache-2.0" 4 | 5 | 6 | # Fallback support so OrasClient still works 7 | from .provider import Registry as OrasClient # noqa 8 | -------------------------------------------------------------------------------- /oras/container.py: -------------------------------------------------------------------------------- 1 | __author__ = "Vanessa Sochat" 2 | __copyright__ = "Copyright The ORAS Authors." 3 | __license__ = "Apache-2.0" 4 | 5 | 6 | import re 7 | from typing import Optional 8 | 9 | import oras.defaults 10 | 11 | docker_regex = re.compile( 12 | "(?:(?P[^/@]+[.:][^/@]*)/)?" 13 | "(?P(?:[^:@/]+/)+)?" 14 | "(?P[^:@/]+)" 15 | "(?::(?P[^:@]+))?" 16 | "(?:@(?P.+))?" 17 | "$" 18 | ) 19 | 20 | 21 | class Container: 22 | def __init__(self, name: str, registry: Optional[str] = None): 23 | """ 24 | Parse a container name and easily get urls for registry interactions. 25 | 26 | :param name: the full name of the container to parse (with any components) 27 | :type name: str 28 | :param registry: a custom registry name, if not provided with URI 29 | :type registry: str 30 | """ 31 | self.registry = registry or oras.defaults.registry.index_name 32 | 33 | # Registry is the name takes precendence 34 | self.parse(name) 35 | 36 | @property 37 | def api_prefix(self): 38 | """ 39 | Return the repository prefix for the v2 API endpoints. 40 | """ 41 | if self.namespace: 42 | return f"{self.namespace}/{self.repository}" 43 | return self.repository 44 | 45 | def get_blob_url(self, digest: str) -> str: 46 | """ 47 | Get the URL to download a blob 48 | 49 | :param digest: the digest to download 50 | :type digest: str 51 | """ 52 | return f"{self.registry}/v2/{self.api_prefix}/blobs/{digest}" 53 | 54 | def upload_blob_url(self) -> str: 55 | return f"{self.registry}/v2/{self.api_prefix}/blobs/uploads/" 56 | 57 | def tags_url(self, N=None) -> str: 58 | if N is None: 59 | return f"{self.registry}/v2/{self.api_prefix}/tags/list" 60 | return f"{self.registry}/v2/{self.api_prefix}/tags/list?n={N}" 61 | 62 | def manifest_url(self, tag: Optional[str] = None) -> str: 63 | """ 64 | Get the manifest url for a specific tag, or the one for this container. 65 | 66 | The tag provided can also correspond to a digest. 67 | 68 | :param tag: an optional tag to provide (if not provided defaults to container) 69 | :type tag: None or str 70 | """ 71 | 72 | # an explicitly defined tag has precedence over everything, 73 | # but from the already defined ones, prefer the digest for consistency. 74 | tag = tag or (self.digest or self.tag) 75 | return f"{self.registry}/v2/{self.api_prefix}/manifests/{tag}" 76 | 77 | def __str__(self) -> str: 78 | return self.uri 79 | 80 | @property 81 | def uri(self) -> str: 82 | """ 83 | Assemble the complete unique resource identifier 84 | """ 85 | if self.namespace: 86 | uri = f"{self.namespace}/{self.repository}" 87 | else: 88 | uri = f"{self.repository}" 89 | if self.registry: 90 | uri = f"{self.registry}/{uri}" 91 | 92 | # Digest takes preference because more specific 93 | if self.digest: 94 | uri = f"{uri}@{self.digest}" 95 | elif self.tag: 96 | uri = f"{uri}:{self.tag}" 97 | return uri 98 | 99 | def parse(self, name: str): 100 | """ 101 | Parse the container name into registry, repository, and tag. 102 | 103 | :param name: the full name of the container to parse (with any components) 104 | :type name: str 105 | """ 106 | match = re.search(docker_regex, name) 107 | if not match: 108 | raise ValueError( 109 | f"{name} does not match a recognized registry unique resource identifier. Try //:" 110 | ) 111 | items = match.groupdict() # type: ignore 112 | self.repository = items["repository"] 113 | self.registry = items["registry"] or self.registry 114 | self.namespace = items["namespace"] 115 | self.tag = items["tag"] or oras.defaults.default_tag 116 | self.digest = items["digest"] 117 | 118 | # Repository is required 119 | if not self.repository: 120 | raise ValueError( 121 | "You are minimally required to include a /" 122 | ) 123 | if self.namespace: 124 | self.namespace = self.namespace.strip("/") 125 | -------------------------------------------------------------------------------- /oras/decorator.py: -------------------------------------------------------------------------------- 1 | __author__ = "Vanessa Sochat" 2 | __copyright__ = "Copyright The ORAS Authors." 3 | __license__ = "Apache-2.0" 4 | 5 | import time 6 | from functools import wraps 7 | 8 | import requests.exceptions 9 | 10 | import oras.auth 11 | from oras.logger import logger 12 | 13 | 14 | def ensure_container(func): 15 | """ 16 | Ensure the first argument is a container, and not a string. 17 | """ 18 | 19 | @wraps(func) 20 | def wrapper(cls, *args, **kwargs): 21 | if "container" in kwargs: 22 | kwargs["container"] = cls.get_container(kwargs["container"]) 23 | elif args: 24 | container = cls.get_container(args[0]) 25 | args = (container, *args[1:]) 26 | return func(cls, *args, **kwargs) 27 | 28 | return wrapper 29 | 30 | 31 | def retry(attempts=5, timeout=2): 32 | """ 33 | A simple retry decorator 34 | """ 35 | 36 | def decorator(func): 37 | @wraps(func) 38 | def inner(*args, **kwargs): 39 | attempt = 0 40 | while attempt < attempts: 41 | try: 42 | res = func(*args, **kwargs) 43 | if res.status_code == 500: 44 | try: 45 | msg = res.json() 46 | for error in msg.get("errors", []): 47 | if isinstance(error, dict) and "message" in error: 48 | logger.error(error["message"]) 49 | except Exception: 50 | pass 51 | raise ValueError(f"Issue with {res.request.url}: {res.reason}") 52 | return res 53 | except oras.auth.AuthenticationException as e: 54 | raise e 55 | except requests.exceptions.SSLError: 56 | raise 57 | except Exception as e: 58 | sleep = timeout + 3**attempt 59 | logger.info(f"Retrying in {sleep} seconds - error: {e}") 60 | time.sleep(sleep) 61 | attempt += 1 62 | return func(*args, **kwargs) 63 | 64 | return inner 65 | 66 | return decorator 67 | -------------------------------------------------------------------------------- /oras/defaults.py: -------------------------------------------------------------------------------- 1 | __author__ = "Vanessa Sochat" 2 | __copyright__ = "Copyright The ORAS Authors" 3 | __license__ = "Apache-2.0" 4 | 5 | # Default tag to use 6 | default_tag = "latest" 7 | 8 | 9 | # https://github.com/moby/moby/blob/master/registry/config.go#L29 10 | class registry: 11 | index_hostname = "index.docker.io" 12 | index_server = "https://index.docker.io/v1/" 13 | index_name = "docker.io" 14 | default_v2_registry = {"scheme": "https", "host": "registry-1.docker.io"} 15 | 16 | 17 | # DefaultBlobDirMediaType specifies the default blob directory media type 18 | default_blob_dir_media_type = "application/vnd.oci.image.layer.v1.tar+gzip" 19 | 20 | # MediaTypeImageLayer is the media type used for layers referenced by the manifest. 21 | default_blob_media_type = "application/vnd.oci.image.layer.v1.tar" 22 | unknown_config_media_type = "application/vnd.unknown.config.v1+json" 23 | default_manifest_media_type = "application/vnd.oci.image.manifest.v1+json" 24 | 25 | # AnnotationDigest is the annotation key for the digest of the uncompressed content 26 | annotation_digest = "io.deis.oras.content.digest" 27 | 28 | # AnnotationTitle is the annotation key for the human-readable title of the image. 29 | annotation_title = "org.opencontainers.image.title" 30 | 31 | # AnnotationUnpack is the annotation key for indication of unpacking 32 | annotation_unpack = "io.deis.oras.content.unpack" 33 | 34 | # OCIImageIndexFile is the file name of the index from the OCI Image Layout Specification 35 | # Reference: https://github.com/opencontainers/image-spec/blob/master/image-layout.md#indexjson-file 36 | oci_image_index_file = "index.json" 37 | 38 | # DefaultBlocksize default size of each slice of bytes read in each write through in gunzipand untar. 39 | default_blocksize = 32768 40 | 41 | # DefaultChunkSize default size of each chunk when uploading chunked blobs. 42 | default_chunksize = 16777216 # 16MB 43 | 44 | # what you get for a blank digest, so we don't need to save and recalculate 45 | blank_hash = "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" 46 | 47 | # what you get for a blank config digest, so we don't need to save and recalculate 48 | blank_config_hash = ( 49 | "sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a" 50 | ) 51 | -------------------------------------------------------------------------------- /oras/logger.py: -------------------------------------------------------------------------------- 1 | __author__ = "Vanessa Sochat" 2 | __copyright__ = "Copyright The ORAS Authors." 3 | __license__ = "Apache-2.0" 4 | 5 | import inspect 6 | import logging as _logging 7 | import os 8 | import platform 9 | import sys 10 | import threading 11 | from pathlib import Path 12 | from typing import Optional, Text, TextIO, Union 13 | 14 | 15 | class ColorizingStreamHandler(_logging.StreamHandler): 16 | BLACK, RED, GREEN, YELLOW, BLUE, MAGENTA, CYAN, WHITE = range(8) 17 | RESET_SEQ = "\033[0m" 18 | COLOR_SEQ = "\033[%dm" 19 | BOLD_SEQ = "\033[1m" 20 | 21 | colors = { 22 | "WARNING": YELLOW, 23 | "INFO": GREEN, 24 | "DEBUG": BLUE, 25 | "CRITICAL": RED, 26 | "ERROR": RED, 27 | } 28 | 29 | def __init__( 30 | self, 31 | nocolor: bool = False, 32 | stream: Union[Text, Path, TextIO] = sys.stderr, 33 | use_threads: bool = False, 34 | ): 35 | """ 36 | Create a new ColorizingStreamHandler 37 | 38 | :param nocolor: do not use color 39 | :type nocolor: bool 40 | :param stream: stream list to this output 41 | :type stream: bool 42 | :param use_threads: use threads! lol 43 | :type use_threads: bool 44 | """ 45 | super().__init__(stream=stream) 46 | self._output_lock = threading.Lock() 47 | self.nocolor = nocolor or not self.can_color_tty() 48 | 49 | def can_color_tty(self) -> bool: 50 | """ 51 | Determine if the tty supports color 52 | """ 53 | if "TERM" in os.environ and os.environ["TERM"] == "dumb": 54 | return False 55 | return self.is_tty and not platform.system() == "Windows" 56 | 57 | @property 58 | def is_tty(self) -> bool: 59 | """ 60 | Determine if we have a tty environment 61 | """ 62 | isatty = getattr(self.stream, "isatty", None) 63 | return isatty and isatty() # type: ignore 64 | 65 | def emit(self, record: _logging.LogRecord): 66 | """ 67 | Emit a log record 68 | 69 | :param record: the record to emit 70 | :type record: logging.LogRecord 71 | """ 72 | with self._output_lock: 73 | try: 74 | self.format(record) # add the message to the record 75 | self.stream.write(self.decorate(record)) 76 | self.stream.write(getattr(self, "terminator", "\n")) 77 | self.flush() 78 | except BrokenPipeError as e: 79 | raise e 80 | except (KeyboardInterrupt, SystemExit): 81 | # ignore any exceptions in these cases as any relevant messages have been printed before 82 | pass 83 | except Exception: 84 | self.handleError(record) 85 | 86 | def decorate(self, record) -> str: 87 | """ 88 | Decorate a log record 89 | 90 | :param record: the record to emit 91 | """ 92 | message = record.message 93 | message = [message] 94 | if not self.nocolor and record.levelname in self.colors: 95 | message.insert(0, self.COLOR_SEQ % (30 + self.colors[record.levelname])) 96 | message.append(self.RESET_SEQ) 97 | return "".join(message) 98 | 99 | 100 | class Logger: 101 | def __init__(self): 102 | """ 103 | Create a new logger 104 | """ 105 | self.logger = _logging.getLogger(__name__) 106 | self.log_handler = [self.text_handler] 107 | self.stream_handler = None 108 | self.printshellcmds = False 109 | self.quiet = False 110 | self.logfile = None 111 | self.last_msg_was_job_info = False 112 | self.logfile_handler = None 113 | 114 | def cleanup(self): 115 | """ 116 | Close open files, etc. for the logger 117 | """ 118 | if self.logfile_handler is not None: 119 | self.logger.removeHandler(self.logfile_handler) 120 | self.logfile_handler.close() 121 | self.log_handler = [self.text_handler] 122 | 123 | def handler(self, msg: dict): 124 | """ 125 | Handle a log message. 126 | 127 | :param msg: the message to handle 128 | :type msg: dict 129 | """ 130 | for handler in self.log_handler: 131 | handler(msg) 132 | 133 | def set_stream_handler(self, stream_handler: _logging.Handler): 134 | """ 135 | Set a stream handler. 136 | 137 | :param stream_handler : the stream handler 138 | :type stream_handler: logging.Handler 139 | """ 140 | if self.stream_handler is not None: 141 | self.logger.removeHandler(self.stream_handler) 142 | self.stream_handler = stream_handler 143 | self.logger.addHandler(stream_handler) 144 | 145 | def set_level(self, level: int): 146 | """ 147 | Set the logging level. 148 | 149 | :param level: the logging level to set 150 | :type level: int 151 | """ 152 | self.logger.setLevel(level) 153 | 154 | def location(self, msg: str): 155 | """ 156 | Debug level message with location info. 157 | 158 | :param msg: the logging message 159 | :type msg: dict 160 | """ 161 | callerframerecord = inspect.stack()[1] 162 | frame = callerframerecord[0] 163 | info = inspect.getframeinfo(frame) 164 | self.debug( 165 | "{}: {info.filename}, {info.function}, {info.lineno}".format(msg, info=info) 166 | ) 167 | 168 | def info(self, msg: str): 169 | """ 170 | Info level message 171 | 172 | :param msg: the informational message 173 | :type msg: str 174 | """ 175 | self.handler({"level": "info", "msg": msg}) 176 | 177 | def warning(self, msg: str): 178 | """ 179 | Warning level message 180 | 181 | :param msg: the warning message 182 | :type msg: str 183 | """ 184 | self.handler({"level": "warning", "msg": msg}) 185 | 186 | def debug(self, msg: str): 187 | """ 188 | Debug level message 189 | 190 | :param msg: the debug message 191 | :type msg: str 192 | """ 193 | self.handler({"level": "debug", "msg": msg}) 194 | 195 | def error(self, msg: str): 196 | """ 197 | Error level message 198 | 199 | :param msg: the error message 200 | :type msg: str 201 | """ 202 | self.handler({"level": "error", "msg": msg}) 203 | 204 | def exit(self, msg: str, return_code: int = 1): 205 | """ 206 | Error level message and exit with error code 207 | 208 | :param msg: the exiting (error) message 209 | :type msg: str 210 | :param return_code: return code to exit on 211 | :type return_code: int 212 | """ 213 | self.handler({"level": "error", "msg": msg}) 214 | sys.exit(return_code) 215 | 216 | def progress(self, done: int, total: int): 217 | """ 218 | Show piece of a progress bar 219 | 220 | :param done: count of total that is complete 221 | :type done: int 222 | :param total: count of total 223 | :type total: int 224 | """ 225 | self.handler({"level": "progress", "done": done, "total": total}) 226 | 227 | def shellcmd(self, msg: Optional[str]): 228 | """ 229 | Shellcmd message 230 | 231 | :param msg: the message 232 | :type msg: str 233 | """ 234 | if msg is not None: 235 | self.handler({"level": "shellcmd", "msg": msg}) 236 | 237 | def text_handler(self, msg: dict): 238 | """ 239 | The default log handler that prints to the console. 240 | 241 | :param msg: the log message dict 242 | :type msg: dict 243 | """ 244 | level = msg["level"] 245 | if level == "info" and not self.quiet: 246 | self.logger.info(msg["msg"]) 247 | if level == "warning": 248 | self.logger.warning(msg["msg"]) 249 | elif level == "error": 250 | self.logger.error(msg["msg"]) 251 | elif level == "debug": 252 | self.logger.debug(msg["msg"]) 253 | elif level == "progress" and not self.quiet: 254 | done = msg["done"] 255 | total = msg["total"] 256 | p = done / total 257 | percent_fmt = ("{:.2%}" if p < 0.01 else "{:.0%}").format(p) 258 | self.logger.info( 259 | "{} of {} steps ({}) done".format(done, total, percent_fmt) 260 | ) 261 | elif level == "shellcmd": 262 | if self.printshellcmds: 263 | self.logger.warning(msg["msg"]) 264 | 265 | 266 | logger = Logger() 267 | 268 | 269 | def setup_logger( 270 | quiet: bool = False, 271 | printshellcmds: bool = False, 272 | nocolor: bool = False, 273 | stdout: bool = False, 274 | debug: bool = False, 275 | use_threads: bool = False, 276 | ): 277 | """ 278 | Setup the logger. This should be called from an init or client. 279 | 280 | :param quiet: set logging level to quiet 281 | :type quiet: bool 282 | :param printshellcmds: a special level to print shell commands 283 | :type printshellcmds: bool 284 | :param nocolor: do not use color 285 | :type nocolor: bool 286 | :param stdout: print to standard output for the logger 287 | :type stdout: bool 288 | :param debug: debug level logging 289 | :type debug: bool 290 | :param use_threads: use threads! 291 | :type use_threads: bool 292 | """ 293 | # console output only if no custom logger was specified 294 | stream_handler = ColorizingStreamHandler( 295 | nocolor=nocolor, 296 | stream=sys.stdout if stdout else sys.stderr, 297 | use_threads=use_threads, 298 | ) 299 | level = _logging.INFO 300 | if debug: 301 | level = _logging.DEBUG 302 | 303 | logger.set_stream_handler(stream_handler) 304 | logger.set_level(level) 305 | logger.quiet = quiet 306 | logger.printshellcmds = printshellcmds 307 | -------------------------------------------------------------------------------- /oras/main/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oras-project/oras-py/b63ede0287865d5884812312f7e90ca44724296f/oras/main/__init__.py -------------------------------------------------------------------------------- /oras/main/login.py: -------------------------------------------------------------------------------- 1 | __author__ = "Vanessa Sochat" 2 | __copyright__ = "Copyright The ORAS Authors." 3 | __license__ = "Apache-2.0" 4 | 5 | import os 6 | from typing import Optional 7 | 8 | import oras.auth.utils as auth_utils 9 | import oras.utils 10 | 11 | 12 | class DockerClient: 13 | """ 14 | If running inside a container (or similar without docker) do a manual login 15 | """ 16 | 17 | def login( 18 | self, 19 | username: str, 20 | password: str, 21 | registry: str, 22 | dockercfg_path: Optional[str] = None, 23 | ) -> dict: 24 | """ 25 | Manual login means loading and checking the config file 26 | 27 | :param registry: if provided, use this custom provider instead of default 28 | :type registry: oras.provider.Registry or None 29 | :param username: the user account name 30 | :type username: str 31 | :param password: the user account password 32 | :type password: str 33 | :param dockercfg_str: docker config path 34 | :type dockercfg_str: list 35 | """ 36 | if not dockercfg_path: 37 | dockercfg_path = oras.utils.find_docker_config(exists=False) 38 | if os.path.exists(dockercfg_path): # type: ignore 39 | cfg = oras.utils.read_json(dockercfg_path) # type: ignore 40 | else: 41 | oras.utils.mkdir_p(os.path.dirname(dockercfg_path)) # type: ignore 42 | cfg = {"auths": {}} 43 | if registry in cfg["auths"]: 44 | cfg["auths"][registry]["auth"] = auth_utils.get_basic_auth( 45 | username, password 46 | ) 47 | else: 48 | cfg["auths"][registry] = { 49 | "auth": auth_utils.get_basic_auth(username, password) 50 | } 51 | oras.utils.write_json(cfg, dockercfg_path) # type: ignore 52 | return {"Status": "Login Succeeded"} 53 | -------------------------------------------------------------------------------- /oras/oci.py: -------------------------------------------------------------------------------- 1 | __author__ = "Vanessa Sochat" 2 | __copyright__ = "Copyright The ORAS Authors." 3 | __license__ = "Apache-2.0" 4 | 5 | import copy 6 | import hashlib 7 | import json 8 | import os 9 | from dataclasses import dataclass 10 | from typing import Dict, Optional, Tuple 11 | 12 | import jsonschema 13 | 14 | import oras.defaults 15 | import oras.schemas 16 | import oras.utils 17 | 18 | EmptyManifest = { 19 | "schemaVersion": 2, 20 | "mediaType": oras.defaults.default_manifest_media_type, 21 | "config": {}, 22 | "layers": [], 23 | "annotations": {}, 24 | } 25 | 26 | 27 | class Annotations: 28 | """ 29 | Create a new set of annotations 30 | """ 31 | 32 | def __init__(self, filename=None): 33 | self.lookup = {} 34 | self.load(filename) 35 | 36 | def add(self, section, key, value): 37 | """ 38 | Add key/value pairs to a named section. 39 | """ 40 | if section not in self.lookup: 41 | self.lookup[section] = {} 42 | self.lookup[section][key] = value 43 | 44 | def load(self, filename: str): 45 | if filename and os.path.exists(filename): 46 | self.lookup = oras.utils.read_json(filename) 47 | if filename and not os.path.exists(filename): 48 | raise FileNotFoundError(f"Annotation file {filename} does not exist.") 49 | 50 | def get_annotations(self, section: str) -> dict: 51 | """ 52 | Given the name (a relative path or named section) get annotations 53 | """ 54 | for name in section, os.path.abspath(section): 55 | if name in self.lookup: 56 | return self.lookup[name] 57 | return {} 58 | 59 | 60 | class Layer: 61 | def __init__( 62 | self, blob_path: str, media_type: Optional[str] = None, is_dir: bool = False 63 | ): 64 | """ 65 | Create a new Layer 66 | 67 | :param blob_path: the path of the blob for the layer 68 | :type blob_path: str 69 | :param media_type: media type for the blob (optional) 70 | :type media_type: str 71 | :param is_dir: is the blob a directory? 72 | :type is_dir: bool 73 | """ 74 | self.blob_path = blob_path 75 | self.set_media_type(media_type, is_dir) 76 | 77 | def set_media_type(self, media_type: Optional[str] = None, is_dir: bool = False): 78 | """ 79 | Vary the media type to be directory or default layer 80 | 81 | :param media_type: media type for the blob (optional) 82 | :type media_type: str 83 | :param is_dir: is the blob a directory? 84 | :type is_dir: bool 85 | """ 86 | self.media_type = media_type 87 | if is_dir and not media_type: 88 | self.media_type = oras.defaults.default_blob_dir_media_type 89 | elif not is_dir and not media_type: 90 | self.media_type = oras.defaults.default_blob_media_type 91 | 92 | def to_dict(self): 93 | """ 94 | Return a dictionary representation of the layer 95 | """ 96 | layer = { 97 | "mediaType": self.media_type, 98 | "size": oras.utils.get_size(self.blob_path), 99 | "digest": "sha256:" + oras.utils.get_file_hash(self.blob_path), 100 | } 101 | jsonschema.validate(layer, schema=oras.schemas.layer) 102 | return layer 103 | 104 | 105 | def NewLayer( 106 | blob_path: str, media_type: Optional[str] = None, is_dir: bool = False 107 | ) -> dict: 108 | """ 109 | Courtesy function to create and retrieve a layer as dict 110 | 111 | :param blob_path: the path of the blob for the layer 112 | :type blob_path: str 113 | :param media_type: media type for the blob (optional) 114 | :type media_type: str 115 | :param is_dir: is the blob a directory? 116 | :type is_dir: bool 117 | """ 118 | return Layer(blob_path=blob_path, media_type=media_type, is_dir=is_dir).to_dict() 119 | 120 | 121 | def ManifestConfig( 122 | path: Optional[str] = None, media_type: Optional[str] = None 123 | ) -> Tuple[Dict[str, object], Optional[str]]: 124 | """ 125 | Write an empty config, if one is not provided 126 | 127 | :param path: the path of the manifest config, if exists. 128 | :type path: str 129 | :param media_type: media type for the manifest config (optional) 130 | :type media_type: str 131 | """ 132 | # Create an empty config if we don't have one 133 | if not path or not os.path.exists(path): 134 | path = None 135 | conf = { 136 | "mediaType": media_type or oras.defaults.unknown_config_media_type, 137 | "size": 2, 138 | "digest": oras.defaults.blank_config_hash, 139 | } 140 | 141 | else: 142 | conf = { 143 | "mediaType": media_type or oras.defaults.unknown_config_media_type, 144 | "size": oras.utils.get_size(path), 145 | "digest": "sha256:" + oras.utils.get_file_hash(path), 146 | } 147 | 148 | jsonschema.validate(conf, schema=oras.schemas.layer) 149 | return conf, path 150 | 151 | 152 | def NewManifest() -> dict: 153 | """ 154 | Get an empty manifest config. 155 | """ 156 | return copy.deepcopy(EmptyManifest) 157 | 158 | 159 | @dataclass 160 | class Subject: 161 | mediaType: str 162 | digest: str 163 | size: int 164 | 165 | @classmethod 166 | def from_manifest(cls, manifest: dict) -> "Subject": 167 | """ 168 | Create a new Subject from a Manifest 169 | 170 | :param manifest: manifest to convert to subject 171 | """ 172 | manifest_string = json.dumps(manifest).encode("utf-8") 173 | digest = "sha256:" + hashlib.sha256(manifest_string).hexdigest() 174 | size = len(manifest_string) 175 | 176 | return cls( 177 | manifest["mediaType"] or oras.defaults.default_manifest_media_type, 178 | digest, 179 | size, 180 | ) 181 | -------------------------------------------------------------------------------- /oras/schemas.py: -------------------------------------------------------------------------------- 1 | __author__ = "Vanessa Sochat" 2 | __copyright__ = "Copyright The ORAS Authors." 3 | __license__ = "Apache-2.0" 4 | 5 | ## Manifest and Layer schemas 6 | 7 | schema_url = "http://json-schema.org/draft-07/schema" 8 | 9 | # Layer 10 | 11 | layerProperties = { 12 | "type": "object", 13 | "properties": { 14 | "mediaType": {"type": "string"}, 15 | "size": {"type": "number"}, 16 | "digest": {"type": "string"}, 17 | "annotations": {"type": ["object", "null", "array"]}, 18 | }, 19 | } 20 | 21 | layer = { 22 | "$schema": schema_url, 23 | "title": "Layer Schema", 24 | "required": [ 25 | "mediaType", 26 | "size", 27 | "digest", 28 | ], 29 | "additionalProperties": True, 30 | } 31 | 32 | layer.update(layerProperties) 33 | 34 | 35 | # Manifest 36 | 37 | manifestProperties = { 38 | "schemaVersion": {"type": "number"}, 39 | "subject": {"type": ["null", "object"]}, 40 | "mediaType": {"type": "string"}, 41 | "layers": {"type": "array", "items": layerProperties}, 42 | "config": layerProperties, 43 | "annotations": {"type": ["object", "null", "array"]}, 44 | } 45 | 46 | 47 | manifest = { 48 | "$schema": schema_url, 49 | "title": "Manifest Schema", 50 | "type": "object", 51 | "required": [ 52 | "schemaVersion", 53 | "config", 54 | "layers", 55 | ], 56 | "properties": manifestProperties, 57 | "additionalProperties": True, 58 | } 59 | -------------------------------------------------------------------------------- /oras/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oras-project/oras-py/b63ede0287865d5884812312f7e90ca44724296f/oras/tests/__init__.py -------------------------------------------------------------------------------- /oras/tests/annotations.json: -------------------------------------------------------------------------------- 1 | { 2 | "$manifest": { 3 | "holiday": "Christmas", 4 | "candy": "candy-corn" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /oras/tests/artifact.txt: -------------------------------------------------------------------------------- 1 | hello dinosaur 2 | -------------------------------------------------------------------------------- /oras/tests/conftest.py: -------------------------------------------------------------------------------- 1 | import os 2 | from dataclasses import dataclass 3 | 4 | import pytest 5 | 6 | 7 | @dataclass 8 | class TestCredentials: 9 | with_auth: bool 10 | user: str 11 | password: str 12 | 13 | 14 | @pytest.fixture 15 | def registry(): 16 | host = os.environ.get("ORAS_HOST") 17 | port = os.environ.get("ORAS_PORT") 18 | 19 | if not host or not port: 20 | pytest.skip( 21 | "You must export ORAS_HOST and ORAS_PORT" 22 | " for a running registry before running tests." 23 | ) 24 | 25 | return f"{host}:{port}" 26 | 27 | 28 | @pytest.fixture 29 | def credentials(request): 30 | with_auth = os.environ.get("ORAS_AUTH") == "true" 31 | user = os.environ.get("ORAS_USER", "myuser") 32 | pwd = os.environ.get("ORAS_PASS", "mypass") 33 | 34 | if with_auth and not user or not pwd: 35 | pytest.skip("To test auth you need to export ORAS_USER and ORAS_PASS") 36 | 37 | marks = [m.name for m in request.node.iter_markers()] 38 | if request.node.parent: 39 | marks += [m.name for m in request.node.parent.iter_markers()] 40 | 41 | if request.node.get_closest_marker("with_auth"): 42 | if request.node.get_closest_marker("with_auth").args[0] != with_auth: 43 | if with_auth: 44 | pytest.skip("test requires un-authenticated access to registry") 45 | else: 46 | pytest.skip("test requires authenticated access to registry") 47 | 48 | return TestCredentials(with_auth, user, pwd) 49 | 50 | 51 | @pytest.fixture 52 | def target(registry): 53 | return f"{registry}/dinosaur/artifact:v1" 54 | 55 | 56 | @pytest.fixture 57 | def target_dir(registry): 58 | return f"{registry}/dinosaur/directory:v1" 59 | -------------------------------------------------------------------------------- /oras/tests/run_registry.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # A helper script to easily run a development, local registry 4 | docker run -it -e REGISTRY_STORAGE_DELETE_ENABLED=true --rm -p 5000:5000 ghcr.io/oras-project/registry:latest 5 | -------------------------------------------------------------------------------- /oras/tests/snakeoil.crt: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIDATCCAemgAwIBAgIUVD3r8T8WSZDh9kfPNus7RgWf+mIwDQYJKoZIhvcNAQEL 3 | BQAwFzEVMBMGA1UEAwwMeWQtb3I2MjkyNDkyMB4XDTI1MDEwNzE5MzgyN1oXDTM1 4 | MDEwNTE5MzgyN1owFzEVMBMGA1UEAwwMeWQtb3I2MjkyNDkyMIIBIjANBgkqhkiG 5 | 9w0BAQEFAAOCAQ8AMIIBCgKCAQEAoiQ4lEoFDmoCFCLWH9gRyebWlnv15OPdykwV 6 | N2P7nLf+fFFOyQgE59jc2gVw9TeOVmnXxncDOYQrLKikHko8vGKg+daT1DLIufjz 7 | ijOtdUz9Zgd0rt5Ar7ocnjxkOYJRSW9cBd51O6jpUGN3r1BziQMdvIywDSw+BX2F 8 | IMLvg2tlHuMb1dR0GHemwqwwdzAsw9QJB3MbGSfVsH4/QQxRsQhpq/5D3WyZJPlJ 9 | /tYmJhUPVVF94ZLAhqfDueWj+6LaiDUVLQ0CUml8S33Q9570e8JvAYYHwveRIkw/ 10 | W7lVoKu0OnkCKrKWjqgweBfszVz6ARTd8QPCCZO6At99RLjZBwIDAQABo0UwQzAJ 11 | BgNVHRMEAjAAMBcGA1UdEQQQMA6CDHlkLW9yNjI5MjQ5MjAdBgNVHQ4EFgQUSNhV 12 | pFANLtcSQsnuT4qrZ7TdADUwDQYJKoZIhvcNAQELBQADggEBABNaYMotGee4GT4J 13 | 6lw6eRq/Zzy6xN1n0iSJW4QJvPkbOoFa+CBRtQ/Vk5yiKOB3yX7SOdilm5yzQbBd 14 | QKsYgEhkPi8F/LN3UYDgE69soIBJv9Fx/EcmbsqLZqz60xSuwtVcqtJ9MuewZhZm 15 | 5ny2zbCNQxK6JehscFKVeR/W088jfWmtp4kIQIdK9h4301dAeNu8Si35HGtQupIw 16 | +jliOQtOFu3nikbBH+k8wwi4/2tMtEAsKbr83ixfkjkkivT7B5A9NNHNpNTzYw/T 17 | HGQaoWWIMf6Zcea33lM087raIC83qU/CuJYiNVvUkw7CnV2P/yg/FpCxsP0AudGC 18 | 4FLB77A= 19 | -----END CERTIFICATE----- 20 | -------------------------------------------------------------------------------- /oras/tests/test_oci.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | import oras.defaults 4 | import oras.oci 5 | 6 | 7 | @pytest.mark.with_auth(False) 8 | def test_create_subject_from_manifest(): 9 | """ 10 | Basic tests for oras Subject creation from empty manifest 11 | """ 12 | manifest = oras.oci.NewManifest() 13 | subject = oras.oci.Subject.from_manifest(manifest) 14 | 15 | assert subject.mediaType == oras.defaults.default_manifest_media_type 16 | assert ( 17 | subject.digest 18 | == "sha256:7a6f84d8c73a71bf9417c13f721ed102f74afac9e481f89e5a72d28954e7d0c5" 19 | ) 20 | assert subject.size == 126 21 | -------------------------------------------------------------------------------- /oras/tests/test_oras.py: -------------------------------------------------------------------------------- 1 | __author__ = "Vanessa Sochat" 2 | __copyright__ = "Copyright The ORAS Authors." 3 | __license__ = "Apache-2.0" 4 | 5 | import os 6 | import shutil 7 | import time 8 | from pathlib import Path 9 | 10 | import pytest 11 | from requests.exceptions import SSLError 12 | 13 | import oras.client 14 | 15 | here = os.path.abspath(os.path.dirname(__file__)) 16 | 17 | 18 | def test_basic_oras(registry): 19 | """ 20 | Basic tests for oras (without authentication) 21 | """ 22 | client = oras.client.OrasClient(hostname=registry, insecure=True) 23 | assert "Python version" in client.version() 24 | 25 | 26 | @pytest.mark.with_auth(True) 27 | def test_login_logout(registry, credentials): 28 | """ 29 | Login and logout are all we can test with basic auth! 30 | """ 31 | client = oras.client.OrasClient(hostname=registry, tls_verify=False) 32 | res = client.login( 33 | hostname=registry, 34 | tls_verify=False, 35 | username=credentials.user, 36 | password=credentials.password, 37 | ) 38 | assert res["Status"] == "Login Succeeded" 39 | client.logout(registry) 40 | 41 | 42 | @pytest.mark.with_auth(False) 43 | def test_basic_push_pull(tmp_path, registry, credentials, target): 44 | """ 45 | Basic tests for oras (without authentication) 46 | """ 47 | client = oras.client.OrasClient(hostname=registry, insecure=True) 48 | artifact = os.path.join(here, "artifact.txt") 49 | 50 | assert os.path.exists(artifact) 51 | 52 | res = client.push(files=[artifact], target=target) 53 | assert res.status_code in [200, 201] 54 | 55 | # Test pulling elsewhere 56 | files = client.pull(target=target, outdir=tmp_path) 57 | assert len(files) == 1 58 | assert os.path.basename(files[0]) == "artifact.txt" 59 | assert str(tmp_path) in files[0] 60 | assert os.path.exists(files[0]) 61 | 62 | # Move artifact outside of context (should not work) 63 | moved_artifact = tmp_path / os.path.basename(artifact) 64 | shutil.copyfile(artifact, moved_artifact) 65 | with pytest.raises(ValueError): 66 | client.push(files=[moved_artifact], target=target) 67 | 68 | # This should work because we aren't checking paths 69 | res = client.push(files=[artifact], target=target, disable_path_validation=True) 70 | assert res.status_code == 201 71 | 72 | 73 | @pytest.mark.with_auth(False) 74 | def test_basic_push_pul_via_sha_ref(tmp_path, registry, credentials, target): 75 | """ 76 | Basic tests for oras pushing and then pulling with SHA reference 77 | """ 78 | client = oras.client.OrasClient(hostname=registry, insecure=True) 79 | artifact = os.path.join(here, "artifact.txt") 80 | 81 | assert os.path.exists(artifact) 82 | 83 | res = client.push(files=[artifact], target=target) 84 | assert res.status_code in [200, 201] 85 | 86 | # Test pulling elsewhere 87 | using_ref = f"{registry}/dinosaur/artifact@{res.headers['Docker-Content-Digest']}" 88 | files = client.pull(target=using_ref, outdir=tmp_path) 89 | assert len(files) == 1 90 | assert os.path.basename(files[0]) == "artifact.txt" 91 | assert str(tmp_path) in files[0] 92 | assert os.path.exists(files[0]) 93 | 94 | 95 | @pytest.mark.with_auth(False) 96 | def test_get_delete_tags(tmp_path, registry, credentials, target): 97 | """ 98 | Test creationg, getting, and deleting tags. 99 | """ 100 | client = oras.client.OrasClient(hostname=registry, insecure=True) 101 | artifact = os.path.join(here, "artifact.txt") 102 | assert os.path.exists(artifact) 103 | 104 | res = client.push(files=[artifact], target=target) 105 | assert res.status_code in [200, 201] 106 | 107 | # Test getting tags 108 | tags = client.get_tags(target) 109 | assert "v1" in tags 110 | 111 | # Test deleting not-existence tag 112 | assert not client.delete_tags(target, "v1-boop-boop") 113 | 114 | assert "v1" in client.delete_tags(target, "v1") 115 | tags = client.get_tags(target) 116 | assert not tags 117 | 118 | 119 | def test_get_many_tags(): 120 | """ 121 | Test getting many tags 122 | """ 123 | client = oras.client.OrasClient(hostname="ghcr.io", insecure=False) 124 | 125 | # Test getting tags with a limit set 126 | tags = client.get_tags( 127 | "channel-mirrors/conda-forge/linux-aarch64/arrow-cpp", N=1005 128 | ) 129 | assert len(tags) == 1005 130 | 131 | # This should retrieve all tags (defaults to None) 132 | tags = client.get_tags("channel-mirrors/conda-forge/linux-aarch64/arrow-cpp") 133 | assert len(tags) > 1500 134 | 135 | # Same result if explicitly set 136 | same_tags = client.get_tags( 137 | "channel-mirrors/conda-forge/linux-aarch64/arrow-cpp", N=None 138 | ) 139 | assert not set(tags).difference(set(same_tags)) 140 | 141 | # Small number of tags 142 | tags = client.get_tags("channel-mirrors/conda-forge/linux-aarch64/arrow-cpp", N=10) 143 | assert not set(tags).difference(set(same_tags)) 144 | assert len(tags) == 10 145 | 146 | 147 | @pytest.mark.with_auth(False) 148 | def test_directory_push_pull(tmp_path, registry, credentials, target_dir): 149 | """ 150 | Test push and pull for directory 151 | """ 152 | client = oras.client.OrasClient(hostname=registry, insecure=True) 153 | 154 | # Test upload of a directory 155 | upload_dir = os.path.join(here, "upload_data") 156 | res = client.push(files=[upload_dir], target=target_dir) 157 | assert res.status_code == 201 158 | files = client.pull(target=target_dir, outdir=tmp_path) 159 | 160 | assert len(files) == 1 161 | assert os.path.basename(files[0]) == "upload_data" 162 | assert str(tmp_path) in files[0] 163 | assert os.path.exists(files[0]) 164 | assert "artifact.txt" in os.listdir(files[0]) 165 | 166 | 167 | @pytest.mark.with_auth(True) 168 | def test_directory_push_pull_selfsigned_auth( 169 | tmp_path, registry, credentials, target_dir 170 | ): 171 | """ 172 | Test push and pull for directory using a self-signed cert registry (`tls_verify=False`) and basic auth (`auth_backend="basic"`) 173 | """ 174 | client = oras.client.OrasClient( 175 | hostname=registry, tls_verify=False, auth_backend="basic" 176 | ) 177 | res = client.login( 178 | hostname=registry, 179 | tls_verify=False, 180 | username=credentials.user, 181 | password=credentials.password, 182 | ) 183 | assert res["Status"] == "Login Succeeded" 184 | 185 | # Test upload of a directory 186 | upload_dir = os.path.join(here, "upload_data") 187 | res = client.push(files=[upload_dir], target=target_dir) 188 | assert res.status_code == 201 189 | files = client.pull(target=target_dir, outdir=tmp_path) 190 | 191 | assert len(files) == 1 192 | assert os.path.basename(files[0]) == "upload_data" 193 | assert str(tmp_path) in files[0] 194 | assert os.path.exists(files[0]) 195 | assert "artifact.txt" in os.listdir(files[0]) 196 | 197 | 198 | @pytest.mark.with_auth(True) 199 | def test_custom_docker_config_path(tmp_path, registry, credentials, target_dir): 200 | """ 201 | Custom docker config_path for login, push, pull 202 | """ 203 | my_dockercfg_path = tmp_path / "myconfig.json" 204 | client = oras.client.OrasClient( 205 | hostname=registry, tls_verify=False, auth_backend="basic" 206 | ) 207 | res = client.login( 208 | hostname=registry, 209 | tls_verify=False, 210 | username=credentials.user, 211 | password=credentials.password, 212 | config_path=my_dockercfg_path, # <-- for login 213 | ) 214 | assert res["Status"] == "Login Succeeded" 215 | 216 | # Test push/pull with custom docker config_path 217 | upload_dir = os.path.join(here, "upload_data") 218 | res = client.push( 219 | files=[upload_dir], target=target_dir, config_path=my_dockercfg_path 220 | ) 221 | assert res.status_code == 201 222 | 223 | files = client.pull( 224 | target=target_dir, outdir=tmp_path, config_path=my_dockercfg_path 225 | ) 226 | assert len(files) == 1 227 | assert os.path.basename(files[0]) == "upload_data" 228 | assert str(tmp_path) in files[0] 229 | assert os.path.exists(files[0]) 230 | assert "artifact.txt" in os.listdir(files[0]) 231 | 232 | client.logout(registry) 233 | 234 | 235 | @pytest.fixture 236 | def empty_request_ca(): 237 | old_ca = os.environ.get("REQUESTS_CA_BUNDLE", None) 238 | try: 239 | # we're setting a fake CA since an empty one won't work 240 | os.environ["REQUESTS_CA_BUNDLE"] = str(Path(__file__).parent / "snakeoil.crt") 241 | yield 242 | finally: 243 | if old_ca is not None: 244 | os.environ["REQUESTS_CA_BUNDLE"] = old_ca 245 | else: 246 | del os.environ["REQUESTS_CA_BUNDLE"] 247 | 248 | 249 | def test_ssl_no_verify(empty_request_ca): 250 | """ 251 | Make sure the client works without a CA file and tls_verify set to False 252 | """ 253 | client = oras.client.OrasClient( 254 | hostname="ghcr.io", insecure=False, tls_verify=False 255 | ) 256 | client.get_tags("channel-mirrors/conda-forge/linux-aarch64/arrow-cpp", N=1) 257 | 258 | 259 | def test_ssl_verify_fails_if_bad_ca(empty_request_ca): 260 | """ 261 | Make sure the client fails without a CA file and tls_verify set to True 262 | """ 263 | client = oras.client.OrasClient(hostname="ghcr.io", insecure=False, tls_verify=True) 264 | 265 | with pytest.raises(SSLError): 266 | client.get_tags("channel-mirrors/conda-forge/linux-aarch64/arrow-cpp", N=1) 267 | 268 | 269 | def test_ssl_verify_fails_fast_if_bad_ca(empty_request_ca): 270 | """ 271 | The client should fail fast in case of SSL errors 272 | """ 273 | client = oras.client.OrasClient(hostname="ghcr.io", insecure=False, tls_verify=True) 274 | st = time.monotonic() 275 | with pytest.raises(SSLError): 276 | client.get_tags("channel-mirrors/conda-forge/linux-aarch64/arrow-cpp", N=1) 277 | et = time.monotonic() 278 | assert et - st < 5 279 | -------------------------------------------------------------------------------- /oras/tests/test_provider.py: -------------------------------------------------------------------------------- 1 | __author__ = "Vanessa Sochat" 2 | __copyright__ = "Copyright The ORAS Authors." 3 | __license__ = "Apache-2.0" 4 | 5 | import os 6 | import subprocess 7 | from pathlib import Path 8 | 9 | import pytest 10 | 11 | import oras.client 12 | import oras.defaults 13 | import oras.oci 14 | import oras.provider 15 | import oras.utils 16 | 17 | here = Path(__file__).resolve().parent 18 | 19 | 20 | @pytest.mark.with_auth(False) 21 | def test_annotated_registry_push(tmp_path, registry, credentials, target): 22 | """ 23 | Basic tests for oras push with annotations 24 | """ 25 | 26 | # Direct access to registry functions 27 | remote = oras.provider.Registry(hostname=registry, insecure=True) 28 | client = oras.client.OrasClient(hostname=registry, insecure=True) 29 | artifact = os.path.join(here, "artifact.txt") 30 | 31 | assert os.path.exists(artifact) 32 | 33 | # Custom manifest annotations 34 | annots = {"holiday": "Halloween", "candy": "chocolate"} 35 | res = client.push(files=[artifact], target=target, manifest_annotations=annots) 36 | assert res.status_code in [200, 201] 37 | 38 | # Get the manifest 39 | manifest = remote.get_manifest(target) 40 | assert "annotations" in manifest 41 | for k, v in annots.items(): 42 | assert k in manifest["annotations"] 43 | assert manifest["annotations"][k] == v 44 | 45 | # Annotations from file with $manifest 46 | annotation_file = os.path.join(here, "annotations.json") 47 | file_annots = oras.utils.read_json(annotation_file) 48 | assert "$manifest" in file_annots 49 | res = client.push(files=[artifact], target=target, annotation_file=annotation_file) 50 | assert res.status_code in [200, 201] 51 | manifest = remote.get_manifest(target) 52 | 53 | assert "annotations" in manifest 54 | for k, v in file_annots["$manifest"].items(): 55 | assert k in manifest["annotations"] 56 | assert manifest["annotations"][k] == v 57 | 58 | # File that doesn't exist 59 | annotation_file = os.path.join(here, "annotations-nope.json") 60 | with pytest.raises(FileNotFoundError): 61 | res = client.push( 62 | files=[artifact], target=target, annotation_file=annotation_file 63 | ) 64 | 65 | 66 | @pytest.mark.with_auth(False) 67 | def test_file_contains_column(tmp_path, registry, credentials, target): 68 | """ 69 | Test for file containing column symbol 70 | """ 71 | client = oras.client.OrasClient(hostname=registry, insecure=True) 72 | artifact = os.path.join(here, "artifact.txt") 73 | assert os.path.exists(artifact) 74 | 75 | # file containing `:` 76 | try: 77 | contains_column = here / "some:file" 78 | with open(contains_column, "w") as f: 79 | f.write("hello world some:file") 80 | 81 | res = client.push(files=[contains_column], target=target) 82 | assert res.status_code in [200, 201] 83 | 84 | files = client.pull(target, outdir=tmp_path / "download") 85 | download = str(tmp_path / "download/some:file") 86 | assert download in files 87 | assert oras.utils.get_file_hash( 88 | str(contains_column) 89 | ) == oras.utils.get_file_hash(download) 90 | finally: 91 | contains_column.unlink() 92 | 93 | # file containing `:` as prefix, pushed with type 94 | try: 95 | contains_column = here / ":somefile" 96 | with open(contains_column, "w") as f: 97 | f.write("hello world :somefile") 98 | 99 | res = client.push(files=[f"{contains_column}:text/plain"], target=target) 100 | assert res.status_code in [200, 201] 101 | 102 | files = client.pull(target, outdir=tmp_path / "download") 103 | download = str(tmp_path / "download/:somefile") 104 | assert download in files 105 | assert oras.utils.get_file_hash( 106 | str(contains_column) 107 | ) == oras.utils.get_file_hash(download) 108 | finally: 109 | contains_column.unlink() 110 | 111 | # error: file does not exist 112 | with pytest.raises(FileNotFoundError): 113 | client.push(files=[".doesnotexist"], target=target) 114 | 115 | with pytest.raises(FileNotFoundError): 116 | client.push(files=[":doesnotexist"], target=target) 117 | 118 | with pytest.raises(FileNotFoundError, match=r".*does:not:exists .*"): 119 | client.push(files=["does:not:exists:text/plain"], target=target) 120 | 121 | with pytest.raises(FileNotFoundError, match=r".*does:not:exists .*"): 122 | client.push(files=["does:not:exists:text/plain+ext"], target=target) 123 | 124 | 125 | @pytest.mark.with_auth(False) 126 | def test_chunked_push(tmp_path, registry, credentials, target): 127 | """ 128 | Basic tests for oras chunked push 129 | """ 130 | # Direct access to registry functions 131 | client = oras.client.OrasClient(hostname=registry, insecure=True) 132 | artifact = os.path.join(here, "artifact.txt") 133 | 134 | assert os.path.exists(artifact) 135 | 136 | res = client.push(files=[artifact], target=target, do_chunked=True) 137 | assert res.status_code in [200, 201, 202] 138 | 139 | files = client.pull(target, outdir=tmp_path) 140 | assert str(tmp_path / "artifact.txt") in files 141 | assert oras.utils.get_file_hash(artifact) == oras.utils.get_file_hash(files[0]) 142 | 143 | # large file upload 144 | base_size = oras.defaults.default_chunksize * 1024 # 16GB 145 | tmp_chunked = here / "chunked" 146 | try: 147 | subprocess.run( 148 | [ 149 | "dd", 150 | "if=/dev/null", 151 | f"of={tmp_chunked}", 152 | "bs=1", 153 | "count=0", 154 | f"seek={base_size}", 155 | ], 156 | ) 157 | 158 | res = client.push( 159 | files=[tmp_chunked], 160 | target=target, 161 | do_chunked=True, 162 | ) 163 | assert res.status_code in [200, 201, 202] 164 | 165 | files = client.pull(target, outdir=tmp_path / "download") 166 | download = str(tmp_path / "download/chunked") 167 | assert download in files 168 | assert oras.utils.get_file_hash(str(tmp_chunked)) == oras.utils.get_file_hash( 169 | download 170 | ) 171 | finally: 172 | tmp_chunked.unlink() 173 | 174 | # File that doesn't exist 175 | with pytest.raises(FileNotFoundError): 176 | res = client.push(files=[tmp_path / "none"], target=target) 177 | 178 | 179 | def test_parse_manifest(registry): 180 | """ 181 | Test parse manifest function. 182 | 183 | Parse manifest function has additional logic for Windows - this isn't included in 184 | these tests as they don't usually run on Windows. 185 | """ 186 | testref = "path/to/config:application/vnd.oci.image.config.v1+json" 187 | remote = oras.provider.Registry(hostname=registry, insecure=True) 188 | ref, content_type = remote._parse_manifest_ref(testref) 189 | assert ref == "path/to/config" 190 | assert content_type == "application/vnd.oci.image.config.v1+json" 191 | 192 | testref = "/dev/null:application/vnd.oci.image.manifest.v1+json" 193 | ref, content_type = remote._parse_manifest_ref(testref) 194 | assert ref == "/dev/null" 195 | assert content_type == "application/vnd.oci.image.manifest.v1+json" 196 | 197 | testref = "/dev/null" 198 | ref, content_type = remote._parse_manifest_ref(testref) 199 | assert ref == "/dev/null" 200 | assert content_type == oras.defaults.unknown_config_media_type 201 | 202 | testref = "path/to/config.json" 203 | ref, content_type = remote._parse_manifest_ref(testref) 204 | assert ref == "path/to/config.json" 205 | assert content_type == oras.defaults.unknown_config_media_type 206 | 207 | 208 | def test_sanitize_path(): 209 | HOME_DIR = str(Path.home()) 210 | assert str(oras.utils.sanitize_path(HOME_DIR, HOME_DIR)) == f"{HOME_DIR}" 211 | assert ( 212 | str(oras.utils.sanitize_path(HOME_DIR, os.path.join(HOME_DIR, "username"))) 213 | == f"{HOME_DIR}/username" 214 | ) 215 | assert ( 216 | str(oras.utils.sanitize_path(HOME_DIR, os.path.join(HOME_DIR, ".", "username"))) 217 | == f"{HOME_DIR}/username" 218 | ) 219 | 220 | with pytest.raises(Exception) as e: 221 | assert oras.utils.sanitize_path(HOME_DIR, os.path.join(HOME_DIR, "..")) 222 | assert ( 223 | str(e.value) 224 | == f"Filename {Path(os.path.join(HOME_DIR, '..')).resolve()} is not in {HOME_DIR} directory" 225 | ) 226 | 227 | assert oras.utils.sanitize_path("", "") == str(Path(".").resolve()) 228 | assert oras.utils.sanitize_path("/opt", os.path.join("/opt", "image_name")) == str( 229 | Path("/opt/image_name").resolve() 230 | ) 231 | assert oras.utils.sanitize_path("/../../", "/") == str(Path("/").resolve()) 232 | assert oras.utils.sanitize_path( 233 | Path(os.getcwd()).parent.absolute(), os.path.join(os.getcwd(), "..") 234 | ) == str(Path("..").resolve()) 235 | 236 | with pytest.raises(Exception) as e: 237 | assert oras.utils.sanitize_path( 238 | Path(os.getcwd()).parent.absolute(), os.path.join(os.getcwd(), "..", "..") 239 | ) != str(Path("../..").resolve()) 240 | assert ( 241 | str(e.value) 242 | == f"Filename {Path(os.path.join(os.getcwd(), '..', '..')).resolve()} is not in {Path('../').resolve()} directory" 243 | ) 244 | -------------------------------------------------------------------------------- /oras/tests/test_utils.py: -------------------------------------------------------------------------------- 1 | __author__ = "Vanessa Sochat" 2 | __copyright__ = "Copyright The ORAS Authors." 3 | __license__ = "Apache-2.0" 4 | 5 | import json 6 | import os 7 | import pathlib 8 | import shutil 9 | 10 | import pytest 11 | 12 | import oras.utils as utils 13 | 14 | 15 | def test_write_read_files(tmp_path): 16 | print("Testing utils.write_file...") 17 | 18 | tmpfile = str(tmp_path / "written_file.txt") 19 | assert not os.path.exists(tmpfile) 20 | utils.write_file(tmpfile, "hello!") 21 | assert os.path.exists(tmpfile) 22 | 23 | print("Testing utils.read_file...") 24 | 25 | content = utils.read_file(tmpfile) 26 | assert content == "hello!" 27 | 28 | 29 | def test_workdir(tmp_path): 30 | print("Testing utils.workdir") 31 | noodle_base = os.path.join(tmp_path, "noodles") 32 | os.makedirs(noodle_base) 33 | pathlib.Path(os.path.join(noodle_base, "pasta.txt")).touch() 34 | assert "pasta.txt" not in os.listdir() 35 | with utils.workdir(noodle_base): 36 | assert "pasta.txt" in os.listdir() 37 | 38 | 39 | def test_write_bad_json(tmp_path): 40 | bad_json = {"Wakkawakkawakka'}": [{True}, "2", 3]} 41 | tmpfile = str(tmp_path / "json_file.txt") 42 | assert not os.path.exists(tmpfile) 43 | with pytest.raises(TypeError): 44 | utils.write_json(bad_json, tmpfile) 45 | 46 | 47 | def test_write_json(tmp_path): 48 | good_json = {"Wakkawakkawakka": [True, "2", 3]} 49 | tmpfile = str(tmp_path / "good_json_file.txt") 50 | 51 | assert not os.path.exists(tmpfile) 52 | utils.write_json(good_json, tmpfile) 53 | with open(tmpfile, "r") as f: 54 | content = json.loads(f.read()) 55 | assert isinstance(content, dict) 56 | assert "Wakkawakkawakka" in content 57 | content = utils.read_json(tmpfile) 58 | assert "Wakkawakkawakka" in content 59 | 60 | 61 | def test_copyfile(tmp_path): 62 | print("Testing utils.copyfile") 63 | 64 | original = str(tmp_path / "location1.txt") 65 | dest = str(tmp_path / "location2.txt") 66 | print(original) 67 | print(dest) 68 | utils.write_file(original, "CONTENT IN FILE") 69 | utils.copyfile(original, dest) 70 | assert os.path.exists(original) 71 | assert os.path.exists(dest) 72 | 73 | 74 | def test_get_tmpdir_tmpfile(): 75 | print("Testing utils.get_tmpdir, get_tmpfile") 76 | 77 | tmpdir = utils.get_tmpdir() 78 | assert os.path.exists(tmpdir) 79 | assert os.path.basename(tmpdir).startswith("oras") 80 | shutil.rmtree(tmpdir) 81 | tmpdir = utils.get_tmpdir(prefix="name") 82 | assert os.path.basename(tmpdir).startswith("name") 83 | shutil.rmtree(tmpdir) 84 | tmpfile = utils.get_tmpfile() 85 | assert "oras" in tmpfile 86 | os.remove(tmpfile) 87 | tmpfile = utils.get_tmpfile(prefix="pancakes") 88 | assert "pancakes" in tmpfile 89 | os.remove(tmpfile) 90 | 91 | 92 | def test_mkdir_p(tmp_path): 93 | print("Testing utils.mkdir_p") 94 | 95 | dirname = str(tmp_path / "input") 96 | result = os.path.join(dirname, "level1", "level2", "level3") 97 | utils.mkdir_p(result) 98 | assert os.path.exists(result) 99 | 100 | 101 | def test_print_json(): 102 | print("Testing utils.print_json") 103 | result = utils.print_json({1: 1}) 104 | assert result == '{\n "1": 1\n}' 105 | 106 | 107 | def test_split_path_and_content(): 108 | """ 109 | Test split path and content function. 110 | 111 | Function has additional logic for Windows - this isn't included in these tests as 112 | they don't usually run on Windows. 113 | """ 114 | testref = "path/to/config:application/vnd.oci.image.config.v1+json" 115 | path_content = utils.split_path_and_content(testref) 116 | assert path_content.path == "path/to/config" 117 | assert path_content.content == "application/vnd.oci.image.config.v1+json" 118 | 119 | testref = "/dev/null:application/vnd.oci.image.config.v1+json" 120 | path_content = utils.split_path_and_content(testref) 121 | assert path_content.path == "/dev/null" 122 | assert path_content.content == "application/vnd.oci.image.config.v1+json" 123 | 124 | testref = "/dev/null" 125 | path_content = utils.split_path_and_content(testref) 126 | assert path_content.path == "/dev/null" 127 | assert not path_content.content 128 | 129 | testref = "path/to/config.json" 130 | path_content = utils.split_path_and_content(testref) 131 | assert path_content.path == "path/to/config.json" 132 | assert not path_content.content 133 | 134 | 135 | def test_make_targz_files_with_same_content_generates_same_hash(tmp_path): 136 | tmp_file = str(tmp_path / "written_file.txt") 137 | utils.write_file(tmp_file, "hello!") 138 | 139 | tmp_tar_1 = str(tmp_path / "test1.tar.gz") 140 | utils.make_targz(tmp_file, tmp_tar_1) 141 | 142 | tmp_tar_2 = str(tmp_path / "test2.tar.gz") 143 | utils.make_targz(tmp_file, tmp_tar_2) 144 | 145 | hash_tar_1 = utils.get_file_hash(tmp_tar_1) 146 | hash_tar_2 = utils.get_file_hash(tmp_tar_2) 147 | 148 | assert hash_tar_1 == hash_tar_2 149 | -------------------------------------------------------------------------------- /oras/tests/upload_data/artifact.txt: -------------------------------------------------------------------------------- 1 | hello dinosaur 2 | -------------------------------------------------------------------------------- /oras/types.py: -------------------------------------------------------------------------------- 1 | __author__ = "Vanessa Sochat" 2 | __copyright__ = "Copyright The ORAS Authors." 3 | __license__ = "Apache-2.0" 4 | 5 | from typing import Union 6 | 7 | import oras.container 8 | 9 | # container type can be string or container 10 | container_type = Union[str, oras.container.Container] 11 | -------------------------------------------------------------------------------- /oras/utils/__init__.py: -------------------------------------------------------------------------------- 1 | from .fileio import ( 2 | copyfile, 3 | extract_targz, 4 | get_file_hash, 5 | get_size, 6 | get_tmpdir, 7 | get_tmpfile, 8 | make_targz, 9 | mkdir_p, 10 | print_json, 11 | read_file, 12 | read_in_chunks, 13 | read_json, 14 | readline, 15 | recursive_find, 16 | sanitize_path, 17 | split_path_and_content, 18 | workdir, 19 | write_file, 20 | write_json, 21 | ) 22 | from .request import ( 23 | append_url_params, 24 | find_docker_config, 25 | get_docker_client, 26 | iter_localhosts, 27 | ) 28 | -------------------------------------------------------------------------------- /oras/utils/request.py: -------------------------------------------------------------------------------- 1 | __author__ = "Vanessa Sochat" 2 | __copyright__ = "Copyright The ORAS Authors." 3 | __license__ = "Apache-2.0" 4 | 5 | import os 6 | import urllib.parse as urlparse 7 | from urllib.parse import urlencode 8 | 9 | 10 | def iter_localhosts(name: str): 11 | """ 12 | Given a url with localhost, always resolve to 127.0.0.1. 13 | 14 | :param name : the name of the original host string 15 | :type name: str 16 | """ 17 | names = [name] 18 | if "localhost" in name: 19 | names.append(name.replace("localhost", "127.0.0.1")) 20 | elif "127.0.0.1" in name: 21 | names.append(name.replace("127.0.0.1", "localhost")) 22 | for name in names: 23 | yield name 24 | 25 | 26 | def find_docker_config(exists: bool = True): 27 | """ 28 | Return the docker default config path. 29 | """ 30 | path = os.path.expanduser("~/.docker/config.json") 31 | 32 | # Allow the caller to request the path regardless of existing 33 | if os.path.exists(path) or not exists: 34 | return path 35 | 36 | 37 | def append_url_params(url: str, params: dict) -> str: 38 | """ 39 | Given a dictionary of params and a url, parse the url and add extra params. 40 | 41 | :param url: the url string to parse 42 | :type url: str 43 | :param params: parameters to add 44 | :type params: dict 45 | """ 46 | parts = urlparse.urlparse(url) 47 | query = dict(urlparse.parse_qsl(parts.query)) 48 | query.update(params) 49 | updated = list(parts) 50 | updated[4] = urlencode(query) 51 | return urlparse.urlunparse(updated) 52 | 53 | 54 | def get_docker_client(tls_verify: bool = True, **kwargs): 55 | """ 56 | Get a docker client. 57 | 58 | :param tls_verify : enable tls 59 | :type tls_verify: bool 60 | """ 61 | import docker 62 | 63 | return docker.DockerClient(tls=tls_verify, **kwargs) 64 | -------------------------------------------------------------------------------- /oras/version.py: -------------------------------------------------------------------------------- 1 | __author__ = "Vanessa Sochat" 2 | __copyright__ = "Copyright The ORAS Authors." 3 | __license__ = "Apache-2.0" 4 | 5 | __version__ = "0.2.33" 6 | AUTHOR = "Vanessa Sochat" 7 | EMAIL = "vsoch@users.noreply.github.com" 8 | NAME = "oras" 9 | PACKAGE_URL = "https://github.com/oras-project/oras-py" 10 | KEYWORDS = "oci, registry, storage" 11 | DESCRIPTION = "OCI Registry as Storage Python SDK" 12 | LICENSE = "LICENSE" 13 | 14 | ################################################################################ 15 | # Global requirements 16 | 17 | INSTALL_REQUIRES = ( 18 | ("jsonschema", {"min_version": None}), 19 | ("requests", {"min_version": None}), 20 | ) 21 | 22 | TESTS_REQUIRES = (("pytest", {"min_version": "4.6.2"}),) 23 | 24 | DOCKER_REQUIRES = (("docker", {"exact_version": "5.0.1"}),) 25 | 26 | INSTALL_REQUIRES_ALL = INSTALL_REQUIRES + TESTS_REQUIRES + DOCKER_REQUIRES 27 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.black] 2 | profile = "black" 3 | exclude = '^env/' 4 | 5 | [tool.isort] 6 | profile = "black" # needed for black/isort compatibility 7 | skip = [] 8 | 9 | [tool.mypy] 10 | mypy_path = ["oras", "examples"] 11 | 12 | [tool.pytest.ini_options] 13 | markers = ["with_auth: mark for tests requiring authenticated registry access (or not)"] 14 | -------------------------------------------------------------------------------- /scripts/lint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # This script performs linting, and was used before pre-commit was added 10/25. 4 | # It is included in case we want to use it in another context. 5 | 6 | DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" 7 | cd $DIR/../ 8 | 9 | black --check oras 10 | 11 | for filename in $(find . -name "*.py" -not -path "./docs/*" -not -path "*__init__.py" -not -path "./env/*"); do 12 | pyflakes $filename 13 | done 14 | 15 | # mypy checks typing 16 | mypy oras examples 17 | 18 | # isort (import order) 19 | isort --skip oras/utils/__init__.py --check-only *.py oras examples 20 | -------------------------------------------------------------------------------- /scripts/test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" 4 | cd $DIR/../ 5 | 6 | # Ensure envars are defined - expected registry port and host 7 | export ORAS_PORT=${ORAS_PORT:-5000} 8 | export ORAS_HOST=localhost 9 | export ORAS_REGISTRY=${ORAS_HOST}:${ORAS_PORT} 10 | export ORAS_USER=myuser 11 | export ORAS_PASS=mypass 12 | 13 | if [ ! -z ${with_auth} ]; then 14 | export ORAS_AUTH=true 15 | fi 16 | 17 | printf "ORAS_PORT: ${ORAS_PORT}\n" 18 | printf "ORAS_HOST: ${ORAS_HOST}\n" 19 | printf "ORAS_REGISTRY: ${ORAS_REGISTRY}\n" 20 | printf "ORAS_AUTH: ${ORAS_AUTH}\n" 21 | 22 | # Client (command line) tests 23 | pytest -xs oras/ 24 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | exclude = benchmarks docs 3 | max-line-length = 100 4 | ignore = E1 E2 E5 W5 5 | per-file-ignores = 6 | oras/utils/__init__.py:F401 7 | oras/main/__init__.py:F401 8 | oras/__init__.py:F401 9 | oras/cli/__init__.py:F841 10 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from setuptools import find_packages, setup 4 | 5 | 6 | def get_lookup(): 7 | lookup = dict() 8 | version_file = os.path.join("oras", "version.py") 9 | with open(version_file) as filey: 10 | exec(filey.read(), lookup) 11 | return lookup 12 | 13 | 14 | # Read in requirements 15 | def get_reqs(lookup=None, key="INSTALL_REQUIRES"): 16 | """ 17 | get requirements, mean reading in requirements and versions from 18 | the lookup obtained with get_lookup 19 | """ 20 | 21 | if lookup is None: 22 | lookup = get_lookup() 23 | 24 | install_requires = [] 25 | for module in lookup[key]: 26 | module_name = module[0] 27 | module_meta = module[1] 28 | if "exact_version" in module_meta: 29 | dependency = "%s==%s" % (module_name, module_meta["exact_version"]) 30 | elif "min_version" in module_meta: 31 | if module_meta["min_version"] is None: 32 | dependency = module_name 33 | else: 34 | dependency = "%s>=%s" % (module_name, module_meta["min_version"]) 35 | install_requires.append(dependency) 36 | return install_requires 37 | 38 | 39 | # Make sure everything is relative to setup.py 40 | install_path = os.path.dirname(os.path.abspath(__file__)) 41 | os.chdir(install_path) 42 | 43 | # Get version information from the lookup 44 | lookup = get_lookup() 45 | VERSION = lookup["__version__"] 46 | NAME = lookup["NAME"] 47 | AUTHOR = lookup["AUTHOR"] 48 | EMAIL = lookup["EMAIL"] 49 | PACKAGE_URL = lookup["PACKAGE_URL"] 50 | KEYWORDS = lookup["KEYWORDS"] 51 | DESCRIPTION = lookup["DESCRIPTION"] 52 | LICENSE = lookup["LICENSE"] 53 | 54 | # Try to read description, otherwise fallback to short description 55 | try: 56 | with open("README.md") as filey: 57 | LONG_DESCRIPTION = filey.read() 58 | except Exception: 59 | LONG_DESCRIPTION = DESCRIPTION 60 | 61 | ################################################################################ 62 | # MAIN ######################################################################### 63 | ################################################################################ 64 | 65 | if __name__ == "__main__": 66 | INSTALL_REQUIRES = get_reqs(lookup) 67 | TESTS_REQUIRES = get_reqs(lookup, "TESTS_REQUIRES") 68 | INSTALL_REQUIRES_ALL = get_reqs(lookup, "INSTALL_REQUIRES_ALL") 69 | DOCKER_REQUIRES = get_reqs(lookup, "DOCKER_REQUIRES") 70 | 71 | setup( 72 | name=NAME, 73 | version=VERSION, 74 | author=AUTHOR, 75 | author_email=EMAIL, 76 | maintainer=AUTHOR, 77 | packages=find_packages(), 78 | include_package_data=True, 79 | zip_safe=False, 80 | url=PACKAGE_URL, 81 | license=LICENSE, 82 | description=DESCRIPTION, 83 | long_description=LONG_DESCRIPTION, 84 | long_description_content_type="text/markdown", 85 | keywords=KEYWORDS, 86 | setup_requires=[], 87 | install_requires=INSTALL_REQUIRES, 88 | tests_require=TESTS_REQUIRES, 89 | extras_require={ 90 | "all": [INSTALL_REQUIRES_ALL], 91 | "tests": [TESTS_REQUIRES], 92 | "docker": [DOCKER_REQUIRES], 93 | }, 94 | classifiers=[ 95 | "Intended Audience :: Science/Research", 96 | "Intended Audience :: Developers", 97 | "License :: OSI Approved :: Apache Software License", 98 | "Programming Language :: Python", 99 | "Topic :: Software Development", 100 | "Topic :: Scientific/Engineering", 101 | "Operating System :: Unix", 102 | "Programming Language :: Python :: 3 :: Only", 103 | "Programming Language :: Python :: 3.7", 104 | ], 105 | ) 106 | --------------------------------------------------------------------------------