Skip to content

Commit

Permalink
Add read-file action (#203)
Browse files Browse the repository at this point in the history
* Add read-file

* Use YYYY.MM cache hash

* Correct inputs & outputs

* Cleanup

* Add default value for when file doesn't exist

* Convert positionals to options

* Invalidate cache based on workflows

* Correct README

* Improve caching

* Use Python >=3.8

* Initialize test suite

* Install all dependencies

* Upload coverage

* Add badges

* Update README.md

* Update test_combine_durations.py

* Add test_read_file.py

* Correct nested double quotes

* Use Python >=3.9

* Stringify

* Don't specify testpaths, allow pytest to autodetect

* Add comment regarding hashing

* Test GHA

* Debug

* Correct remote URL

* Fix quoting

* Setup python

* Standardize Python

* Enable dependabot updates for pip

* Add analyze step with automated error reports

* Minor cleanup

* Update existing failures

* Apply suggestions from code review

* Typing

* Update README.md

* Apply suggestions from code review

* Update .github/workflows/tests.yml

---------

Co-authored-by: Jannis Leidel <[email protected]>
  • Loading branch information
kenodegard and jezdez authored Jan 24, 2025
1 parent 6151812 commit c01236b
Show file tree
Hide file tree
Showing 9 changed files with 424 additions and 2 deletions.
39 changes: 38 additions & 1 deletion .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,46 @@ jobs:
with:
token: ${{ secrets.CODECOV_TOKEN }}

read-file:
runs-on: ubuntu-latest
steps:
- name: Checkout Source
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2

- name: Read Remote JSON
id: json
uses: ./read-file
env:
# `full_name` is the org/repo slug, extracting from `head` in case PR was opened via a fork
FULL_NAME: ${{ github.event.pull_request.head.repo.full_name || github.event.repository.full_name }}
# `head_ref` is the pull_request branch
# `ref_name` is the push/workflow_dispatch/schedule branch
REF: ${{ github.head_ref || github.ref_name }}
with:
path: https://raw.githubusercontent.com/${{ env.FULL_NAME }}/refs/heads/${{ env.REF }}/read-file/data/json.json
parser: json

- name: Read Local YAML
id: yaml
uses: ./read-file
with:
path: ./read-file/data/yaml.yaml
parser: yaml

- name: Setup Python
uses: actions/setup-python@82c7e631bb3cdc910f68e0081d67478d79c6982d # v5.1.0
with:
python-version: '>=3.9'

- name: Run Tests
shell: python
run: |
assert '''${{ steps.json.outputs.content }}''' == '''${{ steps.yaml.outputs.content }}'''
assert '''${{ fromJSON(steps.json.outputs.content)['foo'] }}''' == '''${{ fromJSON(steps.yaml.outputs.content)['foo'] }}'''
# required check
analyze:
needs: [pytest]
needs: [pytest, read-file]
if: '!cancelled()'
runs-on: ubuntu-latest
steps:
Expand Down
7 changes: 6 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@
skip = '.git'

[tool.coverage.report]
exclude_also = ["if TYPE_CHECKING:"]
exclude_also = [
"if TYPE_CHECKING:",
'if __name__ == "__main__":',
]

[tool.coverage.run]
omit = [
Expand All @@ -15,6 +18,7 @@ omit = [
addopts = [
"--color=yes",
"--cov=combine-durations",
"--cov=read-file",
"--cov=template-files",
"--cov-append",
"--cov-branch",
Expand Down Expand Up @@ -46,5 +50,6 @@ select = [
[tool.ruff.lint.isort]
known-first-party = [
"combine_durations",
"read_file",
"template_files",
]
52 changes: 52 additions & 0 deletions read-file/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# Read File

A composite GitHub Action to read a local file or remote URL with an optional JSON/YAML parser.

## Action Inputs

| Name | Description | Default |
|------|-------------|---------|
| `path` | Local path or remote URL to the file to read. | **required** |
| `parser` | Parser to use for the file. Choose json, yaml, or null (to leave it as plain text). | **optional** |
| `default` | File contents to use if the file is not found. | **optional** |

## Action Outputs

| Name | Description |
|------|-------------|
| `content` | File contents as a JSON object (if a parser is specified) or the raw text. |

## Sample Workflows

```yaml
name: Read File

on:
pull_request:

jobs:
read:
steps:
- id: read_json
uses: conda/actions/read-file
with:
path: https://raw.githubusercontent.com/owner/repo/ref/path/to/json.json
default: '{}'
parser: json

- id: read_yaml
uses: conda/actions/read-file
with:
path: https://raw.githubusercontent.com/owner/repo/ref/path/to/yaml.yaml
default: '{}'
parser: yaml

- id: read_text
uses: conda/actions/read-file
with:
path: path/to/text.text

- run: echo "${{ fromJSON(steps.read_json.outputs.content)['key'] }}"
- run: echo "${{ fromJSON(steps.read_yaml.outputs.content)['key'] }}"
- run: echo "${{ steps.read_file.outputs.content }}"
```
59 changes: 59 additions & 0 deletions read-file/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
name: Read File
description: Read a local file or remote URL.
author: Anaconda Inc.
branding:
icon: book-open
color: green

inputs:
path:
description: Local path or remote URL to the file to read.
required: true
parser:
description: Parser to use for the file. Choose json, yaml, or null (to leave it as plain text).
default:
description: File contents to use if the file is not found.
outputs:
content:
description: File contents as a JSON object (if a parser is specified) or the raw text.
value: ${{ steps.read.outputs.content }}

runs:
using: composite
steps:
# `hashFiles` only works on files within the working directory, since `requirements.txt`
# is not in the working directory we need to manually compute the SHA256 hash
- name: Compute Hash
id: hash
shell: bash
run: echo hash=$(sha256sum ${{ github.action_path }}/requirements.txt | awk '{print $1}') >> $GITHUB_OUTPUT

- name: Pip Cache
uses: actions/cache@0c45773b623bea8c8e75f6c82b208c3cf94ea4f9 # v4.0.2
with:
path: ~/.cache/pip
key: ${{ github.workflow }}-read-file-${{ steps.hash.outputs.hash }}

- name: Setup Python
uses: actions/setup-python@82c7e631bb3cdc910f68e0081d67478d79c6982d # v5.1.0
with:
python-version: '>=3.9'

- name: Pip Install
shell: bash
run: pip install --quiet -r ${{ github.action_path }}/requirements.txt

- name: Pip List
shell: bash
run: pip list

- name: Read JSON
id: read
shell: bash
run: >
python ${{ github.action_path }}/read_file.py
${{ inputs.path }}
${{ inputs.parser && format('"--parser={0}"', inputs.parser) || '' }}
${{ inputs.default && format('"--default={0}"', inputs.default) || '' }}
env:
GITHUB_TOKEN: ${{ github.token }}
1 change: 1 addition & 0 deletions read-file/data/json.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"foo": "bar"}
1 change: 1 addition & 0 deletions read-file/data/yaml.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
foo: bar
97 changes: 97 additions & 0 deletions read-file/read_file.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
"""Read a local file or remote URL."""

from __future__ import annotations

import json
import os
from argparse import ArgumentParser
from pathlib import Path
from typing import TYPE_CHECKING

import requests
import yaml
from requests.exceptions import HTTPError, MissingSchema

if TYPE_CHECKING:
from argparse import Namespace
from collections.abc import Sequence
from typing import Literal


def parse_args(argv: Sequence[str] | None = None) -> Namespace:
# parse CLI for inputs
parser = ArgumentParser()
parser.add_argument(
"file",
type=str,
help="Local path or remote URL to the file to read.",
)
parser.add_argument(
"--parser",
choices=["json", "yaml"],
help=(
"Parser to use for the file. "
"If not specified, the file content is returned as is."
),
)
parser.add_argument(
"--default",
type=str,
help=(
"Default value to use if the file is not found. "
"If not specified, an error is raised."
),
)
return parser.parse_args(argv)


def read_file(file: str | os.PathLike[str] | Path, default: str | None) -> str:
try:
response = requests.get(file)
response.raise_for_status()
except (HTTPError, MissingSchema):
# HTTPError: if the response status code is not ok
# MissingSchema: if the URL is not valid
try:
return Path(file).read_text()
except FileNotFoundError:
if default is None:
raise
return default
else:
return response.text


def parse_content(content: str, parser: Literal["json", "yaml"]) -> str:
# if a parser is defined we parse the content and dump it as JSON
if parser == "json":
content = json.loads(content)
return json.dumps(content)
elif parser == "yaml":
content = yaml.safe_load(content)
return json.dumps(content)
else:
raise ValueError("Parser not supported.")


def get_output(content: str) -> str:
return f"content<<GITHUB_OUTPUT_content\n{content}\nGITHUB_OUTPUT_content\n"


def dump_output(content: str) -> None:
if output := os.getenv("GITHUB_OUTPUT"):
with Path(output).open("a") as fh:
fh.write(get_output(content))


def main() -> None:
args = parse_args()

content = read_file(args.file, args.default)
if args.parser:
content = parse_content(content, args.parser)
dump_output(content)


if __name__ == "__main__":
main()
2 changes: 2 additions & 0 deletions read-file/requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
pyyaml==6.0.2
requests==2.32.3
Loading

0 comments on commit c01236b

Please sign in to comment.