├── .dockerignore ├── .github ├── codecov.yml ├── dependabot.yml └── workflows │ ├── codeql-analysis.yml │ └── publish.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .release-please-manifest.json ├── .vscode ├── launch.json └── settings.json ├── ARCHITECTURE.md ├── CHANGELOG.md ├── CNAME ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE ├── README.md ├── ancv ├── __init__.py ├── __main__.py ├── data │ ├── __init__.py │ ├── models │ │ ├── __init__.py │ │ ├── github.py │ │ └── resume.py │ ├── showcase.resume.json │ └── validation.py ├── exceptions.py ├── py.typed ├── reflection.py ├── timing.py ├── typehelp.py ├── visualization │ ├── __init__.py │ ├── templates.py │ ├── themes.py │ └── translations.py └── web │ ├── __init__.py │ ├── client.py │ └── server.py ├── devbox.json ├── devbox.lock ├── docs └── images │ ├── concept-flow-chart.svg │ ├── showcase.svg │ └── users-venn.svg ├── pyproject.toml ├── release-please-config.json ├── schema.json ├── self-hosting ├── Caddyfile └── docker-compose.yml ├── tests ├── __init__.py ├── conftest.py ├── data │ └── test_validation.py ├── test_data │ ├── README.md │ ├── expected-outputs │ │ ├── full.resume.output.txt │ │ ├── partial.resume.output.txt │ │ └── showcase.resume.output.txt │ └── resumes │ │ ├── full.resume.json │ │ └── partial.resume.json ├── test_main.py ├── test_timing.py ├── visualization │ └── test_templates.py └── web │ ├── conftest.py │ ├── test_client.py │ └── test_server.py └── uv.lock /.dockerignore: -------------------------------------------------------------------------------- 1 | .git/ 2 | 3 | # Byte-compiled / optimized / DLL files 4 | __pycache__/ 5 | **/__pycache__/ 6 | *.py[cod] 7 | *$py.class 8 | 9 | # C extensions 10 | *.so 11 | 12 | # Distribution / packaging 13 | .Python 14 | build/ 15 | develop-eggs/ 16 | dist/ 17 | downloads/ 18 | eggs/ 19 | .eggs/ 20 | lib/ 21 | lib64/ 22 | parts/ 23 | sdist/ 24 | var/ 25 | wheels/ 26 | share/python-wheels/ 27 | *.egg-info/ 28 | .installed.cfg 29 | *.egg 30 | MANIFEST 31 | 32 | # PyInstaller 33 | # Usually these files are written by a python script from a template 34 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 35 | *.manifest 36 | *.spec 37 | 38 | # Installer logs 39 | pip-log.txt 40 | pip-delete-this-directory.txt 41 | 42 | # Unit test / coverage reports 43 | htmlcov/ 44 | .tox/ 45 | .nox/ 46 | .coverage 47 | .coverage.* 48 | .cache 49 | nosetests.xml 50 | coverage.xml 51 | *.cover 52 | *.py,cover 53 | .hypothesis/ 54 | .pytest_cache/ 55 | **/.pytest_cache/ 56 | cover/ 57 | 58 | # Translations 59 | *.mo 60 | *.pot 61 | 62 | # Django stuff: 63 | *.log 64 | local_settings.py 65 | db.sqlite3 66 | db.sqlite3-journal 67 | 68 | # Flask stuff: 69 | instance/ 70 | .webassets-cache 71 | 72 | # Scrapy stuff: 73 | .scrapy 74 | 75 | # Sphinx documentation 76 | docs/_build/ 77 | 78 | # PyBuilder 79 | .pybuilder/ 80 | target/ 81 | 82 | # Jupyter Notebook 83 | .ipynb_checkpoints 84 | 85 | # IPython 86 | profile_default/ 87 | ipython_config.py 88 | 89 | # pyenv 90 | # For a library or package, you might want to ignore these files since the code is 91 | # intended to run in multiple environments; otherwise, check them in: 92 | # .python-version 93 | 94 | # pipenv 95 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 96 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 97 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 98 | # install all needed dependencies. 99 | #Pipfile.lock 100 | 101 | # poetry 102 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 103 | # This is especially recommended for binary packages to ensure reproducibility, and is more 104 | # commonly ignored for libraries. 105 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 106 | #poetry.lock 107 | 108 | # pdm 109 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 110 | #pdm.lock 111 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 112 | # in version control. 113 | # https://pdm.fming.dev/#use-with-ide 114 | .pdm.toml 115 | 116 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 117 | __pypackages__/ 118 | 119 | # Celery stuff 120 | celerybeat-schedule 121 | celerybeat.pid 122 | 123 | # SageMath parsed files 124 | *.sage.py 125 | 126 | # Environments 127 | .env 128 | .venv 129 | env/ 130 | venv/ 131 | ENV/ 132 | env.bak/ 133 | venv.bak/ 134 | 135 | # Spyder project settings 136 | .spyderproject 137 | .spyproject 138 | 139 | # Rope project settings 140 | .ropeproject 141 | 142 | # mkdocs documentation 143 | /site 144 | 145 | # mypy 146 | .mypy_cache/ 147 | **/.mypy_cache/ 148 | .dmypy.json 149 | dmypy.json 150 | 151 | # Pyre type checker 152 | .pyre/ 153 | 154 | # pytype static type analyzer 155 | .pytype/ 156 | 157 | # Cython debug symbols 158 | cython_debug/ 159 | 160 | # PyCharm 161 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 162 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 163 | # and can be added to the global gitignore or merged into this file. For a more nuclear 164 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 165 | #.idea/ 166 | -------------------------------------------------------------------------------- /.github/codecov.yml: -------------------------------------------------------------------------------- 1 | coverage: 2 | status: 3 | # This kept 'failing' the CI (reporting failure for an entire commit's pipeline). 4 | # It's perhaps a useful feature but the 'project' status should be enough for now, 5 | # allowing us to keep that sweet, green tick mark. 6 | patch: off 7 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 2 | 3 | version: 2 4 | updates: 5 | - package-ecosystem: "pip" 6 | directory: "/" 7 | schedule: 8 | interval: "monthly" 9 | - package-ecosystem: "github-actions" 10 | directory: "/" 11 | schedule: 12 | interval: "monthly" 13 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: ["main"] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: ["main"] 20 | schedule: 21 | - cron: "18 3 * * 0" 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: ["python"] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] 37 | # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support 38 | 39 | steps: 40 | - name: Checkout repository 41 | uses: actions/checkout@v4 42 | 43 | # Initializes the CodeQL tools for scanning. 44 | - name: Initialize CodeQL 45 | uses: github/codeql-action/init@v3 46 | with: 47 | languages: ${{ matrix.language }} 48 | # If you wish to specify custom queries, you can do so here or in a config file. 49 | # By default, queries listed here will override any specified in a config file. 50 | # Prefix the list here with "+" to use these queries and those in the config file. 51 | 52 | # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs 53 | # queries: security-extended,security-and-quality 54 | 55 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 56 | # If this step fails, then you should remove it and run the build manually (see below) 57 | - name: Autobuild 58 | uses: github/codeql-action/autobuild@v3 59 | 60 | # ℹ️ Command-line programs to run using the OS shell. 61 | # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun 62 | 63 | # If the Autobuild fails above, remove it and uncomment the following three lines. 64 | # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. 65 | 66 | # - run: | 67 | # echo "Run, Build Application using script" 68 | # ./location_of_script_within_repo/buildscript.sh 69 | 70 | - name: Perform CodeQL Analysis 71 | uses: github/codeql-action/analyze@v3 72 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | # From https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python#publishing-to-package-registries 2 | 3 | name: "Publish" 4 | 5 | on: 6 | push: 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | tests: 13 | name: Run tests 14 | 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - uses: actions/checkout@v4 19 | 20 | - name: Install devbox 21 | uses: jetpack-io/devbox-install-action@v0.13.0 22 | 23 | - name: Run linting 24 | run: devbox run lint 25 | 26 | - name: Check code formatting 27 | run: devbox run format-check 28 | 29 | - name: Run type checks 30 | run: devbox run typecheck 31 | 32 | - name: Run tests 33 | run: devbox run test 34 | env: 35 | # Unit tests actually run against the GH API for 'real integration testing', 36 | # and providing a token will increase the otherwise too-low rate limit. 37 | # The `GITHUB_TOKEN` failed (https://github.com/alexpovel/ancv/actions/runs/4093416643/jobs/7063406195): 38 | # 39 | # body = b'{"message":"Resource not accessible by integration","documentation_url":"https://docs.github.com/rest/reference/gists#list-gists-for-a-user"}' 40 | # 41 | # So use a personal token. 42 | GH_TOKEN: ${{ secrets.GH_PERMISSIONLESS_FGAT }} 43 | 44 | - name: Upload coverage to Codecov 45 | uses: codecov/codecov-action@v5 46 | with: 47 | # Docs say a token isn't required for public GitHub repositories using GH 48 | # Actions, but it didn't work and failed with: 49 | # 50 | # [2022-08-08T19:50:41.725Z] ['error'] There was an error running the 51 | # uploader: Error uploading to https://codecov.io: Error: There was an error 52 | # fetching the storage URL during POST: 404 - {'detail': 53 | # ErrorDetail(string='Unable to locate build via Github Actions API. Please 54 | # upload with the Codecov repository upload token to resolve issue.', 55 | # code='not_found')} 56 | # 57 | # See also: https://github.com/alexpovel/ancv/runs/7733256776?check_suite_focus=true#step:7:37 58 | token: ${{ secrets.CODECOV_TOKEN }} 59 | files: coverage.xml 60 | 61 | release-please: 62 | name: Execute release chores 63 | 64 | runs-on: ubuntu-latest 65 | needs: tests 66 | 67 | outputs: 68 | created: ${{ steps.release.outputs.release_created }} 69 | tag_name: ${{ steps.release.outputs.tag_name }} 70 | 71 | steps: 72 | # https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/making-authenticated-api-requests-with-a-github-app-in-a-github-actions-workflow 73 | - uses: actions/create-github-app-token@v2 74 | id: app-token 75 | with: 76 | app-id: ${{ secrets.APP_ID }} 77 | private-key: ${{ secrets.APP_PRIVATE_KEY }} 78 | - uses: googleapis/release-please-action@v4 79 | id: release 80 | with: 81 | # Token needs: `contents: write`, `pull-requests: write` 82 | token: ${{ steps.app-token.outputs.token }} 83 | 84 | publish: 85 | name: Publish to PyPI 86 | 87 | runs-on: ubuntu-latest 88 | needs: release-please 89 | if: ${{ needs.release-please.outputs.created }} 90 | 91 | # https://blog.pypi.org/posts/2023-04-20-introducing-trusted-publishers/ 92 | environment: pypi 93 | permissions: 94 | id-token: write 95 | 96 | steps: 97 | - uses: actions/checkout@v4 98 | 99 | - name: Install devbox 100 | uses: jetpack-io/devbox-install-action@v0.13.0 101 | with: 102 | enable-cache: true 103 | 104 | - name: Build package 105 | run: devbox run uv build 106 | 107 | - name: Publish package 108 | uses: pypa/gh-action-pypi-publish@v1.12.4 109 | 110 | build-and-push-image: 111 | name: Build Docker image and push to GitHub Container Registry 112 | 113 | runs-on: ubuntu-latest 114 | needs: release-please 115 | if: ${{ needs.release-please.outputs.created }} 116 | 117 | environment: container-registry 118 | 119 | permissions: 120 | contents: read 121 | packages: write 122 | 123 | env: 124 | REGISTRY: ghcr.io 125 | IMAGE_NAME: ${{ github.repository }} 126 | 127 | steps: 128 | - name: Checkout repository 129 | uses: actions/checkout@v4 130 | 131 | - name: Set up QEMU 132 | uses: docker/setup-qemu-action@v3 133 | 134 | - name: Set up Docker Buildx 135 | uses: docker/setup-buildx-action@v3 136 | 137 | - name: Log in to the container registry 138 | uses: docker/login-action@v3 139 | with: 140 | registry: ${{ env.REGISTRY }} 141 | username: ${{ github.actor }} 142 | password: ${{ secrets.GITHUB_TOKEN }} 143 | 144 | - name: Extract metadata (tags, labels) for Docker 145 | id: meta 146 | uses: docker/metadata-action@v5 147 | with: 148 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 149 | tags: | 150 | type=semver,value=${{ needs.release-please.outputs.tag_name }},pattern={{version}} 151 | type=semver,value=${{ needs.release-please.outputs.tag_name }},pattern={{major}}.{{minor}} 152 | type=semver,value=${{ needs.release-please.outputs.tag_name }},pattern={{major}},enable=${{ !startsWith(needs.release-please.outputs.tag_name, 'v0.') }} 153 | 154 | - name: Build and push Docker image 155 | uses: docker/build-push-action@v6 156 | with: 157 | context: . 158 | push: true 159 | tags: ${{ steps.meta.outputs.tags }} 160 | labels: ${{ steps.meta.outputs.labels }} 161 | platforms: linux/amd64,linux/arm64 162 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # pydeps dependency graph output file 2 | depgraph.svg 3 | 4 | # This is discardable output for debugging tests 5 | tests/test_data/actual-outputs 6 | 7 | requirements.txt 8 | 9 | # Byte-compiled / optimized / DLL files 10 | __pycache__/ 11 | *.py[cod] 12 | *$py.class 13 | 14 | # C extensions 15 | *.so 16 | 17 | # Distribution / packaging 18 | .Python 19 | build/ 20 | develop-eggs/ 21 | dist/ 22 | downloads/ 23 | eggs/ 24 | .eggs/ 25 | lib/ 26 | lib64/ 27 | parts/ 28 | sdist/ 29 | var/ 30 | wheels/ 31 | share/python-wheels/ 32 | *.egg-info/ 33 | .installed.cfg 34 | *.egg 35 | MANIFEST 36 | 37 | # PyInstaller 38 | # Usually these files are written by a python script from a template 39 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 40 | *.manifest 41 | *.spec 42 | 43 | # Installer logs 44 | pip-log.txt 45 | pip-delete-this-directory.txt 46 | 47 | # Unit test / coverage reports 48 | htmlcov/ 49 | .tox/ 50 | .nox/ 51 | .coverage 52 | .coverage.* 53 | .cache 54 | nosetests.xml 55 | coverage.xml 56 | *.cover 57 | *.py,cover 58 | .hypothesis/ 59 | .pytest_cache/ 60 | cover/ 61 | 62 | # Translations 63 | *.mo 64 | *.pot 65 | 66 | # Django stuff: 67 | *.log 68 | local_settings.py 69 | db.sqlite3 70 | db.sqlite3-journal 71 | 72 | # Flask stuff: 73 | instance/ 74 | .webassets-cache 75 | 76 | # Scrapy stuff: 77 | .scrapy 78 | 79 | # Sphinx documentation 80 | docs/_build/ 81 | 82 | # PyBuilder 83 | .pybuilder/ 84 | target/ 85 | 86 | # Jupyter Notebook 87 | .ipynb_checkpoints 88 | 89 | # IPython 90 | profile_default/ 91 | ipython_config.py 92 | 93 | # pyenv 94 | # For a library or package, you might want to ignore these files since the code is 95 | # intended to run in multiple environments; otherwise, check them in: 96 | # .python-version 97 | 98 | # pipenv 99 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 100 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 101 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 102 | # install all needed dependencies. 103 | #Pipfile.lock 104 | 105 | # poetry 106 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 107 | # This is especially recommended for binary packages to ensure reproducibility, and is more 108 | # commonly ignored for libraries. 109 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 110 | #poetry.lock 111 | 112 | # pdm 113 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 114 | #pdm.lock 115 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 116 | # in version control. 117 | # https://pdm.fming.dev/#use-with-ide 118 | .pdm.toml 119 | 120 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 121 | __pypackages__/ 122 | 123 | # Celery stuff 124 | celerybeat-schedule 125 | celerybeat.pid 126 | 127 | # SageMath parsed files 128 | *.sage.py 129 | 130 | # Environments 131 | .env 132 | .venv 133 | env/ 134 | venv/ 135 | ENV/ 136 | env.bak/ 137 | venv.bak/ 138 | 139 | # Spyder project settings 140 | .spyderproject 141 | .spyproject 142 | 143 | # Rope project settings 144 | .ropeproject 145 | 146 | # mkdocs documentation 147 | /site 148 | 149 | # mypy 150 | .mypy_cache/ 151 | .dmypy.json 152 | dmypy.json 153 | 154 | # Pyre type checker 155 | .pyre/ 156 | 157 | # pytype static type analyzer 158 | .pytype/ 159 | 160 | # Cython debug symbols 161 | cython_debug/ 162 | 163 | # PyCharm 164 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 165 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 166 | # and can be added to the global gitignore or merged into this file. For a more nuclear 167 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 168 | #.idea/ 169 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/commitizen-tools/commitizen 3 | rev: v2.28.0 4 | hooks: 5 | - id: commitizen 6 | - repo: local 7 | hooks: 8 | - id: lint 9 | name: Run linting 10 | language: system 11 | entry: devbox run lint 12 | stages: 13 | - "commit" 14 | - "push" 15 | types: 16 | - file 17 | - python 18 | - repo: local 19 | hooks: 20 | - id: check-formatting 21 | name: Check formatting 22 | language: system 23 | entry: devbox run format-check 24 | stages: 25 | - "commit" 26 | - "push" 27 | types: 28 | - file 29 | - python 30 | - repo: local 31 | hooks: 32 | - id: run-typecheck 33 | name: Run typecheck 34 | language: system 35 | entry: devbox run typecheck 36 | stages: 37 | - "commit" 38 | - "push" 39 | types: 40 | - file 41 | - python 42 | - repo: local 43 | hooks: 44 | - id: run-tests 45 | name: Run tests 46 | language: system 47 | entry: devbox run test 48 | stages: 49 | - "commit" 50 | - "push" 51 | types: 52 | - file 53 | - python 54 | - repo: local 55 | hooks: 56 | - id: build-image 57 | name: Build image 58 | language: system 59 | entry: devbox run build-image 60 | stages: 61 | - "push" 62 | -------------------------------------------------------------------------------- /.release-please-manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | ".": "1.5.3" 3 | } 4 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Python: Run API Server", 9 | "type": "python", 10 | "request": "launch", 11 | "module": "ancv", 12 | "autoReload": { 13 | "enabled": true 14 | }, 15 | "args": [ 16 | "serve", 17 | "api", 18 | "--port", 19 | "8080" 20 | ], 21 | "justMyCode": false, 22 | }, 23 | { 24 | "name": "Python: Run Single-File Server", 25 | "type": "python", 26 | "request": "launch", 27 | "module": "ancv", 28 | "autoReload": { 29 | "enabled": true 30 | }, 31 | "args": [ 32 | "serve", 33 | "file", 34 | "tests/test_data/resumes/full.resume.json", 35 | "--port", 36 | "8080" 37 | ], 38 | "justMyCode": false, 39 | }, 40 | { 41 | "name": "Python: Current File", 42 | "type": "python", 43 | "request": "launch", 44 | "program": "${file}", 45 | "console": "integratedTerminal", 46 | "justMyCode": false, 47 | } 48 | ] 49 | } 50 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.testing.pytestArgs": [ 3 | "tests" 4 | ], 5 | "python.testing.unittestEnabled": false, 6 | "python.testing.pytestEnabled": true, 7 | "[plaintext]": { 8 | "files.trimTrailingWhitespace": false, 9 | "files.insertFinalNewline": false 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /ARCHITECTURE.md: -------------------------------------------------------------------------------- 1 | # Architecture 2 | 3 | [![codecov](https://codecov.io/gh/alexpovel/ancv/branch/main/graph/badge.svg?token=I5XFLBHRCH)](https://codecov.io/gh/alexpovel/ancv) 4 | 5 | This document is less about software architecture per-se, but rather about technical details deemed out-of-scope for the [README](./README.md). 6 | 7 | ## Features 8 | 9 | - fully async using [`aiohttp`](https://docs.aiohttp.org/en/stable/) and [gidgethub](https://gidgethub.readthedocs.io/en/latest/index.html) 10 | - based around the fantastic [`rich`](https://github.com/Textualize/rich) library for terminal output 11 | - [structural pattern matching](https://peps.python.org/pep-0634/), introduced in Python 3.10: 12 | - initially used for fun, but not because any use cases were substantially easier using it, 13 | - then dropped on 2022-07-16 since Python 3.10 is [unsupported by AWS lambda](https://github.com/aws/aws-lambda-base-images/issues/31), 14 | - but since switched to [Google Cloud Run](https://cloud.google.com/run), which is based on regular OCI containers (see [Dockerfile](./Dockerfile)), hence resolving the hell that is dependency management in serverless environments. 15 | 16 | Fully serverless is still interesting since it's such a fitting use-case. 17 | The best solution seems to [vendor all dependencies](https://www.serverless.com/plugins/serverless-python-requirements), instead of trying our luck with the serverless provider reading and correctly installing the dependencies for us (which often requires a `requirements.txt` instead of a `poetry.lock` or similar). 18 | 19 | However, since this project treats self-hosting as a first-class citizen, going full serverless and abandoning providing Docker images entirely isn't an option anyway. 20 | Hosting serverlessly would be a split, required maintenance of two hosting options instead of just building one image and calling it a day. 21 | - [fully typed](https://mypy.readthedocs.io/en/stable/index.html) using Python type hints, verified through `mypy --strict` (with additional, [even stricter settings](pyproject.toml)) 22 | - [structural logging](https://github.com/hynek/structlog) with a JSON event stream output 23 | - [`pydantic`](https://pydantic-docs.helpmanual.io/) for fully typed data validation (e.g., for APIs), facilitated by [automatic `pydantic` model generation](https://koxudaxi.github.io/datamodel-code-generator/) from e.g. OpenAPI specs like [GitHub's](https://github.com/github/rest-api-description/tree/main/descriptions/api.github.com) or [JSON Resume's](https://github.com/jsonresume/resume-schema/blob/master/schema.json), allowing full support from `mypy` and the IDE when using said validated data 24 | - [12 Factor App](https://12factor.net/) conformance: 25 | 1. [Codebase](https://12factor.net/codebase): [GitHub-hosted repo](https://github.com/alexpovel/ancv/) 26 | 2. [Dependencies](https://12factor.net/dependencies): taken care of by [uv](https://docs.astral.sh/uv/) 27 | 3. [Config](https://12factor.net/config): the app is configured using environment variables. 28 | Although [problematic](https://news.ycombinator.com/item?id=31200132), this approach was chosen for its simplicity 29 | 4. [Backing Services](https://12factor.net/backing-services): not applicable for this very simple app 30 | 5. [Build, release, run](https://12factor.net/build-release-run): handled through GitHub releases via git tags and [release-please](https://github.com/marketplace/actions/release-please-action) 31 | 6. [Processes](https://12factor.net/processes): this simple app is stateless in and of itself 32 | 7. [Port binding](https://12factor.net/port-binding): the `aiohttp` [server](ancv/web/server.py) part of the app acts as a [standalone web server](https://docs.aiohttp.org/en/stable/deployment.html#standalone), exposing a port. 33 | That port can then be serviced by any arbitrary reverse proxy 34 | 8. [Concurrency](https://12factor.net/concurrency): covered by async functionality (in a single process and thread). 35 | This being a stateless app, horizontal scaling through additional processes is trivial (e.g. via serverless hosting), although vertical scaling will likely suffice indefinitely 36 | 9. [Disposability](https://12factor.net/disposability): `aiohttp` handles `SIGTERM` gracefully 37 | 10. [Dev/prod parity](https://12factor.net/dev-prod-parity): trivial to do for this simple app. 38 | If running on Windows, mind [this issue](https://stackoverflow.com/q/45600579/11477374). 39 | If running on Linux, no special precautions are necessary 40 | 11. [Logs](https://12factor.net/logs): structured JSON logs are written directly to `stdout` 41 | 12. [Admin processes](https://12factor.net/admin-processes): not applicable either 42 | 43 | ## Similar solutions 44 | 45 | Very hard to find any, and even hard to google. 46 | For example, `bash curl curriculum vitae` will prompt Google to interpret `curriculum vitae == resume`, which isn't wrong but `curl resume` is an entirely unrelated query (concerned with resuming halted downloads and such). 47 | 48 | Similar projects: 49 | 50 | - 51 | 52 | Related, but 'fake' hits: 53 | 54 | - 55 | -------------------------------------------------------------------------------- /CNAME: -------------------------------------------------------------------------------- 1 | madm.ancv.povel.dev -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | ## Environment setup 4 | 5 | [![Built with Devbox](https://jetpack.io/img/devbox/shield_moon.svg)](https://jetpack.io/devbox/docs/contributor-quickstart/) 6 | 7 | Use devbox to set up a development environment. 8 | Refer to [the available `script`s](devbox.json) to see what's possible. 9 | Generally, even when running in a `devbox shell`, running `uv` is necessary: 10 | 11 | - devbox sets up what used to be system-wide packages (Python, uv, ...) deterministically and automatically 12 | - within the devbox virtual environment, we still manage and use a Python virtual environment through `uv` commands 13 | 14 | That way, we get the normal Python package management for normal Python packages (`ruff`, `pytest`, ...), and devbox for the overarching rest. 15 | 16 | Lastly, for bonus points, set up pre-commit hooks: 17 | 18 | ```bash 19 | devbox run install-hooks 20 | ``` 21 | 22 | ## Creating components 23 | 24 | ### Templates 25 | 26 | To create a new template, inherit from [`Template`](./ancv/visualization/templates.py) and simply fulfills its interface: 27 | It needs a [`__rich_console__`](https://rich.readthedocs.io/en/stable/protocol.html#console-render) method yielding all elements that will eventually make up the output document. 28 | That's it! 29 | The implementation is entirely up to you. 30 | You can use [tables](https://rich.readthedocs.io/en/stable/tables.html), [panels](https://rich.readthedocs.io/en/stable/panel.html), [columns](https://rich.readthedocs.io/en/stable/columns.html) and most else [`rich`](https://github.com/Textualize/rich) has on offer. 31 | 32 | `mypy` checks (`make typecheck`) will help getting the implementation right. 33 | However, note that this method cannot (currently) check whether *all sections* were implemented: for example, *Volunteering* could simply have been forgotten, but the code would run all checks would pass. 34 | Further, lots of manual and visual testing will be necessary. 35 | 36 | ### Translations 37 | 38 | Simply add your translation [here](./ancv/visualization/translations.py). 39 | 40 | ### Themes 41 | 42 | Simply add your theme [here](./ancv/visualization/themes.py). 43 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.12-slim 2 | 3 | ARG WORKDIR="/app" 4 | WORKDIR ${WORKDIR} 5 | 6 | RUN useradd -u 1000 -d ${WORKDIR} -M app 7 | RUN chown -R app:app ${WORKDIR} 8 | USER 1000 9 | 10 | # Cache-friendly dependency installation 11 | COPY pyproject.toml uv.lock ./ 12 | # https://docs.astral.sh/uv/guides/integration/docker/#installing-uv 13 | COPY --from=ghcr.io/astral-sh/uv:latest /uv /bin/ 14 | RUN uv sync --frozen --no-dev 15 | 16 | COPY ancv/ ancv/ 17 | 18 | # Required for Google Cloud Run to auto-detect 19 | EXPOSE 8080 20 | 21 | ENTRYPOINT [ "uv", "run", "--frozen", "--module", "ancv" ] 22 | CMD [ "--verbose", "serve", "api", "--port", "8080" ] 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Alex Povel 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # [ancv](https://github.com/alexpovel/ancv) 2 | 3 | Getting your resume aka [an CV](https://youtu.be/mJUtMEJdvqM?t=16) (*[ANSI](https://en.wikipedia.org/wiki/ANSI_escape_code)-v* 🤡) straight to your and anyone else's terminals: 4 | 5 | ![showcase cv terminal output](docs/images/showcase.svg) 6 | 7 | --- 8 | 9 | Be warned though, for this is kinda useless and just for fun: 10 | 11 |

12 | users venn diagram 13 |

14 | 15 | ## Getting started 16 | 17 | 1. Create your resume according to the [JSON Resume Schema](https://jsonresume.org/schema/) (see also the [schema specification](https://github.com/jsonresume/resume-schema/blob/master/schema.json)) either: 18 | 19 | - use ChatGPT (or another LLM) with the following prompt (you need to fill in the spaces for `[resume]` and `[json_schema]`): 20 | ``` 21 | Resume:[resume] 22 | 23 | JSON Resume Schema:[json_schema] 24 | 25 | Provide JSON data structure of the resume, formatted according to the JSON Resume Schema 26 | 27 | Output json, no yapping 28 | ``` 29 | Note: for `json_schema` you can just use the example [from here](https://jsonresume.org/schema/) 30 | - manually (see [the `heyho` sample](./ancv/data/showcase.resume.json) for a possible starting point), 31 | - exporting from [LinkedIn](https://www.linkedin.com/) using [Joshua Tzucker's LinkedIn exporter](https://joshuatz.com/projects/web-stuff/linkedin-profile-to-json-resume-exporter/) ([repo](https://github.com/joshuatz/linkedin-to-jsonresume))[^1], or 32 | - exporting from one of the platforms advertised as offering [JSON resume integration](https://jsonresume.org/schema/). 33 | 2. [Create a **public** gist](https://gist.github.com/) named `resume.json` with your resume contents. 34 | 3. You're now the proud owner of an ancv. 35 | Time to try it out. 36 | 37 | The following examples work out-of-the-box. 38 | **Replace `heyho` with your GitHub username** once you're all set up. 39 | 40 | - curl: 41 | 42 | ```bash 43 | curl -L ancv.povel.dev/heyho 44 | ``` 45 | 46 | with `-L` being shorthand for [`--location`](https://curl.se/docs/manpage.html), allowing you to follow the redirect from `http://ancv.povel.dev` through to `https://ancv.povel.dev`. 47 | It's shorter than its also perfectly viable alternative: 48 | 49 | ```bash 50 | curl https://ancv.povel.dev/heyho 51 | ``` 52 | 53 | Lastly, you might want to page the output for easiest reading, top-to-bottom: 54 | 55 | ```bash 56 | curl -sL ancv.povel.dev/heyho | less 57 | ``` 58 | 59 | If that garbles the rendered output, try `less -r` aka [`--raw-control-chars`](https://man7.org/linux/man-pages/man1/less.1.html). 60 | 61 | - wget: 62 | 63 | ```bash 64 | wget -O - --quiet ancv.povel.dev/heyho 65 | ``` 66 | 67 | where `-O` is short for [`--output-document`](https://linux.die.net/man/1/wget), used here to redirect to stdout. 68 | 69 | - PowerShell 7: 70 | 71 | ```powershell 72 | (iwr ancv.povel.dev/heyho).Content 73 | ``` 74 | 75 | where `iwr` is an alias for [`Invoke-Webrequest`](https://docs.microsoft.com/en-us/powershell/module/microsoft.powershell.utility/invoke-webrequest?view=powershell-7.2), returning an object whose `Content` we access. 76 | - PowerShell 5: 77 | 78 | ```powershell 79 | (iwr -UseBasicParsing ancv.povel.dev/heyho).Content 80 | ``` 81 | 82 | where `-UseBasicParsing` is *only* required if you haven't set up Internet Explorer yet ([yes, really](https://stackoverflow.com/q/38005341/11477374)). 83 | If you have, then it works as PowerShell 7 (where that flag is deprecated and the default anyway). 84 | 85 | ## Configuration 86 | 87 | *All configuration is optional.* 88 | 89 | The CV is constructed as follows: 90 | 91 | ![conceptual flow chart](docs/images/concept-flow-chart.svg) 92 | 93 | In summary: 94 | 95 | - you control: 96 | - the **template**. 97 | 98 | Essentially the order of items, indentations, text alignment, position of dates and more. 99 | Templates are like layouts/skeletons. 100 | - the **theme**. 101 | 102 | This controls colors, italics, boldface, underlining, blinking (yes, really) and more. 103 | A couple themes exist but you can easily add your own one. 104 | - the **language** to use. 105 | 106 | Pre-set strings like section titles (*Education*, ...), names of months etc. are governed by *translations*, of which there are a couple available already. 107 | All other text is free-form. 108 | - text content like emojis and newlines to control paragraph breaks. 109 | 110 | Emojis are user-controlled: if you want them, use them in your `resume.json`; in the future, there might be *templates* with emojis baked in, but you'd have to actively opt into using one. 111 | - date formatting, in a limited fashion through a special `dec31_as_year` toggle. 112 | If that toggle is `true`, dates in the format `YYYY-12-31` will be displayed as `YYYY` only. 113 | - lastly, there's a toggle for ASCII-only output. 114 | 115 | It only concerns the *template* and controls the drawing of boxes and such (e.g., [`-`](https://symbl.cc/en/002D/) versus [`─`](https://symbl.cc/en/2500/) : only the latter will produce gapless rules). 116 | If you yourself use non-ASCII characters in your texts, use a *language* containing non-ASCII characters (Spanish, French, ...) or a *theme* with non-ASCII characters (e.g., a theme might use the `•` character to print bullet points), non-ASCII Unicode will still occur. 117 | As such, this toggle currently isn't very powerful, but with some care it *does* ultimately allow you to be ASCII-only. 118 | 119 | If you come up with new templates, themes or translations, a PR would be highly appreciated. 120 | - you *do not* control: 121 | - anything about a viewer's terminal! 122 | 123 | Any recent terminal will support a baseline of features (e.g., colors), but large parts of the functionalities depend on the *font* used: proper Unicode support is needed for pretty output (see `ascii_only`), and ideally emojis if you're into that (although it's easy to pick an emoji-free template). 124 | Many themes leverage Unicode characters as well. 125 | - access to your CV: like the gist itself, it will be publicly available on GitHub. 126 | 127 | ### How to configure 128 | 129 | Configuring `ancv` requires going beyond the vanilla JSON Resume schema. 130 | You will need to add an (entirely optional) `$.meta.ancv` field to your `resume.json`. 131 | The [provided schema](schema.json) will be of help here: 132 | an editor capable of providing auto-completion based on it, like [Visual Studio Code](https://code.visualstudio.com/docs/languages/json#_json-schemas-and-settings), will make filling out the additional configuration a breeze. 133 | 134 | The schema will further inform you of the default values (used for unspecified fields). 135 | Since everything is optional, a [valid JSON resume](https://github.com/jsonresume/resume-schema/blob/master/schema.json) (without an `ancv` section) is valid for `ancv` use as well. 136 | 137 | ## Installation 138 | 139 | ### As a library 140 | 141 | Install the package as usual: 142 | 143 | ```bash 144 | pip install ancv 145 | ``` 146 | 147 | This also allows you to import whatever you could want or need from the package, if anything. 148 | Note that it's pretty heavy on the dependencies. 149 | 150 | ### As a container 151 | 152 | See also the available [packages aka images](https://github.com/alexpovel/ancv/pkgs/container/ancv): 153 | 154 | ```bash 155 | docker pull ghcr.io/alexpovel/ancv 156 | ``` 157 | 158 | Versioned tags (so you can pin a major) are available. 159 | 160 | ### Local usage 161 | 162 | Once installed, you could for example check whether your `resume.json` is valid at all (`validate`) or get a glimpse at the final product (`render`): 163 | 164 | ```bash 165 | # pip route: 166 | $ ancv render resume.json 167 | # container route: 168 | $ docker run -v $(pwd)/resume.json:/app/resume.json ghcr.io/alexpovel/ancv render 169 | ``` 170 | 171 | Alternatively, you can directly serve your resume from any HTTP URL using he built-in web server: 172 | 173 | ```bash 174 | # pip route: 175 | $ ancv serve web https://raw.githubusercontent.com/alexpovel/ancv/refs/heads/main/ancv/data/showcase.resume.json 176 | # container route: 177 | $ docker run -p 8080:8080 ghcr.io/alexpovel/ancv serve web https://raw.githubusercontent.com/alexpovel/ancv/refs/heads/main/ancv/data/showcase.resume.json 178 | ``` 179 | 180 | Test it: 181 | 182 | ```bash 183 | curl http://localhost:8080 184 | ``` 185 | 186 | The web server includes useful features like: 187 | 188 | - Automatic refresh of resume content (configurable interval) 189 | - Fallback to cached version if source is temporarily unavailable 190 | - Configurable host/port binding (default: http://localhost:8080) 191 | 192 | ## Self-hosting 193 | 194 | Self-hosting is a first-class citizen here. 195 | 196 | ### Context: Cloud Hosting 197 | 198 | The site is hosted on [Google Cloud Run](https://cloud.google.com/run) (serverless) and deployed there [automatically](https://github.com/alexpovel/ancv/runs/8172131447), such that the latest release you see here is also the code executing in that cloud environment. 199 | That's convenient to get started: simply create a `resume.json` gist and you're good to go within minutes. 200 | It can also be used for debugging and playing around; it's a playground of sorts. 201 | 202 | You're invited to use this service for as much and as long as you'd like. 203 | However, obviously, as an individual I cannot guarantee its availability in perpetuity. 204 | You might also feel uncomfortable uploading your CV onto GitHub, since it *has* to be public for this whole exercise to work. 205 | Lastly, you might also be suspicious of me inserting funny business into your CV before serving it out. 206 | If this is you, self-hosting is for you. 207 | 208 | ### Setup 209 | 210 | For simplicity, using Docker Compose (with Docker's recent [Compose CLI plugin](https://docs.docker.com/compose/install/compose-plugin/)): 211 | 212 | 1. Clone this repository onto your server (or fork it, make your edits and clone that) 213 | 2. `cd self-hosting` 214 | 3. Edit [Caddy's config file](./self-hosting/Caddyfile) ([more info](https://caddyserver.com/docs/caddyfile)) to contain your own domain name 215 | 4. Place your `resume.json` into the directory 216 | 5. Run `docker compose up` 217 | 218 | Caddy (chosen here for simplicity) will handle HTTPS automatically for you, but will of course require domain names to be set up correctly to answer ACME challenges. 219 | Handling DNS is up to you; for dynamic DNS, I can recommend [`qmcgaw/ddns-updater`](https://github.com/qdm12/ddns-updater). 220 | 221 | If you self-host in the cloud, the server infrastructure might be taken care of for you by your provider already (as is the case for Google Cloud Run). 222 | In these cases, a dedicated proxy is unnecessary and a single [Dockerfile](./Dockerfile) might suffice (adjusted to your needs). 223 | True [serverless](https://www.serverless.com/) is also a possibility and an excellent fit here. 224 | For example, one could use [Digital Ocean's *Functions*](https://docs.digitalocean.com/products/functions/). 225 | If you go that route and succeed, please let me know! (I had given up with how depressingly hard dependency management was, as opposed to tried-and-tested container images.) 226 | 227 | --- 228 | 229 |

230 | 231 | github logo 232 | 233 |

234 | 235 | [^1]: The exporter has a couple caveats. 236 | You will probably not be able to paste its result into a gist and have it work out of the box. 237 | It is recommended to paste the export into an editor capable of helping you find errors against the contained `$schema`, like VS Code. 238 | Alternatively, a local `ancv render your-file.json` will print `pydantic` validation errors, which might be helpful in debugging. 239 | For example, the exporter might leave `$.basics.url` an empty string, which isn't a valid URI and therefore fails the schema and, by extension, `ancv`. 240 | Similarly, `endDate` keys might get empty string values. 241 | **Remove these entries** entirely to stay conformant to the JSON Resume Schema (to which `ancv` stays conformant). 242 | -------------------------------------------------------------------------------- /ancv/__init__.py: -------------------------------------------------------------------------------- 1 | from enum import IntEnum 2 | from pathlib import Path 3 | 4 | from ancv.typehelp import unwrap 5 | 6 | PROJECT_ROOT = Path(__file__).parent 7 | PACKAGE = unwrap(__package__) 8 | 9 | 10 | class SIPrefix(IntEnum): 11 | KILO = int(1e3) 12 | MEGA = int(1e6) 13 | -------------------------------------------------------------------------------- /ancv/__main__.py: -------------------------------------------------------------------------------- 1 | """Render JSON resumes to rich ANSI text for terminal output. 2 | 3 | Comes with a server serving either an API or a single file, and a CLI to render files 4 | locally. 5 | """ 6 | 7 | from pathlib import Path 8 | from typing import Optional 9 | 10 | import typer 11 | 12 | app = typer.Typer(no_args_is_help=True, help=__doc__) 13 | server_app = typer.Typer(no_args_is_help=True, help="Interacts with the web server.") 14 | 15 | app.add_typer(server_app, name="serve") 16 | 17 | 18 | @server_app.command() 19 | def api( 20 | host: str = typer.Option("0.0.0.0", help="Hostname to bind to."), 21 | port: int = typer.Option(8080, help="Port to bind to."), 22 | path: Optional[str] = typer.Option( 23 | None, help="File system path for an HTTP server UNIX domain socket." 24 | ), 25 | ) -> None: 26 | """Starts the web server and serves the API.""" 27 | 28 | import os 29 | 30 | from ancv.reflection import METADATA 31 | from ancv.web.server import APIHandler, ServerContext 32 | 33 | context = ServerContext(host=host, port=port, path=path) 34 | api = APIHandler( 35 | # https://docs.github.com/en/rest/overview/resources-in-the-rest-api#user-agent-required : 36 | requester=os.environ.get("GH_REQUESTER", METADATA.name), 37 | # Not specifying a token works just as well, but has a much lower request 38 | # ceiling: 39 | token=os.environ.get("GH_TOKEN"), 40 | terminal_landing_page=os.environ.get( 41 | "HOMEPAGE", 42 | str(METADATA.project_urls.get("Homepage", "No homepage set")), 43 | ), 44 | # When visiting this endpoint in a browser, we want to redirect to the homepage. 45 | # That page cannot be this same path under the same hostname again, else we get 46 | # a loop. 47 | browser_landing_page=os.environ.get( 48 | "LANDING_PAGE", 49 | str(METADATA.project_urls.get("Homepage", "https://github.com/")), 50 | ), 51 | ) 52 | api.run(context) 53 | 54 | 55 | @server_app.command() 56 | def file( 57 | file: Path = typer.Argument(Path("resume.json")), 58 | host: str = typer.Option("0.0.0.0", help="Hostname to bind to."), 59 | port: int = typer.Option(8080, help="Port to bind to."), 60 | path: Optional[str] = typer.Option( 61 | None, help="File system path for an HTTP server UNIX domain socket." 62 | ), 63 | ) -> None: 64 | """Starts the web server and serves a single, rendered resume file.""" 65 | 66 | from ancv.web.server import FileHandler, ServerContext 67 | 68 | context = ServerContext(host=host, port=port, path=path) 69 | FileHandler(file).run(context) 70 | 71 | 72 | @server_app.command(no_args_is_help=True) 73 | def web( 74 | destination: str = typer.Argument( 75 | ..., help="HTTP/HTTPS URL of the JSON resume file to serve." 76 | ), 77 | refresh: int = typer.Option( 78 | 3600, help="Refresh interval in seconds for fetching updates from the URL." 79 | ), 80 | port: int = typer.Option(8080, help="Port to bind to."), 81 | host: str = typer.Option("0.0.0.0", help="Hostname to bind to."), 82 | path: Optional[str] = typer.Option( 83 | None, help="File system path for an HTTP server UNIX domain socket." 84 | ), 85 | ) -> None: 86 | """Starts a web server that serves a JSON resume from a URL with periodic refresh. 87 | 88 | The server will fetch and render the resume from the provided URL, caching it for the specified 89 | refresh interval. This is useful for serving resumes hosted on external services. 90 | """ 91 | 92 | from ancv.web.server import WebHandler, ServerContext 93 | from datetime import timedelta 94 | 95 | context = ServerContext(host=host, port=port, path=path) 96 | WebHandler(destination, refresh_interval=timedelta(seconds=refresh)).run(context) 97 | 98 | 99 | @app.command() 100 | def render( 101 | path: Path = typer.Argument( 102 | Path("resume.json"), 103 | help="File path to the JSON resume file.", 104 | ), 105 | ) -> None: 106 | """Locally renders the JSON resume at the given file path.""" 107 | 108 | from ancv.visualization.templates import Template 109 | 110 | template = Template.from_file(path) 111 | output = template.render() 112 | print(output) 113 | return None 114 | 115 | 116 | @app.command() 117 | def validate( 118 | path: Path = typer.Argument( 119 | Path("resume.json"), 120 | help="File path to the JSON resume file.", 121 | ), 122 | ) -> None: 123 | """Checks the validity of the given JSON resume without rendering.""" 124 | 125 | from pydantic import ValidationError 126 | 127 | from ancv.exceptions import ResumeConfigError 128 | from ancv.visualization.templates import Template 129 | 130 | try: 131 | Template.from_file(path) 132 | except (ValidationError, ResumeConfigError) as e: 133 | print(str(e)) 134 | raise typer.Exit(code=1) 135 | else: 136 | print("Pass!") 137 | 138 | 139 | @app.command() 140 | def version() -> None: 141 | """Prints the application version.""" 142 | 143 | from ancv.reflection import METADATA 144 | 145 | print(f"ancv {METADATA.version}") 146 | 147 | 148 | @app.command() 149 | def list() -> None: 150 | """Lists all available components (templates, themes and translations).""" 151 | 152 | # This is pretty raw, but it works. Could make it prettier using more of `rich`. 153 | from rich import print 154 | from rich.tree import Tree 155 | 156 | from ancv.visualization.templates import Template 157 | from ancv.visualization.themes import THEMES 158 | from ancv.visualization.translations import TRANSLATIONS 159 | 160 | tree = Tree("Components") 161 | 162 | template_tree = Tree("Templates") 163 | for template in Template.subclasses().keys(): 164 | template_tree.add(template) 165 | tree.add(template_tree) 166 | 167 | theme_tree = Tree("Themes") 168 | for theme in THEMES: 169 | theme_tree.add(theme) 170 | tree.add(theme_tree) 171 | 172 | translation_tree = Tree("Translations") 173 | for translation in TRANSLATIONS: 174 | translation_tree.add(translation) 175 | tree.add(translation_tree) 176 | 177 | print(tree) 178 | 179 | 180 | @app.command() 181 | def generate_schema() -> None: 182 | """Generates and prints the current JSON schema. 183 | 184 | ATTENTION: This schema is defined manually, independently of the actual models 185 | contained within this package. As such, the two *might* end up out of sync. This 186 | approach was chosen as a temporary solution, since syncing the JSON Schema and the 187 | pydantic models is a lot of work with a lot of tiny blockers. 188 | """ 189 | 190 | import json 191 | import typing as t 192 | 193 | from ancv.reflection import METADATA 194 | from ancv.visualization.templates import Template 195 | from ancv.visualization.themes import THEMES 196 | from ancv.visualization.translations import TRANSLATIONS 197 | 198 | schema: dict[str, t.Any] = { 199 | "$schema": "http://json-schema.org/draft-04/schema#", 200 | "allOf": [ 201 | { 202 | "$ref": "https://raw.githubusercontent.com/jsonresume/resume-schema/v1.0.0/schema.json" 203 | }, 204 | { 205 | "type": "object", 206 | "properties": { 207 | "meta": { 208 | "allOf": [ 209 | { 210 | "$ref": "https://raw.githubusercontent.com/jsonresume/resume-schema/v1.0.0/schema.json#/properties/meta" 211 | } 212 | ], 213 | "properties": { 214 | METADATA.name: { 215 | "type": "object", 216 | "description": f"{METADATA.name}-specific ({METADATA.project_urls.get("Homepage")}) properties", 217 | "properties": { 218 | "template": { 219 | "type": "string", 220 | "description": "The template (ordering, alignment, positioning, ...) to use", 221 | "enum": sorted(Template.subclasses().keys()), 222 | }, 223 | "theme": { 224 | "type": "string", 225 | "description": "The theme (colors, emphasis, ...) to use", 226 | "enum": sorted(THEMES.keys()), 227 | }, 228 | "language": { 229 | "type": "string", 230 | "description": "The language aka translation (for section titles like 'Education' etc.) to use", 231 | "enum": sorted(TRANSLATIONS.keys()), 232 | }, 233 | "ascii_only": { 234 | "type": "boolean", 235 | "description": "Whether to only use ASCII characters in the template (you are responsible for not using non-ASCII characters in your resume)", 236 | }, 237 | "dec31_as_year": { 238 | "type": "boolean", 239 | "description": "Whether to display dates of 'December 31st of some year' as that year only, without month or day info", 240 | }, 241 | }, 242 | } 243 | }, 244 | } 245 | }, 246 | }, 247 | ], 248 | } 249 | 250 | print(json.dumps(schema, indent=4)) 251 | 252 | 253 | @app.callback() 254 | def main( 255 | verbose: bool = typer.Option( 256 | False, "--verbose", "-v", help="Turn on verbose logging output." 257 | ), 258 | ) -> None: 259 | """CLI-wide, global options. 260 | 261 | https://typer.tiangolo.com/tutorial/commands/callback/ 262 | """ 263 | 264 | import logging 265 | 266 | import structlog 267 | from structlog.processors import JSONRenderer, TimeStamper, add_log_level 268 | 269 | from ancv.reflection import METADATA 270 | 271 | structlog.configure( # This is global state 272 | processors=[ # https://www.structlog.org/en/stable/api.html#procs 273 | TimeStamper(fmt="iso", utc=True, key="ts"), 274 | add_log_level, 275 | JSONRenderer(sort_keys=True), 276 | ], 277 | wrapper_class=structlog.make_filtering_bound_logger( 278 | logging.DEBUG if verbose else logging.INFO 279 | ), 280 | ) 281 | 282 | log = structlog.get_logger() 283 | log.debug("Got app metadata.", metadata=METADATA.model_dump()) 284 | 285 | 286 | if __name__ == "__main__": 287 | app() 288 | -------------------------------------------------------------------------------- /ancv/data/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexpovel/ancv/5e7096ea57360ccfb942a0105eef7612c168b90c/ancv/data/__init__.py -------------------------------------------------------------------------------- /ancv/data/models/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexpovel/ancv/5e7096ea57360ccfb942a0105eef7612c168b90c/ancv/data/models/__init__.py -------------------------------------------------------------------------------- /ancv/data/models/github.py: -------------------------------------------------------------------------------- 1 | """This module contains models for the relevant parts of GitHub's API.""" 2 | 3 | import typing as t 4 | from datetime import datetime 5 | 6 | from pydantic import BaseModel, Field, HttpUrl 7 | 8 | 9 | class File(BaseModel): 10 | """Modelling a GitHub gist's file. 11 | 12 | See: https://docs.github.com/en/rest/gists/gists?apiVersion=2022-11-28#list-gists-for-the-authenticated-user, under the "files" key. 13 | """ 14 | 15 | filename: t.Optional[str] = None 16 | type: t.Optional[str] = None 17 | language: t.Optional[str] = None 18 | raw_url: t.Optional[HttpUrl] = None 19 | size: t.Optional[int] = None 20 | 21 | 22 | class GistUser(BaseModel): 23 | """Modelling a GitHub gist's owner/user. 24 | 25 | See: https://docs.github.com/en/rest/gists/gists?apiVersion=2022-11-28#list-gists-for-the-authenticated-user 26 | """ 27 | 28 | name: t.Optional[str] = None 29 | email: t.Optional[str] = None 30 | login: t.Annotated[str, Field(examples=["octocat"])] 31 | id: t.Annotated[int, Field(examples=[1])] 32 | node_id: t.Annotated[str, Field(examples=["MDQ6VXNlcjE="])] 33 | avatar_url: t.Annotated[ 34 | HttpUrl, Field(examples=["https://github.com/images/error/octocat_happy.gif"]) 35 | ] 36 | gravatar_id: t.Annotated[ 37 | t.Optional[str], Field(examples=["41d064eb2195891e12d0413f63227ea7"]) 38 | ] 39 | url: t.Annotated[HttpUrl, Field(examples=["https://api.github.com/users/octocat"])] 40 | html_url: t.Annotated[HttpUrl, Field(examples=["https://github.com/octocat"])] 41 | followers_url: t.Annotated[ 42 | HttpUrl, Field(examples=["https://api.github.com/users/octocat/followers"]) 43 | ] 44 | following_url: t.Annotated[ 45 | str, 46 | Field(examples=["https://api.github.com/users/octocat/following{/other_user}"]), 47 | ] 48 | gists_url: t.Annotated[ 49 | str, Field(examples=["https://api.github.com/users/octocat/gists{/gist_id}"]) 50 | ] 51 | starred_url: t.Annotated[ 52 | str, 53 | Field(examples=["https://api.github.com/users/octocat/starred{/owner}{/repo}"]), 54 | ] 55 | subscriptions_url: t.Annotated[ 56 | HttpUrl, Field(examples=["https://api.github.com/users/octocat/subscriptions"]) 57 | ] 58 | organizations_url: t.Annotated[ 59 | HttpUrl, Field(examples=["https://api.github.com/users/octocat/orgs"]) 60 | ] 61 | repos_url: t.Annotated[ 62 | HttpUrl, Field(examples=["https://api.github.com/users/octocat/repos"]) 63 | ] 64 | events_url: t.Annotated[ 65 | str, Field(examples=["https://api.github.com/users/octocat/events{/privacy}"]) 66 | ] 67 | received_events_url: t.Annotated[ 68 | HttpUrl, 69 | Field(examples=["https://api.github.com/users/octocat/received_events"]), 70 | ] 71 | type: t.Annotated[str, Field(examples=["User"])] 72 | site_admin: bool 73 | starred_at: t.Annotated[ 74 | t.Optional[datetime], Field(None, examples=['"2020-07-09T00:17:55Z"']) 75 | ] 76 | 77 | 78 | class Gist(BaseModel): 79 | """Modelling a GitHub gist. 80 | 81 | See: https://docs.github.com/en/rest/gists/gists?apiVersion=2022-11-28""" 82 | 83 | url: HttpUrl 84 | forks_url: HttpUrl 85 | commits_url: HttpUrl 86 | id: str 87 | node_id: str 88 | git_pull_url: HttpUrl 89 | git_push_url: HttpUrl 90 | html_url: HttpUrl 91 | files: t.Mapping[str, File] 92 | public: bool 93 | created_at: datetime 94 | updated_at: datetime 95 | description: t.Optional[str] = None 96 | comments: int 97 | user: t.Optional[GistUser] = None 98 | comments_url: HttpUrl 99 | owner: t.Optional[GistUser] = None 100 | truncated: t.Optional[bool] = None 101 | -------------------------------------------------------------------------------- /ancv/data/models/resume.py: -------------------------------------------------------------------------------- 1 | """This module contains models for the JSON Resume standard. 2 | 3 | See: https://jsonresume.org/schema/. 4 | """ 5 | 6 | import datetime 7 | import typing as t 8 | 9 | from pydantic import AnyUrl, BaseModel, ConfigDict, EmailStr, Field 10 | 11 | 12 | class Location(BaseModel): 13 | """Modelling a JSON resume location item. 14 | 15 | See: https://github.com/alexpovel/resume-schema/blob/6e3244639cebfa89e66ee60d47c665a96e01a811/schema.json#L50-L73 16 | """ 17 | 18 | model_config = ConfigDict(extra="allow") 19 | 20 | address: t.Annotated[ 21 | t.Optional[str], 22 | Field( 23 | description="To add multiple address lines, use \n. For example, 1234 Glücklichkeit Straße\nHinterhaus 5. Etage li.", 24 | ), 25 | ] = None 26 | postalCode: t.Optional[str] = None 27 | city: t.Optional[str] = None 28 | countryCode: t.Annotated[ 29 | t.Optional[str], 30 | Field(description="code as per ISO-3166-1 ALPHA-2, e.g. US, AU, IN"), 31 | ] = None 32 | region: t.Annotated[ 33 | t.Optional[str], 34 | Field( 35 | description="The general region where you live. Can be a US state, or a province, for instance.", 36 | ), 37 | ] = None 38 | 39 | 40 | class Profile(BaseModel): 41 | """Modelling a JSON resume profile item. 42 | 43 | See: https://github.com/alexpovel/resume-schema/blob/6e3244639cebfa89e66ee60d47c665a96e01a811/schema.json#L74-L99 44 | """ 45 | 46 | model_config = ConfigDict(extra="allow") 47 | 48 | network: t.Annotated[ 49 | t.Optional[str], Field(description="e.g. Facebook or Twitter") 50 | ] = None 51 | username: t.Annotated[ 52 | t.Optional[str], Field(description="e.g. neutralthoughts") 53 | ] = None 54 | url: t.Annotated[ 55 | t.Optional[AnyUrl], 56 | Field(description="e.g. http://twitter.example.com/neutralthoughts"), 57 | ] = None 58 | 59 | 60 | class Basics(BaseModel): 61 | """Modelling a JSON resume basics item. 62 | 63 | See: https://github.com/alexpovel/resume-schema/blob/6e3244639cebfa89e66ee60d47c665a96e01a811/schema.json#L17-L49 64 | """ 65 | 66 | model_config = ConfigDict(extra="allow") 67 | 68 | name: t.Optional[str] = None 69 | label: t.Annotated[t.Optional[str], Field(description="e.g. Web Developer")] = None 70 | image: t.Annotated[ 71 | t.Optional[str], 72 | Field(description="URL (as per RFC 3986) to a image in JPEG or PNG format"), 73 | ] = None 74 | email: t.Annotated[ 75 | t.Optional[EmailStr], Field(description="e.g. thomas@gmail.com") 76 | ] = None 77 | phone: t.Annotated[ 78 | t.Optional[str], 79 | Field( 80 | description="Phone numbers are stored as strings so use any format you like, e.g. 712-117-2923", 81 | ), 82 | ] = None 83 | url: t.Annotated[ 84 | t.Optional[AnyUrl], 85 | Field( 86 | description="URL (as per RFC 3986) to your website, e.g. personal homepage", 87 | ), 88 | ] = None 89 | summary: t.Annotated[ 90 | t.Optional[str], 91 | Field(description="Write a short 2-3 sentence biography about yourself"), 92 | ] = None 93 | location: t.Optional[Location] = None 94 | profiles: t.Annotated[ 95 | t.Optional[list[Profile]], 96 | Field( 97 | description="Specify any number of social networks that you participate in", 98 | ), 99 | ] = None 100 | 101 | 102 | class Certificate(BaseModel): 103 | """Modelling a JSON resume certificate item. 104 | 105 | See: https://github.com/alexpovel/resume-schema/blob/6e3244639cebfa89e66ee60d47c665a96e01a811/schema.json#L264-L292 106 | """ 107 | 108 | model_config = ConfigDict(extra="allow") 109 | 110 | name: t.Annotated[ 111 | t.Optional[str], Field(description="e.g. Certified Kubernetes Administrator") 112 | ] = None 113 | date: t.Annotated[ 114 | t.Optional[datetime.date], Field(description="e.g. 1989-06-12") 115 | ] = None 116 | url: t.Annotated[ 117 | t.Optional[AnyUrl], Field(description="e.g. http://example.com") 118 | ] = None 119 | issuer: t.Annotated[t.Optional[str], Field(description="e.g. CNCF")] = None 120 | 121 | 122 | class Skill(BaseModel): 123 | """Modelling a JSON resume skill item. 124 | 125 | See: https://github.com/alexpovel/resume-schema/blob/6e3244639cebfa89e66ee60d47c665a96e01a811/schema.json#L324-L351 126 | """ 127 | 128 | model_config = ConfigDict(extra="allow") 129 | 130 | name: t.Annotated[t.Optional[str], Field(description="e.g. Web Development")] = None 131 | level: t.Annotated[t.Optional[str], Field(description="e.g. Master")] = None 132 | keywords: t.Annotated[ 133 | t.Optional[list[str]], 134 | Field(description="List some keywords pertaining to this skill"), 135 | ] = None 136 | 137 | 138 | class Language(BaseModel): 139 | """Modelling a JSON resume language item. 140 | 141 | See: https://github.com/alexpovel/resume-schema/blob/6e3244639cebfa89e66ee60d47c665a96e01a811/schema.json#L352-L370 142 | """ 143 | 144 | model_config = ConfigDict(extra="allow") 145 | 146 | language: t.Annotated[ 147 | t.Optional[str], Field(description="e.g. English, Spanish") 148 | ] = None 149 | fluency: t.Annotated[ 150 | t.Optional[str], Field(description="e.g. Fluent, Beginner") 151 | ] = None 152 | 153 | 154 | class Interest(BaseModel): 155 | """Modelling a JSON resume interest item. 156 | 157 | See: https://github.com/alexpovel/resume-schema/blob/6e3244639cebfa89e66ee60d47c665a96e01a811/schema.json#L371-L392 158 | """ 159 | 160 | model_config = ConfigDict(extra="allow") 161 | 162 | name: t.Annotated[t.Optional[str], Field(description="e.g. Philosophy")] = None 163 | keywords: t.Optional[list[str]] = None 164 | 165 | 166 | class Reference(BaseModel): 167 | """Modelling a JSON resume reference item. 168 | 169 | See: https://github.com/alexpovel/resume-schema/blob/6e3244639cebfa89e66ee60d47c665a96e01a811/schema.json#L393-L411 170 | """ 171 | 172 | model_config = ConfigDict(extra="allow") 173 | 174 | name: t.Annotated[t.Optional[str], Field(description="e.g. Timothy Cook")] = None 175 | reference: t.Annotated[ 176 | t.Optional[str], 177 | Field( 178 | description="e.g. Joe blogs was a great employee, who turned up to work at least once a week. He exceeded my expectations when it came to doing nothing.", 179 | ), 180 | ] = None 181 | 182 | 183 | class TemplateConfig(BaseModel): 184 | """Modelling ancv-specific template configuration. 185 | 186 | This controls ancv-specific settings such as the template and theme to use. 187 | It occurs as an additional, but optional field in the JSON resume. 188 | """ 189 | 190 | template: t.Optional[str] = None 191 | theme: t.Optional[str] = None 192 | language: t.Optional[str] = None 193 | ascii_only: t.Optional[bool] = None 194 | dec31_as_year: t.Optional[bool] = None 195 | 196 | 197 | class Meta(BaseModel): 198 | """Modelling a JSON resume meta item. 199 | 200 | See: https://github.com/alexpovel/resume-schema/blob/6e3244639cebfa89e66ee60d47c665a96e01a811/schema.json#L477-L497 201 | """ 202 | 203 | model_config = ConfigDict(extra="allow") 204 | 205 | canonical: t.Annotated[ 206 | t.Optional[AnyUrl], 207 | Field(description="URL (as per RFC 3986) to latest version of this document"), 208 | ] = None 209 | version: t.Annotated[ 210 | t.Optional[str], 211 | Field(description="A version field which follows semver - e.g. v1.0.0"), 212 | ] = None 213 | lastModified: t.Annotated[ 214 | t.Optional[datetime.datetime], 215 | Field(description="Using ISO 8601 with YYYY-MM-DDThh:mm:ss"), 216 | ] = None 217 | config: t.Annotated[ 218 | t.Optional[TemplateConfig], 219 | Field( 220 | alias="ancv", 221 | description="Template configuration to control display", 222 | ), 223 | ] = None 224 | 225 | 226 | class WorkItem(BaseModel): 227 | """Modelling a JSON resume work item. 228 | 229 | See: https://github.com/alexpovel/resume-schema/blob/6e3244639cebfa89e66ee60d47c665a96e01a811/schema.json#L100-L149 230 | """ 231 | 232 | model_config = ConfigDict(extra="allow") 233 | 234 | name: t.Annotated[t.Optional[str], Field(description="e.g. Facebook")] = None 235 | location: t.Annotated[t.Optional[str], Field(description="e.g. Menlo Park, CA")] = ( 236 | None 237 | ) 238 | description: t.Annotated[ 239 | t.Optional[str], Field(description="e.g. Social Media Company") 240 | ] = None 241 | position: t.Annotated[ 242 | t.Optional[str], Field(description="e.g. Software Engineer") 243 | ] = None 244 | url: t.Annotated[ 245 | t.Optional[AnyUrl], Field(description="e.g. http://facebook.example.com") 246 | ] = None 247 | startDate: t.Optional[datetime.date] = None 248 | endDate: t.Optional[datetime.date] = None 249 | summary: t.Annotated[ 250 | t.Optional[str], 251 | Field(description="Give an overview of your responsibilities at the company"), 252 | ] = None 253 | highlights: t.Annotated[ 254 | t.Optional[list[str]], Field(description="Specify multiple accomplishments") 255 | ] = None 256 | 257 | 258 | class VolunteerItem(BaseModel): 259 | """Modelling a JSON resume volunteer item. 260 | 261 | See: https://github.com/alexpovel/resume-schema/blob/6e3244639cebfa89e66ee60d47c665a96e01a811/schema.json#L150-L191 262 | """ 263 | 264 | model_config = ConfigDict(extra="allow") 265 | 266 | organization: t.Annotated[t.Optional[str], Field(description="e.g. Facebook")] = ( 267 | None 268 | ) 269 | position: t.Annotated[ 270 | t.Optional[str], Field(description="e.g. Software Engineer") 271 | ] = None 272 | url: t.Annotated[ 273 | t.Optional[AnyUrl], Field(description="e.g. http://facebook.example.com") 274 | ] = None 275 | startDate: t.Optional[datetime.date] = None 276 | endDate: t.Optional[datetime.date] = None 277 | summary: t.Annotated[ 278 | t.Optional[str], 279 | Field(description="Give an overview of your responsibilities at the company"), 280 | ] = None 281 | highlights: t.Annotated[ 282 | t.Optional[list[str]], 283 | Field(description="Specify accomplishments and achievements"), 284 | ] = None 285 | 286 | 287 | class EducationItem(BaseModel): 288 | """Modelling a JSON resume education item. 289 | 290 | See: https://github.com/alexpovel/resume-schema/blob/6e3244639cebfa89e66ee60d47c665a96e01a811/schema.json#L192-L237 291 | """ 292 | 293 | model_config = ConfigDict(extra="allow") 294 | 295 | institution: t.Annotated[ 296 | t.Optional[str], Field(description="e.g. Massachusetts Institute of Technology") 297 | ] = None 298 | url: t.Annotated[ 299 | t.Optional[AnyUrl], Field(description="e.g. http://facebook.example.com") 300 | ] = None 301 | area: t.Annotated[t.Optional[str], Field(description="e.g. Arts")] = None 302 | studyType: t.Annotated[t.Optional[str], Field(description="e.g. Bachelor")] = None 303 | startDate: t.Optional[datetime.date] = None 304 | endDate: t.Optional[datetime.date] = None 305 | score: t.Annotated[ 306 | t.Optional[str], Field(description="grade point average, e.g. 3.67/4.0") 307 | ] = None 308 | courses: t.Annotated[ 309 | t.Optional[list[str]], Field(description="List notable courses/subjects") 310 | ] = None 311 | 312 | 313 | class Award(BaseModel): 314 | """Modelling a JSON resume award item. 315 | 316 | See: https://github.com/alexpovel/resume-schema/blob/6e3244639cebfa89e66ee60d47c665a96e01a811/schema.json#L238-L263 317 | """ 318 | 319 | model_config = ConfigDict(extra="allow") 320 | 321 | title: t.Annotated[ 322 | t.Optional[str], 323 | Field(description="e.g. One of the 100 greatest minds of the century"), 324 | ] = None 325 | date: datetime.date | None = None 326 | awarder: t.Annotated[t.Optional[str], Field(description="e.g. Time Magazine")] = ( 327 | None 328 | ) 329 | summary: t.Annotated[ 330 | t.Optional[str], 331 | Field(description="e.g. Received for my work with Quantum Physics"), 332 | ] = None 333 | 334 | 335 | class Publication(BaseModel): 336 | """Modelling a JSON resume publication item. 337 | 338 | See: https://github.com/alexpovel/resume-schema/blob/6e3244639cebfa89e66ee60d47c665a96e01a811/schema.json#L293-L323 339 | """ 340 | 341 | model_config = ConfigDict(extra="allow") 342 | 343 | name: t.Annotated[t.Optional[str], Field(description="e.g. The World Wide Web")] = ( 344 | None 345 | ) 346 | publisher: t.Annotated[ 347 | t.Optional[str], Field(description="e.g. IEEE, Computer Magazine") 348 | ] = None 349 | releaseDate: t.Optional[datetime.date] = None 350 | url: t.Annotated[ 351 | t.Optional[AnyUrl], 352 | Field( 353 | description="e.g. http://www.computer.org.example.com/csdl/mags/co/1996/10/rx069-abs.html", 354 | ), 355 | ] = None 356 | summary: t.Annotated[ 357 | t.Optional[str], 358 | Field( 359 | description="Short summary of publication. e.g. Discussion of the World Wide Web, HTTP, HTML.", 360 | ), 361 | ] = None 362 | 363 | 364 | class Project(BaseModel): 365 | """Modelling a JSON resume project item. 366 | 367 | See: https://github.com/alexpovel/resume-schema/blob/6e3244639cebfa89e66ee60d47c665a96e01a811/schema.json#L412-L476 368 | """ 369 | 370 | model_config = ConfigDict(extra="allow") 371 | 372 | name: t.Annotated[t.Optional[str], Field(description="e.g. The World Wide Web")] = ( 373 | None 374 | ) 375 | description: t.Annotated[ 376 | t.Optional[str], 377 | Field(description="Short summary of project. e.g. Collated works of 2017."), 378 | ] = None 379 | highlights: t.Annotated[ 380 | t.Optional[list[str]], Field(description="Specify multiple features") 381 | ] = None 382 | keywords: t.Annotated[ 383 | t.Optional[list[str]], Field(description="Specify special elements involved") 384 | ] = None 385 | startDate: t.Optional[datetime.date] = None 386 | endDate: t.Optional[datetime.date] = None 387 | url: t.Annotated[ 388 | t.Optional[AnyUrl], 389 | Field( 390 | description="e.g. http://www.computer.org/csdl/mags/co/1996/10/rx069-abs.html", 391 | ), 392 | ] = None 393 | roles: t.Annotated[ 394 | t.Optional[list[str]], 395 | Field(description="Specify your role on this project or in company"), 396 | ] = None 397 | entity: t.Annotated[ 398 | t.Optional[str], 399 | Field( 400 | description="Specify the relevant company/entity affiliations e.g. 'greenpeace', 'corporationXYZ'", 401 | ), 402 | ] = None 403 | type: t.Annotated[ 404 | t.Optional[str], 405 | Field( 406 | description=" e.g. 'volunteering', 'presentation', 'talk', 'application', 'conference'", 407 | ), 408 | ] = None 409 | 410 | 411 | class ResumeSchema(BaseModel): 412 | """Modelling a complete JSON resume schema. 413 | 414 | See: https://github.com/alexpovel/resume-schema/blob/6e3244639cebfa89e66ee60d47c665a96e01a811/schema.json 415 | """ 416 | 417 | model_config = ConfigDict(extra="forbid") 418 | 419 | schema_: t.Annotated[ 420 | t.Optional[AnyUrl], 421 | Field( 422 | alias="$schema", 423 | description="link to the version of the schema that can validate the resume", 424 | ), 425 | ] = None 426 | basics: t.Optional[Basics] = None 427 | work: t.Optional[list[WorkItem]] = None 428 | volunteer: t.Optional[list[VolunteerItem]] = None 429 | education: t.Optional[list[EducationItem]] = None 430 | awards: t.Annotated[ 431 | t.Optional[list[Award]], 432 | Field( 433 | description="Specify any awards you have received throughout your professional career", 434 | ), 435 | ] = None 436 | certificates: t.Annotated[ 437 | t.Optional[list[Certificate]], 438 | Field( 439 | description="Specify any certificates you have received throughout your professional career", 440 | ), 441 | ] = None 442 | publications: t.Annotated[ 443 | t.Optional[list[Publication]], 444 | Field(description="Specify your publications through your career"), 445 | ] = None 446 | skills: t.Annotated[ 447 | t.Optional[list[Skill]], 448 | Field(description="List out your professional skill-set"), 449 | ] = None 450 | languages: t.Annotated[ 451 | t.Optional[list[Language]], 452 | Field(description="List any other languages you speak"), 453 | ] = None 454 | interests: t.Optional[list[Interest]] = None 455 | references: t.Annotated[ 456 | t.Optional[list[Reference]], 457 | Field(description="List references you have received"), 458 | ] = None 459 | projects: t.Annotated[ 460 | t.Optional[list[Project]], Field(description="Specify career projects") 461 | ] = None 462 | meta: t.Annotated[ 463 | Meta, 464 | Field( 465 | description="The schema version and any other tooling configuration lives here", 466 | ), 467 | ] = Meta() 468 | 469 | 470 | ResumeItem = t.Union[ 471 | Award, 472 | Basics, 473 | Certificate, 474 | EducationItem, 475 | Interest, 476 | Language, 477 | Location, 478 | Profile, 479 | Project, 480 | Publication, 481 | Reference, 482 | Skill, 483 | VolunteerItem, 484 | WorkItem, 485 | ] 486 | -------------------------------------------------------------------------------- /ancv/data/showcase.resume.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://raw.githubusercontent.com/alexpovel/ancv/v1.2.0/schema.json", 3 | "basics": { 4 | "name": "Jane A. Cooper", 5 | "label": "👋 Fullstack Developer", 6 | "image": "http://example.com/jane.jpg", 7 | "email": "janecooper@example.com", 8 | "phone": "+44 113 496 4389", 9 | "url": "https://cooper.example.dev", 10 | "summary": "Passionate and creative fullstack developer with over 7 years of experience in building and maintaining web applications. I have a strong background in JavaScript and PHP, and I am proficient in a variety of frameworks and libraries. I am also experienced in building and maintaining RESTful APIs.", 11 | "location": { 12 | "address": "1995 Little Acres Lane", 13 | "postalCode": "CA 94115", 14 | "city": "Carrollton", 15 | "countryCode": "US", 16 | "region": "Texas" 17 | }, 18 | "profiles": [ 19 | { 20 | "network": "🐤 Twitter", 21 | "username": "janethecoopest", 22 | "url": "https://twitter.com/janethecoopest" 23 | }, 24 | { 25 | "network": "💼 LinkedIn", 26 | "username": "jane-a-cooper", 27 | "url": "https://linkedin.com/jane-a-cooper" 28 | }, 29 | { 30 | "network": "🐱 GitHub", 31 | "username": "janec", 32 | "url": "https://github.com/janec" 33 | } 34 | ] 35 | }, 36 | "work": [ 37 | { 38 | "name": "Google Inc.", 39 | "location": "San Francisco, CA", 40 | "description": "Internal tools and services.", 41 | "position": "Software Engineer III", 42 | "url": "https://google.com", 43 | "startDate": "2016-03-30", 44 | "summary": "Work included Borg (Google's internal cluster management system), Google's internal build system, and Google's internal continuous integration system.\n\nIn a pilot project, I also worked on reimagining Google's internal documentation management.", 45 | "highlights": [ 46 | "Wrote a new build system for Google's internal continuous integration system.", 47 | "Improved performance of Google's internal Borg deployment by 18%." 48 | ] 49 | } 50 | ], 51 | "education": [ 52 | { 53 | "institution": "Massachusets Institute of Technology", 54 | "url": "https://www.mit.edu/", 55 | "area": "Computer Science (Distributed Systems)", 56 | "studyType": "Bachelor of Science", 57 | "startDate": "2012-10-01", 58 | "endDate": "2016-01-01", 59 | "score": "3.91", 60 | "courses": [ 61 | "6.1600: Foundations of Computer Security", 62 | "6.1810: Operating System Engineering", 63 | "6.1820: Mobile and Sensor Computing", 64 | "6.5831: Database Systems" 65 | ] 66 | }, 67 | { 68 | "institution": "Fairwarden High School", 69 | "url": "https://www.fairwardenhs.org/", 70 | "area": "Maths and Computer Science Track", 71 | "studyType": "High School Diploma", 72 | "startDate": "2007-12-31", 73 | "endDate": "2011-12-01", 74 | "score": "3.64", 75 | "courses": [ 76 | "AP Computer Science", 77 | "AP Calculus", 78 | "AP Statistics" 79 | ] 80 | } 81 | ], 82 | "certificates": [ 83 | { 84 | "name": "Google Certified Professional Cloud Architect", 85 | "issuer": "Google", 86 | "date": "2019-02-01", 87 | "url": "https://cloud.google.com/certification/cloud-architect", 88 | "summary": "Demonstrated proficiency in designing, developing, managing, and monitoring cloud-based applications and infrastructure." 89 | } 90 | ], 91 | "awards": [ 92 | { 93 | "title": "Google Code-in Finalist 🏆", 94 | "date": "2011-12-01", 95 | "awarder": "Google", 96 | "summary": "Finalist for Google Code-in, a global contest for pre-university students to introduce them to open source software development." 97 | } 98 | ], 99 | "interests": [ 100 | { 101 | "name": "Open Source", 102 | "keywords": [ 103 | "Software Development", 104 | "Linux", 105 | "Maintenance" 106 | ] 107 | }, 108 | { 109 | "name": "Music", 110 | "keywords": [ 111 | "Guitar", 112 | "Folk Rock" 113 | ] 114 | } 115 | ], 116 | "languages": [ 117 | { 118 | "language": "English", 119 | "fluency": "Native speaker" 120 | }, 121 | { 122 | "language": "Spanish", 123 | "fluency": "Professional working proficiency" 124 | } 125 | ], 126 | "projects": [ 127 | { 128 | "name": "AWS Benchmarking Tool", 129 | "description": "A tool for benchmarking the performance of various AWS services, including EC2, S3, and Lambda. Written in Python.", 130 | "url": "https://github.com/janec/aws-benchmarking-tool", 131 | "highlights": [ 132 | "100% code coverage and fully typed", 133 | "Can run as a CLI or as a web service, with OpenAPI documentation", 134 | "Full support for parallel execution" 135 | ], 136 | "keywords": [ 137 | "AWS", 138 | "Benchmarking", 139 | "Python", 140 | "Infrastructure" 141 | ], 142 | "startDate": "2015-11-30" 143 | } 144 | ], 145 | "publications": [ 146 | { 147 | "name": "Modern Approaches to Distributed Systems", 148 | "publisher": "MIT Press", 149 | "releaseDate": "2015-12-01", 150 | "url": "https://mitpress.mit.edu/books/new-approach-distributed-systems", 151 | "summary": "A comprehensive literature overview to distributed systems, with a focus on the practical aspects of building and maintenance." 152 | } 153 | ], 154 | "references": [ 155 | { 156 | "name": "Tom T. Baker", 157 | "reference": "Jane is a very talented developer and lovely teammate. They are a pleasure to work with, and I would recommend them to any company." 158 | } 159 | ], 160 | "skills": [ 161 | { 162 | "name": "Web Development", 163 | "level": "Expert", 164 | "keywords": [ 165 | "HTML", 166 | "CSS", 167 | "JavaScript (ES6+)", 168 | "PHP" 169 | ] 170 | }, 171 | { 172 | "name": "Python", 173 | "level": "Proficient", 174 | "keywords": [ 175 | "Django", 176 | "Flask", 177 | "Pyramid" 178 | ] 179 | }, 180 | { 181 | "name": "Databases", 182 | "level": "Intermediate", 183 | "keywords": [ 184 | "PostgreSQL", 185 | "MySQL", 186 | "MongoDB" 187 | ] 188 | }, 189 | { 190 | "name": "Cloud", 191 | "level": "Expert", 192 | "keywords": [ 193 | "AWS", 194 | "Google Cloud", 195 | "Azure" 196 | ] 197 | } 198 | ], 199 | "volunteer": [ 200 | { 201 | "organization": "Shelter for Homeless Cats", 202 | "position": "Volunteer", 203 | "url": "https://example.com/catshelter", 204 | "startDate": "2009-07-01", 205 | "endDate": "2019-06-01", 206 | "summary": "Provided food and shelter for homeless cats.", 207 | "highlights": [ 208 | "Cared for 20 cats", 209 | "Raised $3400 for food and supplies", 210 | "Organized adoption events" 211 | ] 212 | } 213 | ], 214 | "meta": { 215 | "version": "v1.0.0", 216 | "canonical": "https://github.com/jsonresume/resume-schema/blob/v1.0.0/schema.json", 217 | "ancv": { 218 | "template": "Sequential", 219 | "theme": "lollipop", 220 | "ascii_only": false, 221 | "language": "en", 222 | "dec31_as_year": true 223 | } 224 | } 225 | } 226 | -------------------------------------------------------------------------------- /ancv/data/validation.py: -------------------------------------------------------------------------------- 1 | import re 2 | from functools import cache 3 | from string import ascii_letters, digits 4 | 5 | 6 | @cache 7 | def is_valid_github_username(name: str) -> bool: 8 | """Checks whether a name is a valid GitHub username. 9 | 10 | From trying to register, we can gather: 11 | 12 | - "Username is too long (maximum is 39 characters)." 13 | - "Username may only contain alphanumeric characters or single hyphens, and 14 | cannot begin or end with a hyphen." 15 | 16 | Decided to do this vanilla, since the corresponding regex is (un)surprisingly 17 | unreadable. 18 | """ 19 | hyphen = "-" 20 | 21 | if not name or len(name) > 39: 22 | return False 23 | 24 | alphanumeric = ascii_letters + digits 25 | legal = set(alphanumeric + hyphen) 26 | 27 | illegal_characters = set(name) - legal 28 | if illegal_characters: 29 | return False 30 | 31 | if name[0] == hyphen or name[-1] == hyphen: 32 | return False 33 | 34 | if re.search("[-]{2,}", name): 35 | return False 36 | 37 | return True 38 | -------------------------------------------------------------------------------- /ancv/exceptions.py: -------------------------------------------------------------------------------- 1 | class ResumeLookupError(LookupError): 2 | """Raised when a user's resume cannot be found, is malformed, ...""" 3 | 4 | pass 5 | 6 | 7 | class ResumeConfigError(ValueError): 8 | """Raised when a resume config is invalid, e.g. missing required fields.""" 9 | 10 | pass 11 | -------------------------------------------------------------------------------- /ancv/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexpovel/ancv/5e7096ea57360ccfb942a0105eef7612c168b90c/ancv/py.typed -------------------------------------------------------------------------------- /ancv/reflection.py: -------------------------------------------------------------------------------- 1 | import typing as t 2 | from importlib.metadata import metadata 3 | 4 | from pydantic import ( 5 | AnyUrl, 6 | BaseModel, 7 | EmailStr, 8 | Field, 9 | computed_field, 10 | ) 11 | 12 | from ancv import PACKAGE 13 | 14 | 15 | class Metadata(BaseModel): 16 | """Modeling Python package metadata. 17 | 18 | Modelled after the Python core metadata specification: 19 | https://packaging.python.org/en/latest/specifications/core-metadata/ . 20 | Not all fields were implemented for lack of ability of testing. 21 | 22 | For more context, see: 23 | 24 | - https://docs.python.org/3/library/importlib.metadata.html#metadata 25 | - https://peps.python.org/pep-0566/ 26 | """ 27 | 28 | metadata_version: t.Annotated[ 29 | str, 30 | Field( 31 | description="Version of the metadata format, e.g. '2.1'", 32 | ), 33 | ] 34 | name: t.Annotated[ 35 | str, 36 | Field( 37 | description="Name of the package, e.g. 'ancv'", 38 | pattern=r"(?i)^([A-Z0-9]|[A-Z0-9][A-Z0-9._-]*[A-Z0-9])$", 39 | ), 40 | ] 41 | version: t.Annotated[ 42 | str, 43 | Field( 44 | description="Version of the package, e.g. '0.1.0'", 45 | # https://peps.python.org/pep-0440/#appendix-b-parsing-version-strings-with-regular-expressions 46 | pattern=r"^([1-9][0-9]*!)?(0|[1-9][0-9]*)(\.(0|[1-9][0-9]*))*((a|b|rc)(0|[1-9][0-9]*))?(\.post(0|[1-9][0-9]*))?(\.dev(0|[1-9][0-9]*))?$", 47 | ), 48 | ] 49 | summary: t.Annotated[ 50 | t.Optional[str], 51 | Field( 52 | description="One-line summary of the package, e.g. 'Ancv is a package for ...'", 53 | ), 54 | ] = None 55 | download_url: t.Annotated[ 56 | t.Optional[AnyUrl], 57 | Field(description="URL to download this version of the package"), 58 | ] = None 59 | license: t.Annotated[ 60 | t.Optional[str], Field(description="License of the package, e.g. 'MIT'") 61 | ] = None 62 | author: t.Annotated[ 63 | t.Optional[str], 64 | Field(description="Author of the package, e.g. 'John Doe'"), 65 | ] = None 66 | author_email: t.Annotated[ 67 | t.Optional[EmailStr], 68 | Field(description="Email of the author, e.g. john@doe.com'"), 69 | ] = None 70 | requires_python: t.Annotated[ 71 | t.Optional[str], 72 | Field( 73 | description="Python version required by the package, e.g. '>=3.6'", 74 | ), 75 | ] = None 76 | classifier: t.Annotated[ 77 | t.Optional[list[str]], 78 | Field( 79 | description="Classifiers of the package, e.g. 'Programming Language :: Python :: 3.6'", 80 | ), 81 | ] = None 82 | requires_dist: t.Annotated[ 83 | t.Optional[list[str]], 84 | Field( 85 | description="Distributions required by the package, e.g. 'aiohttp[speedups] (>=3.8.1,<4.0.0)'", 86 | ), 87 | ] = None 88 | project_url: t.Annotated[ 89 | t.Optional[list[str]], 90 | Field( 91 | description="Project URLs of the package, e.g. 'Repository, https://github.com/namespace/ancv/'", 92 | ), 93 | ] = None 94 | description_content_type: t.Annotated[ 95 | t.Optional[str], 96 | Field( 97 | description="Content type of the description, e.g. 'text/plain'", 98 | ), 99 | ] = None 100 | description: t.Annotated[ 101 | t.Optional[str], 102 | Field( 103 | description="Long description of the package, e.g. 'Ancv is a package for ...'", 104 | ), 105 | ] = None 106 | 107 | @computed_field # type: ignore[prop-decorator] 108 | @property 109 | def project_urls( 110 | self, 111 | ) -> t.Annotated[ 112 | dict[str, str], 113 | Field( 114 | description="Map of project URLs, e.g. {'Homepage': 'https://ancv.povel.dev/'}" 115 | ), 116 | ]: 117 | """Converts the 'Name, https://example.com' array of project URLs to a dict. 118 | 119 | https://packaging.python.org/en/latest/guides/writing-pyproject-toml/#urls 120 | """ 121 | 122 | urls: dict[str, str] = dict() 123 | 124 | if self.project_url is None: 125 | return urls 126 | 127 | for url in self.project_url: 128 | name, url = url.split(",") 129 | urls[name.strip()] = url.strip() 130 | 131 | return urls 132 | 133 | 134 | METADATA = Metadata.model_validate(metadata(PACKAGE).json) 135 | -------------------------------------------------------------------------------- /ancv/timing.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass, field 2 | from datetime import timedelta 3 | from time import perf_counter 4 | from typing import Optional 5 | 6 | 7 | @dataclass 8 | class Stopwatch: 9 | """A simple stopwatch for timing execution. 10 | 11 | Call it with a segment name, and it will start timing that segment, stopping when it 12 | is called again with the next segment or explicitly with `stop()`. 13 | 14 | The results are available in the `timings` attribute. 15 | """ 16 | 17 | timings: dict[str, timedelta] = field(default_factory=dict) 18 | _start: Optional[float] = field(repr=False, default=None) 19 | _current_segment: Optional[str] = field(repr=False, default=None) 20 | _finished: bool = field(repr=False, default=False) 21 | 22 | def __getitem__(self, key: str) -> timedelta: 23 | return self.timings[key] 24 | 25 | def __call__(self, segment: str) -> None: 26 | stop = perf_counter() 27 | 28 | if segment in self.timings or segment == self._current_segment: 29 | raise ValueError(f"Segment '{segment}' already exists.") 30 | 31 | if self._current_segment is not None and self._start is not None: 32 | self.timings[self._current_segment] = timedelta(seconds=stop - self._start) 33 | 34 | if self._finished: 35 | self._start = None 36 | self._current_segment = None 37 | self._finished = False 38 | else: 39 | self._start = perf_counter() 40 | self._current_segment = segment 41 | 42 | def stop(self) -> None: 43 | """Stops the current segment and adds it to the timings. 44 | 45 | Calling the stopwatch again with a new segment will restart it. 46 | """ 47 | self._finished = True 48 | self(segment="__final_segment__") 49 | -------------------------------------------------------------------------------- /ancv/typehelp.py: -------------------------------------------------------------------------------- 1 | import typing as t 2 | 3 | T = t.TypeVar("T") 4 | 5 | 6 | def unwrap(value: None | T) -> T: 7 | """Like Rust.""" 8 | 9 | if value is None: 10 | raise ValueError("Provided value is `None`") 11 | 12 | return value 13 | -------------------------------------------------------------------------------- /ancv/visualization/__init__.py: -------------------------------------------------------------------------------- 1 | from typing import Final, Generator 2 | 3 | from rich.console import RenderableType 4 | 5 | RenderableGenerator = Generator[RenderableType, None, None] 6 | 7 | OUTPUT_COLUMN_WIDTH: Final = 120 8 | -------------------------------------------------------------------------------- /ancv/visualization/themes.py: -------------------------------------------------------------------------------- 1 | from babel.dates import DateTimePattern, parse_pattern 2 | from pydantic import ConfigDict, BaseModel 3 | from rich.style import Style 4 | 5 | 6 | class Emphasis(BaseModel): 7 | """Emphasis styles for different levels of importance. 8 | 9 | Using `rich.Style`, each can be styled arbitrarily. 10 | """ 11 | 12 | maximum: Style 13 | strong: Style 14 | medium: Style 15 | weak: Style 16 | model_config = ConfigDict(arbitrary_types_allowed=True) 17 | 18 | 19 | class DateFormat(BaseModel): 20 | """Date formats for different levels of detail. 21 | 22 | The `full` format is as detailed as possible, while `year_only` should only contain 23 | the year. Formats may otherwise be in whatever format you desire (ISO8601, 24 | localized, months spelled out etc.). For more context, see: 25 | https://babel.pocoo.org/en/latest/dates.html 26 | 27 | Using `babel.dates.DateTimePattern` and forcing it here over `str` allows for 28 | considerably better type safety (`str` is the worst offender in terms of typing) and 29 | fast failure: at application startup, when a theme is loaded but `parse_pattern` (or 30 | similar) fails, the program won't launch altogether, instead of failing at runtime. 31 | """ 32 | 33 | full: DateTimePattern 34 | year_only: DateTimePattern 35 | model_config = ConfigDict(arbitrary_types_allowed=True) 36 | 37 | 38 | class Theme(BaseModel): 39 | """A theme, containing styles and other formatting options.""" 40 | 41 | emphasis: Emphasis # styles for different levels of importance 42 | bullet: str # bullet character to use in (unordered) lists 43 | rulechar: str # character for *horizontal* rules 44 | sep: str # separator character for joined-together strings (e.g. "•" for "foo•bar") 45 | range_sep: str # separator character for ranges (e.g. "..." for "2010...2020") 46 | datefmt: DateFormat # date formats in different levels of detail 47 | 48 | 49 | # See here for available colors: 50 | # https://rich.readthedocs.io/en/stable/appendix/colors.html#appendix-colors 51 | 52 | THEMES = { 53 | "plain": Theme( 54 | emphasis=Emphasis( 55 | maximum=Style(), 56 | strong=Style(), 57 | medium=Style(), 58 | weak=Style(), 59 | ), 60 | bullet="•", 61 | sep="•", 62 | range_sep="–", 63 | rulechar="─", 64 | datefmt=DateFormat( 65 | full=parse_pattern("yyyy-MM"), year_only=parse_pattern("yyyy") 66 | ), 67 | ), 68 | "grayscale": Theme( 69 | emphasis=Emphasis( 70 | maximum=Style(color="grey93"), 71 | strong=Style(color="grey74"), 72 | medium=Style(color="grey58"), 73 | weak=Style(color="grey42"), 74 | ), 75 | bullet="*", 76 | sep="*", 77 | range_sep="–", 78 | rulechar="─", 79 | datefmt=DateFormat( 80 | full=parse_pattern("MMMM yyyy"), year_only=parse_pattern("yyyy") 81 | ), 82 | ), 83 | "basic": Theme( 84 | emphasis=Emphasis( 85 | maximum=Style(bold=True), 86 | strong=Style(italic=True), 87 | medium=Style(), 88 | weak=Style(dim=True), 89 | ), 90 | bullet="•", 91 | sep="•", 92 | range_sep="–", 93 | rulechar="─", 94 | datefmt=DateFormat( 95 | full=parse_pattern("MMMM yyyy"), year_only=parse_pattern("yyyy") 96 | ), 97 | ), 98 | "lollipop": Theme( 99 | emphasis=Emphasis( 100 | maximum=Style(bold=True, color="sandy_brown"), 101 | strong=Style(italic=True, color="pale_green3"), 102 | medium=Style(color="sky_blue1"), 103 | weak=Style(color="thistle3"), 104 | ), 105 | bullet="➔", 106 | sep="•", 107 | range_sep="➔", 108 | rulechar="─", 109 | datefmt=DateFormat( 110 | full=parse_pattern("MMMM yyyy"), year_only=parse_pattern("yyyy") 111 | ), 112 | ), 113 | "hendrix": Theme( 114 | emphasis=Emphasis( 115 | maximum=Style(blink=True, bold=True, color="sandy_brown"), 116 | strong=Style(blink=True, italic=True, color="pale_green3"), 117 | medium=Style(blink=True, color="sky_blue1"), 118 | weak=Style(blink=True, color="thistle3"), 119 | ), 120 | bullet="➔", 121 | sep="•", 122 | range_sep="➔", 123 | rulechar="─", 124 | datefmt=DateFormat( 125 | full=parse_pattern("MMMM yyyy"), year_only=parse_pattern("yyyy") 126 | ), 127 | ), 128 | } 129 | -------------------------------------------------------------------------------- /ancv/visualization/translations.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel 2 | 3 | 4 | class Translation(BaseModel): 5 | """Modelling a translation for a resume section or field. 6 | 7 | These are simple, hard-coded translations. Special grammatical cases, singular vs. 8 | plural, etc. are not handled and need to be handled identically across all languages 9 | (which might end up not working...). 10 | """ 11 | 12 | grade: str 13 | awarded_by: str 14 | issued_by: str 15 | roles: str 16 | skills: str 17 | work: str 18 | volunteer: str 19 | education: str 20 | awards: str 21 | certificates: str 22 | publications: str 23 | languages: str 24 | references: str 25 | interests: str 26 | projects: str 27 | present: str 28 | 29 | 30 | TRANSLATIONS = { 31 | "en": Translation( 32 | grade="Grade", 33 | awarded_by="awarded by", 34 | issued_by="issued by", 35 | roles="Roles", 36 | skills="Skills", 37 | work="Experience", 38 | volunteer="Volunteer", 39 | education="Education", 40 | awards="Awards", 41 | certificates="Certificates", 42 | publications="Publications", 43 | languages="Languages", 44 | references="References", 45 | interests="Interests", 46 | projects="Projects", 47 | present="present", 48 | ), 49 | "de": Translation( 50 | grade="Note", 51 | awarded_by="verliehen von", 52 | issued_by="ausgestellt von", 53 | roles="Rollen", 54 | skills="Fähigkeiten", 55 | work="Erfahrung", 56 | volunteer="Ehrenamtliche Arbeit", 57 | education="Ausbildung", 58 | awards="Auszeichnungen", 59 | certificates="Zertifikate", 60 | publications="Publikationen", 61 | languages="Sprachen", 62 | references="Referenzen", 63 | interests="Interessen", 64 | projects="Projekte", 65 | present="heute", 66 | ), 67 | "es": Translation( 68 | grade="Nota", 69 | awarded_by="otorgado por", 70 | issued_by="emitido por", 71 | roles="Funciones", 72 | skills="Conocimientos y aptitudes", 73 | work="Experiencia", 74 | volunteer="Voluntariado", 75 | education="Educación", 76 | awards="Premios", 77 | certificates="Certificaciones", 78 | publications="Publicaciones", 79 | languages="Idiomas", 80 | references="Referencias", 81 | interests="Intereses", 82 | projects="Proyectos", 83 | present="actualidad", 84 | ), 85 | "fr": Translation( 86 | grade="Note", 87 | awarded_by="décerné par", 88 | issued_by="délivré par", 89 | roles="Rôles", 90 | skills="Compétences", 91 | work="Expérience", 92 | volunteer="Bénévolat", 93 | education="Formation", 94 | awards="Distinctions", 95 | certificates="Certifications", 96 | publications="Publications", 97 | languages="Langues", 98 | references="Références", 99 | interests="Intérêts", 100 | projects="Projets", 101 | present="aujourd'hui", 102 | ), 103 | } 104 | -------------------------------------------------------------------------------- /ancv/web/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexpovel/ancv/5e7096ea57360ccfb942a0105eef7612c168b90c/ancv/web/__init__.py -------------------------------------------------------------------------------- /ancv/web/client.py: -------------------------------------------------------------------------------- 1 | import json 2 | from http import HTTPStatus 3 | from types import SimpleNamespace 4 | 5 | import gidgethub 6 | from gidgethub.aiohttp import GitHubAPI 7 | from humanize import naturalsize 8 | from pydantic import ValidationError 9 | from structlog import get_logger 10 | 11 | from ancv import SIPrefix 12 | from ancv.data.models.github import Gist 13 | from ancv.data.models.resume import ResumeSchema 14 | from ancv.exceptions import ResumeLookupError 15 | from ancv.timing import Stopwatch 16 | 17 | LOGGER = get_logger() 18 | 19 | 20 | async def get_resume( 21 | user: str, 22 | github: GitHubAPI, 23 | stopwatch: Stopwatch, 24 | filename: str = "resume.json", 25 | size_limit: int = 1 * SIPrefix.MEGA, 26 | ) -> ResumeSchema: 27 | """Fetch a user's resume from their GitHub gists. 28 | 29 | Searches through all of the user's gists for a file with a given name. Checks for 30 | various bad states: 31 | 32 | - User... 33 | - doesn't exist. 34 | - has no gists. 35 | - has no gists with the given filename. 36 | - File... 37 | - is too large. 38 | - is not valid JSON. 39 | - is not valid against the resume schema. 40 | 41 | There are others that are probably not covered (hard to test). 42 | 43 | Sections of the code are timed for performance analysis. 44 | 45 | Args: 46 | user: The GitHub username to fetch the resume from. 47 | github: The API object to use for the request. 48 | stopwatch: The `Stopwatch` to use for timing. 49 | filename: The name of the file to look for in the user's gists. 50 | size_limit: The maximum size of the file to look for in the user's gists. 51 | 52 | Returns: 53 | The parsed resume. 54 | """ 55 | 56 | log = LOGGER.bind(user=user) 57 | 58 | stopwatch("Fetching Gists") 59 | gists = github.getiter(f"/users/{user}/gists") 60 | while True: 61 | try: 62 | raw_gist = await anext(gists) 63 | except StopAsyncIteration: 64 | raise ResumeLookupError( 65 | f"No '{filename}' file found in any gist of '{user}'." 66 | ) 67 | except gidgethub.BadRequest as e: 68 | # `except `RateLimitExceeded` didn't work, it seems it's not correctly 69 | # raised inside `gidgethub`. 70 | if e.status_code == HTTPStatus.FORBIDDEN: 71 | raise ResumeLookupError( 72 | "Server exhausted its GitHub API rate limit, terribly sorry!" 73 | + " Please try again later." 74 | ) 75 | if e.status_code == HTTPStatus.NOT_FOUND: 76 | raise ResumeLookupError(f"User {user} not found.") 77 | raise e 78 | 79 | log.info("Got raw gist of user.") 80 | gist = Gist(**raw_gist) 81 | log = log.bind(gist_url=gist.url) 82 | log.info("Parsed gist of user.") 83 | 84 | # https://peps.python.org/pep-0636/#matching-against-constants-and-enums : 85 | obj = SimpleNamespace() # Direct kwargs passing isn't mypy-friendly. 86 | obj.filename = filename 87 | 88 | match gist: # noqa: E999 89 | case Gist(files={obj.filename: file}): 90 | log.info("Gist matched.") 91 | break 92 | case _: 93 | log.info("Gist unsuitable, trying next.") 94 | 95 | if file.size is None or file.size > size_limit: 96 | size = "unknown" if file.size is None else str(naturalsize(file.size)) 97 | raise ResumeLookupError( 98 | "Resume file too large" f" (limit: {naturalsize(size_limit)}, got {size})." 99 | ) 100 | log.info("Fetching resume contents of user.") 101 | raw_resume: str = await github.getitem(str(file.raw_url)) 102 | log.info("Got raw resume of user.") 103 | 104 | stopwatch("Validation") 105 | try: 106 | resume = ResumeSchema(**json.loads(raw_resume)) 107 | except json.decoder.JSONDecodeError: 108 | raise ResumeLookupError("Got malformed JSON.") 109 | except ValidationError: 110 | raise ResumeLookupError( 111 | "Got legal JSON but wrong schema (cf. https://jsonresume.org/schema/)" 112 | ) 113 | 114 | log.info("Successfully parsed raw resume of user, returning.") 115 | return resume 116 | -------------------------------------------------------------------------------- /ancv/web/server.py: -------------------------------------------------------------------------------- 1 | import json 2 | import time 3 | from abc import ABC, abstractmethod 4 | from dataclasses import dataclass 5 | from datetime import timedelta 6 | from http import HTTPStatus 7 | from pathlib import Path 8 | from pydantic import ValidationError 9 | from typing import AsyncGenerator, Optional 10 | 11 | from aiohttp import ClientSession, ClientError, web 12 | from cachetools import TTLCache 13 | from gidgethub.aiohttp import GitHubAPI 14 | from structlog import get_logger 15 | 16 | from ancv import PROJECT_ROOT 17 | from ancv.data.models.resume import ResumeSchema 18 | from ancv.data.validation import is_valid_github_username 19 | from ancv.exceptions import ResumeConfigError, ResumeLookupError 20 | from ancv.timing import Stopwatch 21 | from ancv.visualization.templates import Template 22 | from ancv.web.client import get_resume 23 | 24 | LOGGER = get_logger() 25 | 26 | SHOWCASE_RESUME = Template.from_file( 27 | PROJECT_ROOT / "data" / "showcase.resume.json" 28 | ).render() 29 | 30 | SHOWCASE_USERNAME = "heyho" 31 | 32 | 33 | def is_terminal_client(user_agent: str) -> bool: 34 | """Determines if a user agent string indicates a terminal client.""" 35 | 36 | terminal_clients = [ 37 | "curl", 38 | "wget", 39 | "powershell", 40 | ] 41 | 42 | for client in terminal_clients: 43 | if client.lower() in user_agent.lower(): 44 | return True 45 | return False 46 | 47 | 48 | @dataclass 49 | class ServerContext: 50 | """Context for the server.""" 51 | 52 | host: Optional[str] 53 | port: Optional[int] 54 | path: Optional[str] 55 | 56 | 57 | class Runnable(ABC): 58 | """A server object that can be `run`, enabling different server implementations.""" 59 | 60 | @abstractmethod 61 | def run(self, context: ServerContext) -> None: ... 62 | 63 | 64 | class APIHandler(Runnable): 65 | """A runnable server for handling dynamic API requests. 66 | 67 | This is the core application server powering the API. It is responsible for handling 68 | requests for the resume of a given user, and returning the appropriate response. It 69 | queries the live GitHub API. 70 | """ 71 | 72 | def __init__( 73 | self, 74 | requester: str, 75 | token: Optional[str], 76 | terminal_landing_page: str, 77 | browser_landing_page: str, 78 | ) -> None: 79 | """Initializes the handler. 80 | 81 | Args: 82 | requester: The user agent to use for the GitHub API requests. 83 | token: The token to use for the GitHub API requests. 84 | terminal_landing_page: URL to "redirect" to for requests to the root from a 85 | *terminal* client. 86 | browser_landing_page: URL to redirect to for requests to the root from a 87 | *browser* client. 88 | """ 89 | 90 | self.requester = requester 91 | self.token = token 92 | self.terminal_landing_page = terminal_landing_page 93 | self.browser_landing_page = browser_landing_page 94 | 95 | LOGGER.debug("Instantiating web application.") 96 | self.app = web.Application() 97 | 98 | LOGGER.debug("Adding routes.") 99 | self.app.add_routes( 100 | [ 101 | # Order matters, see also https://www.grandmetric.com/2020/07/08/routing-order-in-aiohttp-library-in-python/ 102 | web.get("/", self.root), 103 | web.get(f"/{SHOWCASE_USERNAME}", self.showcase), 104 | web.get("/{username}", self.username), 105 | ] 106 | ) 107 | 108 | self.app.cleanup_ctx.append(self.app_context) 109 | 110 | def run(self, context: ServerContext) -> None: 111 | LOGGER.info("Loaded, starting server...") 112 | web.run_app(self.app, host=context.host, port=context.port, path=context.path) 113 | 114 | async def app_context(self, app: web.Application) -> AsyncGenerator[None, None]: 115 | """For an `aiohttp.web.Application`, provides statefulness by attaching objects. 116 | 117 | See also: 118 | - https://docs.aiohttp.org/en/stable/web_advanced.html#data-sharing-aka-no-singletons-please 119 | - https://docs.aiohttp.org/en/stable/web_advanced.html#cleanup-context 120 | 121 | Args: 122 | app: The app instance to attach our state to. It can later be retrieved, 123 | such that all app components use the same session etc. 124 | """ 125 | log = LOGGER.bind(app=app) 126 | log.debug("App context initialization starting.") 127 | 128 | log.debug("Starting client session.") 129 | session = ClientSession() 130 | log = log.bind(session=session) 131 | log.debug("Started client session.") 132 | 133 | log.debug("Creating GitHub API instance.") 134 | github = GitHubAPI( 135 | session, 136 | requester=self.requester, 137 | oauth_token=self.token, 138 | cache=TTLCache(maxsize=1e2, ttl=60), 139 | ) 140 | log = log.bind(github=github) 141 | log.debug("Created GitHub API instance.") 142 | 143 | app["client_session"] = session 144 | app["github"] = github 145 | 146 | log.debug("App context initialization done, yielding.") 147 | 148 | yield 149 | 150 | log.debug("App context teardown starting.") 151 | 152 | log.debug("Closing client session.") 153 | await app["client_session"].close() 154 | log.debug("Closed client session.") 155 | 156 | log.info("App context teardown done.") 157 | 158 | async def root(self, request: web.Request) -> web.Response: 159 | """The root endpoint, redirecting to the landing page.""" 160 | 161 | user_agent = request.headers.get("User-Agent", "") 162 | 163 | if is_terminal_client(user_agent): 164 | return web.Response( 165 | text=f"Visit {self.terminal_landing_page} to get started.\n" 166 | ) 167 | 168 | raise web.HTTPFound(self.browser_landing_page) # Redirect 169 | 170 | async def showcase(self, request: web.Request) -> web.Response: 171 | """The showcase endpoint, returning a static resume.""" 172 | 173 | return web.Response(text=SHOWCASE_RESUME) 174 | 175 | async def username(self, request: web.Request) -> web.Response: 176 | """The username endpoint, returning a dynamic resume from a user's gists.""" 177 | 178 | stopwatch = Stopwatch() 179 | stopwatch(segment="Initialize Request") 180 | 181 | log = LOGGER.bind(request=request) 182 | log.info(request.message.headers) 183 | 184 | user = request.match_info["username"] 185 | 186 | if not is_valid_github_username(user): 187 | raise web.HTTPBadRequest(reason=f"Invalid username: {user}") 188 | 189 | # Implicit 'downcasting' from `Any` doesn't require an explicit `cast` call, just 190 | # regular type hints: 191 | # https://adamj.eu/tech/2021/07/06/python-type-hints-how-to-use-typing-cast/ 192 | github: GitHubAPI = request.app["github"] 193 | 194 | log = log.bind(user=user) 195 | 196 | stopwatch.stop() 197 | try: 198 | resume = await get_resume(user=user, github=github, stopwatch=stopwatch) 199 | except ResumeLookupError as e: 200 | stopwatch.stop() 201 | log.warning(str(e)) 202 | return web.Response(text=str(e), status=HTTPStatus.NOT_FOUND) 203 | else: 204 | stopwatch(segment="Templating") 205 | try: 206 | template = Template.from_model_config(resume) 207 | except ResumeConfigError as e: 208 | log.warning(str(e)) 209 | return web.Response(text=str(e)) 210 | 211 | stopwatch(segment="Rendering") 212 | resp = web.Response(text=template.render()) 213 | stopwatch.stop() 214 | 215 | resp.headers["Server-Timing"] = server_timing_header(stopwatch.timings) 216 | 217 | log.debug("Serving rendered template.") 218 | return resp 219 | 220 | 221 | class FileHandler(Runnable): 222 | """A handler serving a rendered, static template loaded from a file at startup.""" 223 | 224 | def __init__(self, file: Path) -> None: 225 | """Initializes the handler. 226 | 227 | Args: 228 | file: The (JSON Resume) file to load the template from. 229 | """ 230 | 231 | self.template = Template.from_file(file) 232 | self.rendered = self.template.render() 233 | 234 | LOGGER.debug("Instantiating web application.") 235 | self.app = web.Application() 236 | 237 | LOGGER.debug("Adding routes.") 238 | self.app.add_routes([web.get("/", self.root)]) 239 | 240 | def run(self, context: ServerContext) -> None: 241 | LOGGER.info("Loaded, starting server...") 242 | web.run_app(self.app, host=context.host, port=context.port, path=context.path) 243 | 244 | async def root(self, request: web.Request) -> web.Response: 245 | """The root and *only* endpoint, returning the rendered template.""" 246 | 247 | LOGGER.debug("Serving rendered template.", request=request) 248 | return web.Response(text=self.rendered) 249 | 250 | 251 | def server_timing_header(timings: dict[str, timedelta]) -> str: 252 | """From a mapping of names to `timedelta`s, return a `Server-Timing` header value. 253 | 254 | See also: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Server-Timing 255 | """ 256 | 257 | # For controlling `timedelta` conversion precision, see: 258 | # https://docs.python.org/3/library/datetime.html#datetime.timedelta.total_seconds 259 | # E.g., `td.microseconds` will return `0` for `timedelta(seconds=1)`, not 1e6. 260 | 261 | return ", ".join( 262 | f"{name.replace(' ', '-')};dur={duration // timedelta(milliseconds=1)}" 263 | for name, duration in timings.items() 264 | ) 265 | 266 | 267 | class RenderError(Exception): 268 | """Base exception for resume rendering failures""" 269 | 270 | pass 271 | 272 | 273 | class TemplateRenderError(RenderError): 274 | """Raised when template rendering fails""" 275 | 276 | pass 277 | 278 | 279 | class InvalidResumeDataError(RenderError): 280 | """Raised when resume data is invalid""" 281 | 282 | pass 283 | 284 | 285 | class WebHandler(Runnable): 286 | """A handler serving a rendered template loaded from a URL with periodic refresh.""" 287 | 288 | def __init__( 289 | self, destination: str, refresh_interval: timedelta = timedelta(seconds=300) 290 | ) -> None: 291 | """Initializes the handler. 292 | 293 | Args: 294 | destination: The URL to load the JSON Resume from. 295 | refresh_interval: How often to refresh the resume. 296 | """ 297 | self.destination = destination 298 | self.refresh_interval = refresh_interval 299 | self.cache: str = "" 300 | self.last_fetch: float = 0 301 | self._last_valid_render: str = "" 302 | 303 | LOGGER.debug("Instantiating web application.") 304 | self.app = web.Application() 305 | LOGGER.debug("Adding routes.") 306 | self.app.add_routes([web.get("/", self.root)]) 307 | self.app.cleanup_ctx.append(self.app_context) 308 | 309 | def run(self, context: ServerContext) -> None: 310 | LOGGER.info("Loaded, starting server...") 311 | web.run_app(self.app, host=context.host, port=context.port, path=context.path) 312 | 313 | async def app_context(self, app: web.Application) -> AsyncGenerator[None, None]: 314 | """Sets up the application context with required clients. 315 | 316 | Args: 317 | app: The app instance to attach our state to. 318 | """ 319 | log = LOGGER.bind(app=app) 320 | log.debug("App context initialization starting.") 321 | log.debug("Starting client session.") 322 | session = ClientSession() 323 | app["client_session"] = session 324 | log.debug("Started client session.") 325 | log.debug("App context initialization done, yielding.") 326 | yield 327 | log.debug("App context teardown starting.") 328 | await session.close() 329 | log.debug("App context teardown done.") 330 | 331 | async def fetch(self, session: ClientSession) -> ResumeSchema: 332 | """Fetches and validates resume JSON from the destination URL. 333 | 334 | Args: 335 | session: The aiohttp client session to use for requests. 336 | 337 | Returns: 338 | ResumeSchema: The validated resume data 339 | web.Response: Error response when: 340 | - Resume cannot be fetched from destination (NOT_FOUND) 341 | - Response is not valid JSON (BAD_REQUEST) 342 | - JSON data doesn't match resume schema 343 | """ 344 | async with session.get(self.destination) as response: 345 | if response.status != HTTPStatus.OK: 346 | raise RenderError(f"Failed to fetch resume from {self.destination}") 347 | content = await response.text() 348 | try: 349 | resume_data = json.loads(content) 350 | return ResumeSchema(**resume_data) 351 | except json.JSONDecodeError: 352 | raise InvalidResumeDataError("Invalid JSON format in resume data") 353 | 354 | def render(self, resume_data: ResumeSchema) -> str: 355 | """Renders resume data into a formatted template string. 356 | 357 | Args: 358 | resume_data: The resume data dictionary to render 359 | 360 | Returns: 361 | str: The successfully rendered resume template 362 | web.Response: Error response when: 363 | - Resume data doesn't match expected schema 364 | - Template rendering fails 365 | """ 366 | try: 367 | template = Template.from_model_config(resume_data) 368 | rendered = template.render() 369 | if not rendered: 370 | raise TemplateRenderError("Template rendering failed") 371 | return rendered 372 | except ResumeConfigError: 373 | raise InvalidResumeDataError("Resume configuration error") 374 | 375 | async def root(self, request: web.Request) -> web.Response: 376 | """The root endpoint, returning the rendered template with periodic refresh. 377 | 378 | Implements a caching mechanism that refreshes the resume data at configured intervals. 379 | Uses monotonic time to ensure reliable cache invalidation. Falls back to cached version 380 | if refresh fails. 381 | 382 | Args: 383 | request: The incoming web request containing the client session 384 | 385 | Returns: 386 | web.Response: Contains either: 387 | - Fresh or cached rendered template as text 388 | - Error message with SERVICE_UNAVAILABLE status when no cache exists 389 | 390 | Note: 391 | Cache refresh occurs when: 392 | - No cache exists 393 | - No previous fetch timestamp exists 394 | - Refresh interval has elapsed since last fetch 395 | """ 396 | log = LOGGER.bind(request=request) 397 | session: ClientSession = request.app["client_session"] 398 | 399 | current_time = time.monotonic() 400 | should_refresh = ( 401 | not self.cache 402 | or (current_time - self.last_fetch) > self.refresh_interval.total_seconds() 403 | ) 404 | 405 | if should_refresh: 406 | log.debug("Fetching fresh resume data.") 407 | try: 408 | resume_data = await self.fetch(session) 409 | rendered = self.render(resume_data) 410 | self._last_valid_render = rendered 411 | self.cache = rendered 412 | self.last_fetch = current_time 413 | except (ClientError, ValidationError) as exc: 414 | log.error("Network or validation error", error=str(exc)) 415 | if self._last_valid_render: 416 | self.cache = self._last_valid_render 417 | log.warning("Using last valid render as fallback") 418 | else: 419 | return web.Response( 420 | text="No cache available", status=HTTPStatus.SERVICE_UNAVAILABLE 421 | ) 422 | except (RenderError, InvalidResumeDataError) as exc: 423 | log.error("Resume rendering error", error=str(exc)) 424 | if self._last_valid_render: 425 | self.cache = self._last_valid_render 426 | log.warning("Using last valid render as fallback") 427 | else: 428 | return web.Response( 429 | text="Unable to render resume", 430 | status=HTTPStatus.INTERNAL_SERVER_ERROR, 431 | ) 432 | 433 | log.debug("Serving rendered template.") 434 | return web.Response(text=self.cache) 435 | -------------------------------------------------------------------------------- /devbox.json: -------------------------------------------------------------------------------- 1 | { 2 | "packages": [ 3 | "python@3.12", 4 | "uv@0.4", 5 | "ruff@0.7", 6 | "pydeps@1.12", 7 | "graphviz@8", 8 | "pre-commit@3" 9 | ], 10 | "env": { 11 | "LIBRARY": "ancv" 12 | }, 13 | "shell": { 14 | "init_hook": [ 15 | "echo 'Running command in devbox shell...'", 16 | "uv sync" 17 | ], 18 | "scripts": { 19 | "build-image": "docker build --progress=plain --tag \"$LIBRARY\"/\"$LIBRARY\":dev .", 20 | "format-check": "ruff format --check --diff", 21 | "install-hooks": "pre-commit install --hook-type pre-push --hook-type pre-commit --hook-type commit-msg", 22 | "lint": "ruff check --verbose .", 23 | "make-depgraph.svg": "pydeps --max-bacon=4 --cluster -T svg -o depgraph.svg \"$LIBRARY\"", 24 | "make-github.py": "uv run datamodel-codegen --url \"https://raw.githubusercontent.com/github/rest-api-description/main/descriptions-next/api.github.com/dereferenced/api.github.com.deref.json\" --encoding utf-8 --input-file-type openapi --openapi-scopes paths --output github.py", 25 | "make-resume.py": "uv run datamodel-codegen --url \"https://raw.githubusercontent.com/jsonresume/resume-schema/master/schema.json\" --encoding utf-8 --input-file-type jsonschema --output resume.py", 26 | "test": "uv run pytest -vv --cov=\"$LIBRARY\" --cov-report=html --cov-report=term --cov-report=xml", 27 | "typecheck": "uv run mypy -v -p \"$LIBRARY\"" 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /devbox.lock: -------------------------------------------------------------------------------- 1 | { 2 | "lockfile_version": "1", 3 | "packages": { 4 | "graphviz@8": { 5 | "last_modified": "2023-09-27T18:02:17Z", 6 | "resolved": "github:NixOS/nixpkgs/517501bcf14ae6ec47efd6a17dda0ca8e6d866f9#graphviz", 7 | "source": "devbox-search", 8 | "version": "8.1.0", 9 | "systems": { 10 | "aarch64-darwin": { 11 | "outputs": [ 12 | { 13 | "name": "out", 14 | "path": "/nix/store/1198j1prrz5d9l3w3zkjxz3y447vg65m-graphviz-8.1.0", 15 | "default": true 16 | } 17 | ], 18 | "store_path": "/nix/store/1198j1prrz5d9l3w3zkjxz3y447vg65m-graphviz-8.1.0" 19 | }, 20 | "aarch64-linux": { 21 | "outputs": [ 22 | { 23 | "name": "out", 24 | "path": "/nix/store/hpdihpsh4hz4j8b0svqbrjn5fc9cz31g-graphviz-8.1.0", 25 | "default": true 26 | } 27 | ], 28 | "store_path": "/nix/store/hpdihpsh4hz4j8b0svqbrjn5fc9cz31g-graphviz-8.1.0" 29 | }, 30 | "x86_64-darwin": { 31 | "outputs": [ 32 | { 33 | "name": "out", 34 | "path": "/nix/store/w2h4453kkhcbr9in0zs07d4pm56qsshr-graphviz-8.1.0", 35 | "default": true 36 | } 37 | ], 38 | "store_path": "/nix/store/w2h4453kkhcbr9in0zs07d4pm56qsshr-graphviz-8.1.0" 39 | }, 40 | "x86_64-linux": { 41 | "outputs": [ 42 | { 43 | "name": "out", 44 | "path": "/nix/store/66sh3daq6j5pdji2s2bcwka7dl5adrfx-graphviz-8.1.0", 45 | "default": true 46 | } 47 | ], 48 | "store_path": "/nix/store/66sh3daq6j5pdji2s2bcwka7dl5adrfx-graphviz-8.1.0" 49 | } 50 | } 51 | }, 52 | "pre-commit@3": { 53 | "last_modified": "2024-10-16T02:16:09Z", 54 | "resolved": "github:NixOS/nixpkgs/70cc7b62f2b448a487de36e1fd714693c59c4719#pre-commit", 55 | "source": "devbox-search", 56 | "version": "3.7.1", 57 | "systems": { 58 | "aarch64-darwin": { 59 | "outputs": [ 60 | { 61 | "name": "out", 62 | "path": "/nix/store/flzj4pzl43m29ml7brh4gabw9rxrvrml-pre-commit-3.7.1", 63 | "default": true 64 | }, 65 | { 66 | "name": "dist", 67 | "path": "/nix/store/87dhz8r6jwy3487h78bbivhw9mg8g8h9-pre-commit-3.7.1-dist" 68 | } 69 | ], 70 | "store_path": "/nix/store/flzj4pzl43m29ml7brh4gabw9rxrvrml-pre-commit-3.7.1" 71 | }, 72 | "aarch64-linux": { 73 | "outputs": [ 74 | { 75 | "name": "out", 76 | "path": "/nix/store/jddjbmxg9rlawirkr8pg9dlj0xma9w1q-pre-commit-3.7.1", 77 | "default": true 78 | }, 79 | { 80 | "name": "dist", 81 | "path": "/nix/store/2lpy8sfhcnn33ia5c77g5gyg1zz6b0qg-pre-commit-3.7.1-dist" 82 | } 83 | ], 84 | "store_path": "/nix/store/jddjbmxg9rlawirkr8pg9dlj0xma9w1q-pre-commit-3.7.1" 85 | }, 86 | "x86_64-darwin": { 87 | "outputs": [ 88 | { 89 | "name": "out", 90 | "path": "/nix/store/2ycxh3vvj0b1bk2pgj84jg88l49hbwq3-pre-commit-3.7.1", 91 | "default": true 92 | }, 93 | { 94 | "name": "dist", 95 | "path": "/nix/store/0wfmhzk48ziaw6vd26shy5mjazg9qsjc-pre-commit-3.7.1-dist" 96 | } 97 | ], 98 | "store_path": "/nix/store/2ycxh3vvj0b1bk2pgj84jg88l49hbwq3-pre-commit-3.7.1" 99 | }, 100 | "x86_64-linux": { 101 | "outputs": [ 102 | { 103 | "name": "out", 104 | "path": "/nix/store/zp9vk1y2fzlavzaz67ikipgh9hq58pxr-pre-commit-3.7.1", 105 | "default": true 106 | }, 107 | { 108 | "name": "dist", 109 | "path": "/nix/store/k3hc0dqi88iik1n1kjc9ddp6rf9wp11v-pre-commit-3.7.1-dist" 110 | } 111 | ], 112 | "store_path": "/nix/store/zp9vk1y2fzlavzaz67ikipgh9hq58pxr-pre-commit-3.7.1" 113 | } 114 | } 115 | }, 116 | "pydeps@1.12": { 117 | "last_modified": "2024-10-17T04:50:07Z", 118 | "resolved": "github:NixOS/nixpkgs/1bde3e8e37a72989d4d455adde764d45f45dc11c#pydeps", 119 | "source": "devbox-search", 120 | "version": "1.12.20", 121 | "systems": { 122 | "aarch64-darwin": { 123 | "outputs": [ 124 | { 125 | "name": "out", 126 | "path": "/nix/store/pgkdnqdh2kchvg1y3dfcgq9qvyhr6rzn-python3.12-pydeps-1.12.20", 127 | "default": true 128 | }, 129 | { 130 | "name": "dist", 131 | "path": "/nix/store/5xbmwbx12x6vvaknryhc40hfcfgc1k91-python3.12-pydeps-1.12.20-dist" 132 | } 133 | ], 134 | "store_path": "/nix/store/pgkdnqdh2kchvg1y3dfcgq9qvyhr6rzn-python3.12-pydeps-1.12.20" 135 | }, 136 | "aarch64-linux": { 137 | "outputs": [ 138 | { 139 | "name": "out", 140 | "path": "/nix/store/wpdsbyv2gs5smn5y0cbjdk9xkvlp8z7a-python3.12-pydeps-1.12.20", 141 | "default": true 142 | }, 143 | { 144 | "name": "dist", 145 | "path": "/nix/store/5n3pfl2xcyc1192nnvkfnm5c2vy7npm7-python3.12-pydeps-1.12.20-dist" 146 | } 147 | ], 148 | "store_path": "/nix/store/wpdsbyv2gs5smn5y0cbjdk9xkvlp8z7a-python3.12-pydeps-1.12.20" 149 | }, 150 | "x86_64-darwin": { 151 | "outputs": [ 152 | { 153 | "name": "out", 154 | "path": "/nix/store/ymjksz32p6fc7pkzpzlxdl2p18zl8cww-python3.12-pydeps-1.12.20", 155 | "default": true 156 | }, 157 | { 158 | "name": "dist", 159 | "path": "/nix/store/dr1rgiysjilh0440zwhjxsqgcxg07iac-python3.12-pydeps-1.12.20-dist" 160 | } 161 | ], 162 | "store_path": "/nix/store/ymjksz32p6fc7pkzpzlxdl2p18zl8cww-python3.12-pydeps-1.12.20" 163 | }, 164 | "x86_64-linux": { 165 | "outputs": [ 166 | { 167 | "name": "out", 168 | "path": "/nix/store/rh6336hrlnirfx9rdmwbz1sin18ym5kb-python3.12-pydeps-1.12.20", 169 | "default": true 170 | }, 171 | { 172 | "name": "dist", 173 | "path": "/nix/store/5h713831gz19smc6hcxk0f79a3kdddsm-python3.12-pydeps-1.12.20-dist" 174 | } 175 | ], 176 | "store_path": "/nix/store/rh6336hrlnirfx9rdmwbz1sin18ym5kb-python3.12-pydeps-1.12.20" 177 | } 178 | } 179 | }, 180 | "python@3.12": { 181 | "last_modified": "2024-10-13T23:44:06Z", 182 | "plugin_version": "0.0.4", 183 | "resolved": "github:NixOS/nixpkgs/d4f247e89f6e10120f911e2e2d2254a050d0f732#python3", 184 | "source": "devbox-search", 185 | "version": "3.12.6", 186 | "systems": { 187 | "aarch64-darwin": { 188 | "outputs": [ 189 | { 190 | "name": "out", 191 | "path": "/nix/store/ybnf7k6i9p244bbhsbxizqk65z58cwyr-python3-3.12.6", 192 | "default": true 193 | } 194 | ], 195 | "store_path": "/nix/store/ybnf7k6i9p244bbhsbxizqk65z58cwyr-python3-3.12.6" 196 | }, 197 | "aarch64-linux": { 198 | "outputs": [ 199 | { 200 | "name": "out", 201 | "path": "/nix/store/pv8p76ihrnqf81xan9qnv53ks84wmp07-python3-3.12.6", 202 | "default": true 203 | }, 204 | { 205 | "name": "debug", 206 | "path": "/nix/store/qk5wzj1q68jdvnzph5ckiir2wj87qygq-python3-3.12.6-debug" 207 | } 208 | ], 209 | "store_path": "/nix/store/pv8p76ihrnqf81xan9qnv53ks84wmp07-python3-3.12.6" 210 | }, 211 | "x86_64-darwin": { 212 | "outputs": [ 213 | { 214 | "name": "out", 215 | "path": "/nix/store/d30gb7nm3bnd58a5c3gwnmxywzbls69v-python3-3.12.6", 216 | "default": true 217 | } 218 | ], 219 | "store_path": "/nix/store/d30gb7nm3bnd58a5c3gwnmxywzbls69v-python3-3.12.6" 220 | }, 221 | "x86_64-linux": { 222 | "outputs": [ 223 | { 224 | "name": "out", 225 | "path": "/nix/store/wfbjq35kxs6x83c3ncpfxdyl5gbhdx4h-python3-3.12.6", 226 | "default": true 227 | }, 228 | { 229 | "name": "debug", 230 | "path": "/nix/store/wmg3whp163d4y3www9kfziwks1yn6k9i-python3-3.12.6-debug" 231 | } 232 | ], 233 | "store_path": "/nix/store/wfbjq35kxs6x83c3ncpfxdyl5gbhdx4h-python3-3.12.6" 234 | } 235 | } 236 | }, 237 | "ruff@0.7": { 238 | "last_modified": "2024-10-18T06:41:56Z", 239 | "resolved": "github:NixOS/nixpkgs/c858402c2a629211153137fb8d39be9fde4694ff#ruff", 240 | "source": "devbox-search", 241 | "version": "0.7.0", 242 | "systems": { 243 | "aarch64-darwin": { 244 | "outputs": [ 245 | { 246 | "name": "out", 247 | "path": "/nix/store/p80xrz0y1qs4z27bbpwp6jlxd8a137v3-ruff-0.7.0", 248 | "default": true 249 | } 250 | ], 251 | "store_path": "/nix/store/p80xrz0y1qs4z27bbpwp6jlxd8a137v3-ruff-0.7.0" 252 | }, 253 | "aarch64-linux": { 254 | "outputs": [ 255 | { 256 | "name": "out", 257 | "path": "/nix/store/i32qgcbfr07l10dpkhhs9482yg7chb2v-ruff-0.7.0", 258 | "default": true 259 | } 260 | ], 261 | "store_path": "/nix/store/i32qgcbfr07l10dpkhhs9482yg7chb2v-ruff-0.7.0" 262 | }, 263 | "x86_64-darwin": { 264 | "outputs": [ 265 | { 266 | "name": "out", 267 | "path": "/nix/store/il9zkd76yw8xdq1ia09napdkqardh7gk-ruff-0.7.0", 268 | "default": true 269 | } 270 | ], 271 | "store_path": "/nix/store/il9zkd76yw8xdq1ia09napdkqardh7gk-ruff-0.7.0" 272 | }, 273 | "x86_64-linux": { 274 | "outputs": [ 275 | { 276 | "name": "out", 277 | "path": "/nix/store/6ix6r6c9xbh442ax04pm72kzrp6cyvga-ruff-0.7.0", 278 | "default": true 279 | } 280 | ], 281 | "store_path": "/nix/store/6ix6r6c9xbh442ax04pm72kzrp6cyvga-ruff-0.7.0" 282 | } 283 | } 284 | }, 285 | "uv@0.4": { 286 | "last_modified": "2024-10-13T23:44:06Z", 287 | "resolved": "github:NixOS/nixpkgs/d4f247e89f6e10120f911e2e2d2254a050d0f732#uv", 288 | "source": "devbox-search", 289 | "version": "0.4.20", 290 | "systems": { 291 | "aarch64-darwin": { 292 | "outputs": [ 293 | { 294 | "name": "out", 295 | "path": "/nix/store/kj94bwy53x1szcp2zggmkcxcjy2n4nxl-uv-0.4.20", 296 | "default": true 297 | }, 298 | { 299 | "name": "dist", 300 | "path": "/nix/store/knkva9prhn39g6chdyfnbc2cy6xyp3aa-uv-0.4.20-dist" 301 | } 302 | ], 303 | "store_path": "/nix/store/kj94bwy53x1szcp2zggmkcxcjy2n4nxl-uv-0.4.20" 304 | }, 305 | "aarch64-linux": { 306 | "outputs": [ 307 | { 308 | "name": "out", 309 | "path": "/nix/store/8jn9dha0xgrvm6xyh6x96q6wid6mck68-uv-0.4.20", 310 | "default": true 311 | }, 312 | { 313 | "name": "dist", 314 | "path": "/nix/store/7jzi3q5g1sg8r48nrmbnsii862ia6s9n-uv-0.4.20-dist" 315 | } 316 | ], 317 | "store_path": "/nix/store/8jn9dha0xgrvm6xyh6x96q6wid6mck68-uv-0.4.20" 318 | }, 319 | "x86_64-darwin": { 320 | "outputs": [ 321 | { 322 | "name": "out", 323 | "path": "/nix/store/5a7gy3fcvzhmy9p99ab2r235m41wzz35-uv-0.4.20", 324 | "default": true 325 | }, 326 | { 327 | "name": "dist", 328 | "path": "/nix/store/z4xlbg5rgqp9ijbj40g1q8hw55vm6bp1-uv-0.4.20-dist" 329 | } 330 | ], 331 | "store_path": "/nix/store/5a7gy3fcvzhmy9p99ab2r235m41wzz35-uv-0.4.20" 332 | }, 333 | "x86_64-linux": { 334 | "outputs": [ 335 | { 336 | "name": "out", 337 | "path": "/nix/store/h9w1a72brbpl8jvxlq7fkmbbc9f2qxk4-uv-0.4.20", 338 | "default": true 339 | }, 340 | { 341 | "name": "dist", 342 | "path": "/nix/store/045dc8ak0pfpz5nfv5avk9khf1y63rpq-uv-0.4.20-dist" 343 | } 344 | ], 345 | "store_path": "/nix/store/h9w1a72brbpl8jvxlq7fkmbbc9f2qxk4-uv-0.4.20" 346 | } 347 | } 348 | } 349 | } 350 | } 351 | -------------------------------------------------------------------------------- /docs/images/users-venn.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | You! 15 | 16 | People working 17 | with resumes 18 | 19 | 20 | People working 21 | with terminals 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "ancv" 3 | version = "1.5.3" 4 | description = "Renders your (JSON) resume/CV for online & pretty terminal display" 5 | authors = [{ name = "Alex Povel", email = "python@alexpovel.de" }] 6 | readme = "README.md" 7 | license = { file = "LICENSE" } 8 | classifiers = [ 9 | "Development Status :: 5 - Production/Stable", 10 | "Environment :: Console", 11 | "Environment :: Web Environment", 12 | "Framework :: AnyIO", 13 | "Framework :: AsyncIO", 14 | "Framework :: Pytest", 15 | "Intended Audience :: Developers", 16 | "Intended Audience :: Information Technology", 17 | "Natural Language :: English", 18 | "Natural Language :: German", 19 | "Operating System :: OS Independent", 20 | "Programming Language :: Python :: 3.12", 21 | "Programming Language :: Python :: Implementation :: CPython", 22 | "Topic :: Internet :: WWW/HTTP", 23 | "Topic :: Office/Business", 24 | "Topic :: System :: Shells", 25 | "Topic :: Terminals", 26 | "Topic :: Terminals :: Terminal Emulators/X Terminals", 27 | "Topic :: Text Processing", 28 | "Topic :: Text Processing :: Markup", 29 | "Typing :: Typed", 30 | ] 31 | requires-python = ">=3.12" 32 | dependencies = [ 33 | "gidgethub>=5.3.0", 34 | "aiohttp>=3.10.10", 35 | "structlog>=24.4.0", 36 | "cachetools>=5.5.0", 37 | "humanize>=4.11.0", 38 | "rich>=13.9.3", 39 | "typer>=0.12.5", 40 | "babel>=2.16.0", 41 | "pydantic[email]>=2.9.2", 42 | ] 43 | 44 | [project.urls] 45 | Homepage = "https://ancv.povel.dev" 46 | Repository = "https://github.com/alexpovel/ancv/" 47 | Issues = "https://github.com/alexpovel/ancv/issues/" 48 | Changelog = "https://github.com/alexpovel/ancv/blob/main/CHANGELOG.md" 49 | 50 | [project.scripts] 51 | ancv = "ancv.__main__:app" 52 | 53 | [tool.uv] 54 | dev-dependencies = [ 55 | "datamodel-code-generator[http]>=0.26.2", 56 | "ipython>=8.28.0", 57 | "mypy>=1.13.0", 58 | "pydeps>=2.0.1", 59 | "pytest-aiohttp>=1.0.5", 60 | "pytest-cov>=5.0.0", 61 | "pytest-asyncio>=0.24.0", 62 | "pytest-rerunfailures>=14.0", 63 | "pytest>=8.3.3", 64 | "requests>=2.32.3", 65 | "types-babel>=2.11.0.15", 66 | "types-cachetools>=5.5.0.20240820", 67 | ] 68 | 69 | [build-system] 70 | requires = ["setuptools >= 75.0"] 71 | build-backend = "setuptools.build_meta" 72 | 73 | [tool.coverage.run] 74 | branch = true 75 | 76 | [tool.ruff.lint] 77 | # `E501` is line length violation 78 | ignore = ["E501"] 79 | 80 | [tool.coverage.report] 81 | fail_under = 80.0 82 | 83 | [tool.datamodel-codegen] 84 | target-python-version = "3.12" 85 | 86 | [tool.pytest.ini_options] 87 | asyncio_mode = "auto" 88 | asyncio_default_fixture_loop_scope = "function" 89 | 90 | [tool.mypy] 91 | mypy_path = "stubs/" 92 | show_error_codes = true 93 | strict = true 94 | namespace_packages = true 95 | disallow_any_unimported = true 96 | # Disable until https://github.com/python/mypy/issues/16454 fixed (inheriting from 97 | # `Basemodel` gives ancv/visualization/translations.py:4: error: Explicit "Any" is not 98 | # allowed [misc]) 99 | disallow_any_explicit = false 100 | disallow_any_generics = true 101 | disallow_subclassing_any = true 102 | disallow_untyped_defs = true 103 | no_implicit_optional = true 104 | check_untyped_defs = true 105 | warn_return_any = true 106 | warn_unused_ignores = true 107 | -------------------------------------------------------------------------------- /release-please-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", 3 | "packages": { 4 | ".": {} 5 | }, 6 | "release-type": "python" 7 | } 8 | -------------------------------------------------------------------------------- /schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-04/schema#", 3 | "allOf": [ 4 | { 5 | "$ref": "https://raw.githubusercontent.com/jsonresume/resume-schema/v1.0.0/schema.json" 6 | }, 7 | { 8 | "type": "object", 9 | "properties": { 10 | "meta": { 11 | "allOf": [ 12 | { 13 | "$ref": "https://raw.githubusercontent.com/jsonresume/resume-schema/v1.0.0/schema.json#/properties/meta" 14 | } 15 | ], 16 | "properties": { 17 | "ancv": { 18 | "type": "object", 19 | "description": "ancv-specific (https://ancv.povel.dev) properties", 20 | "properties": { 21 | "template": { 22 | "type": "string", 23 | "description": "The template (ordering, alignment, positioning, ...) to use", 24 | "enum": [ 25 | "Sequential" 26 | ] 27 | }, 28 | "theme": { 29 | "type": "string", 30 | "description": "The theme (colors, emphasis, ...) to use", 31 | "enum": [ 32 | "basic", 33 | "grayscale", 34 | "hendrix", 35 | "lollipop", 36 | "plain" 37 | ] 38 | }, 39 | "language": { 40 | "type": "string", 41 | "description": "The language aka translation (for section titles like 'Education' etc.) to use", 42 | "enum": [ 43 | "de", 44 | "en", 45 | "es", 46 | "fr" 47 | ] 48 | }, 49 | "ascii_only": { 50 | "type": "boolean", 51 | "description": "Whether to only use ASCII characters in the template (you are responsible for not using non-ASCII characters in your resume)" 52 | }, 53 | "dec31_as_year": { 54 | "type": "boolean", 55 | "description": "Whether to display dates of 'December 31st of some year' as that year only, without month or day info" 56 | } 57 | } 58 | } 59 | } 60 | } 61 | } 62 | } 63 | ] 64 | } 65 | -------------------------------------------------------------------------------- /self-hosting/Caddyfile: -------------------------------------------------------------------------------- 1 | example.com 2 | 3 | reverse_proxy ancv:8080 4 | -------------------------------------------------------------------------------- /self-hosting/docker-compose.yml: -------------------------------------------------------------------------------- 1 | volumes: 2 | proxy_data: 3 | proxy_config: 4 | 5 | 6 | services: 7 | proxy: 8 | image: caddy:2 9 | volumes: 10 | - ./Caddyfile:/etc/caddy/Caddyfile 11 | - proxy_data:/data # Certs, keys etc. 12 | - proxy_config:/config # Configuration files 13 | ports: 14 | - 80:80 15 | - 443:443 16 | restart: unless-stopped 17 | ancv: 18 | image: ghcr.io/alexpovel/ancv:1 19 | volumes: 20 | - ./resume.json:/resume.json:ro 21 | ports: 22 | - 8080:8080 23 | command: 24 | [ 25 | "serve", 26 | "file", 27 | "/resume.json", 28 | "--port", 29 | "8080" 30 | ] 31 | restart: unless-stopped 32 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | from pathlib import Path 3 | 4 | import pytest 5 | import requests 6 | 7 | TESTS_DIR = Path(__file__).parent 8 | DATA_DIR = TESTS_DIR / "test_data" 9 | RESUMES_DIR = DATA_DIR / "resumes" 10 | 11 | EXPECTED_OUTPUTS_DIR = DATA_DIR / "expected-outputs" 12 | ACTUAL_OUTPUTS_DIR = EXPECTED_OUTPUTS_DIR.parent / "actual-outputs" 13 | Path.mkdir(ACTUAL_OUTPUTS_DIR, exist_ok=True) 14 | 15 | RESUMES = {p.name: p for p in RESUMES_DIR.iterdir()} 16 | 17 | # Map empty string to `None` as well: for pull requests from forked repos, the env var 18 | # is/might be defined but *empty*; if we use it, we get Bad Credentials errors: 19 | # https://web.archive.org/web/20241118204412/https://github.com/alexpovel/ancv/actions/runs/11881484194/job/33160883626 20 | # 🤷 21 | GH_TOKEN = os.environ.get("GH_TOKEN", None) or None 22 | 23 | # Probably a terrible idea to do IO using the GH API in unit tests, but it does test the 24 | # full thing instead of just some mock. 25 | headers = { 26 | "Accept": "application/vnd.github+json", 27 | "User-Agent": "ancv-pytest", 28 | } 29 | if GH_TOKEN is not None: 30 | # Just guessing at this point (GitHub Actions CI from PRs from forks is confusing): 31 | assert GH_TOKEN != "", "GitHub token is empty" 32 | 33 | headers["Authorization"] = f"Bearer {GH_TOKEN}" 34 | 35 | resp = requests.get( 36 | "https://api.github.com/rate_limit", 37 | headers=headers, 38 | ) 39 | 40 | try: 41 | remaining = resp.json()["resources"]["core"]["remaining"] 42 | except KeyError as e: 43 | try: 44 | if resp.json()["message"] == "Bad credentials": 45 | raise KeyError("Bad credentials for GH_TOKEN") from e 46 | raise 47 | except KeyError: 48 | raise 49 | 50 | gh_rate_limited = pytest.mark.xfail( 51 | condition=remaining == 0, 52 | reason="GitHub API rate limit reached. If you haven't already, set the 'GH_TOKEN' env var to a PAT.", 53 | ) 54 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | from datetime import date, datetime, time 2 | from typing import Optional 3 | 4 | import pytest 5 | 6 | 7 | def pytest_make_parametrize_id( 8 | config: pytest.Config, val: object, argname: str 9 | ) -> Optional[str]: 10 | if isinstance(val, (date, datetime, time)): 11 | return repr(val) 12 | return None 13 | -------------------------------------------------------------------------------- /tests/data/test_validation.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from ancv.data.validation import is_valid_github_username 4 | 5 | 6 | @pytest.mark.parametrize( 7 | ["name", "valid"], 8 | [ 9 | ("j", True), 10 | ("johndoe", True), 11 | ("john-doe", True), 12 | ("johndoe-johndoe", True), 13 | ("johndoe0-johndoe1", True), 14 | ("john-john-doe-doe", True), 15 | ("johndoe1970", True), 16 | ("1970johndoe", True), 17 | ("j" * 30, True), 18 | ("j" * 37, True), 19 | ("j" * 38, True), 20 | ("j" * 39, True), 21 | # 22 | ("", False), 23 | ("-johndoe", False), 24 | ("johndoe-", False), 25 | ("-johndoe-", False), 26 | ("johndoe--johndoe", False), 27 | ("j" * 40, False), 28 | ("j" * 100, False), 29 | # 30 | ("🚧", False), 31 | ("🚧" * 100, False), 32 | ("ö", False), 33 | ("äääääääääääää", False), 34 | ("}", False), 35 | ("[", False), 36 | ("hello; world", False), 37 | ("exec('evil')", False), 38 | ("TIMMY; DROP TABLES :-)", False), 39 | ], 40 | ) 41 | def test_is_valid_github_name(name: str, valid: bool) -> None: 42 | assert is_valid_github_username(name) == valid 43 | -------------------------------------------------------------------------------- /tests/test_data/README.md: -------------------------------------------------------------------------------- 1 | # Test data 2 | 3 | These files allow for some integration testing. 4 | All files in [`resumes`](resumes/) are expected to have a corresponding output file in [`expected-outputs`](expected-outputs/). 5 | The reverse is **not** true: 6 | 7 | - the `showcase.resume.json` needs to be part of the application, not test data and is therefore tested differently (it still has an expected output file) 8 | -------------------------------------------------------------------------------- /tests/test_data/expected-outputs/partial.resume.output.txt: -------------------------------------------------------------------------------- 1 | ─────────────────────────────────────────────────────── John Doe ─────────────────────────────────────────────────────── 2 | 3 | Programmer 4 | 5 | john@gmail.com • (912) 555-4321 • https://johndoe.com/ 6 | 7 | A summary of John Doe that is more than 200 characters long so that it can be tested and verified to see if it works and 8 | how linebreaks are processed. 9 | 10 | Reddit      11 | Instagram   (https://www.instagram.com/johndoe) 12 | TikTok  johndoe    13 | Twitter  john  (https://twitter.com/john)  14 | LinkedIn  johndoe  (https://linkedin.com/johndoe)  15 | Facebook  john.doe (https://facebook.com/john.doe)  16 | GitHub  johndoe  (https://github.com/johndoe)  17 | 18 | 2712 Broadway St 19 | Second Street, San Francisco, CA 94111 20 | CA 94115, San Francisco, California, US -------------------------------------------------------------------------------- /tests/test_data/expected-outputs/showcase.resume.output.txt: -------------------------------------------------------------------------------- 1 | ──────────────────────────────────────────────────── Jane A. Cooper ──────────────────────────────────────────────────── 2 | 3 | 👋 Fullstack Developer 4 | 5 | janecooper@example.com • +44 113 496 4389 • https://cooper.example.dev/ 6 | 7 | Passionate and creative fullstack developer with over 7 years of experience in building and maintaining web  8 | applications. I have a strong background in JavaScript and PHP, and I am proficient in a variety of frameworks and  9 | libraries. I am also experienced in building and maintaining RESTful APIs. 10 | 11 | 🐤 Twitter  janethecoopest (https://twitter.com/janethecoopest) 12 | 💼 LinkedIn jane-a-cooper  (https://linkedin.com/jane-a-cooper) 13 | 🐱 GitHub  janec  (https://github.com/janec)  14 | 15 | 1995 Little Acres Lane 16 | CA 94115, Carrollton, Texas, US 17 | 18 | 19 | ────────────────────────────────────────────────────── Experience ────────────────────────────────────────────────────── 20 | 21 | Google Inc. Internal tools and services.  March 2016 ➔ present 22 | 23 | Software Engineer III 24 | 25 | Work included Borg (Google's internal cluster management system), Google's internal build system, and Google's  26 | internal continuous integration system. 27 | 28 | In a pilot project, I also worked on reimagining Google's internal documentation management. 29 | 30 | ➔ Wrote a new build system for Google's internal continuous integration system. 31 | ➔ Improved performance of Google's internal Borg deployment by 18%. 32 | 33 | San Francisco, CA • https://google.com/ 34 | 35 | 36 | ────────────────────────────────────────────────────── Education ─────────────────────────────────────────────────────── 37 | 38 | Massachusets Institute of Technology: Computer Science (Distributed Systems) (Bachelor of  October 2012 ➔ January 2016 39 | Science) 40 | 41 | Grade: 3.91 42 | 43 | ➔ 6.1600: Foundations of Computer Security 44 | ➔ 6.1810: Operating System Engineering 45 | ➔ 6.1820: Mobile and Sensor Computing 46 | ➔ 6.5831: Database Systems 47 | 48 | https://www.mit.edu/ 49 | 50 | Fairwarden High School: Maths and Computer Science Track (High School Diploma)  2007 ➔ December 2011 51 | 52 | Grade: 3.64 53 | 54 | ➔ AP Computer Science 55 | ➔ AP Calculus 56 | ➔ AP Statistics 57 | 58 | https://www.fairwardenhs.org/ 59 | 60 | 61 | ──────────────────────────────────────────────────────── Skills ──────────────────────────────────────────────────────── 62 | 63 | Web Development • Expert 64 | 65 | HTML, CSS, JavaScript (ES6+), PHP 66 | 67 | 68 | Python • Proficient 69 | 70 | Django, Flask, Pyramid 71 | 72 | 73 | Databases • Intermediate 74 | 75 | PostgreSQL, MySQL, MongoDB 76 | 77 | 78 | Cloud • Expert 79 | 80 | AWS, Google Cloud, Azure 81 | 82 | 83 | ──────────────────────────────────────────────────────── Awards ──────────────────────────────────────────────────────── 84 | 85 | Google Code-in Finalist 🏆 (Google)  December 2011 86 | 87 | Finalist for Google Code-in, a global contest for pre-university students to introduce them to open source software  88 | development. 89 | 90 | 91 | ───────────────────────────────────────────────────── Certificates ───────────────────────────────────────────────────── 92 | 93 | Google Certified Professional Cloud Architect (Google)  February 2019 94 | 95 | https://cloud.google.com/certification/cloud-architect 96 | 97 | 98 | ───────────────────────────────────────────────────── Publications ───────────────────────────────────────────────────── 99 | 100 | Modern Approaches to Distributed Systems (MIT Press)  December 2015 101 | 102 | A comprehensive literature overview to distributed systems, with a focus on the practical aspects of building and  103 | maintenance. 104 | 105 | https://mitpress.mit.edu/books/new-approach-distributed-systems 106 | 107 | 108 | ────────────────────────────────────────────────────── Languages ─────────────────────────────────────────────────────── 109 | 110 | 111 | English 112 | 113 | Native speaker 114 | 115 | 116 | Spanish 117 | 118 | Professional working proficiency 119 | 120 | 121 | ────────────────────────────────────────────────────── References ────────────────────────────────────────────────────── 122 | 123 | 124 | Jane is a very talented developer and lovely teammate. They are a pleasure to work with, and I would recommend them  125 | to any company. 126 | 127 |  ➔ Tom T. Baker 128 | 129 | 130 | ────────────────────────────────────────────────────── Volunteer ─────────────────────────────────────────────────────── 131 | 132 | Shelter for Homeless Cats  July 2009 ➔ June 2019 133 | 134 | Volunteer 135 | 136 | Provided food and shelter for homeless cats. 137 | 138 | ➔ Cared for 20 cats 139 | ➔ Raised $3400 for food and supplies 140 | ➔ Organized adoption events 141 | 142 | https://example.com/catshelter 143 | 144 | 145 | ─────────────────────────────────────────────────────── Projects ─────────────────────────────────────────────────────── 146 | 147 | AWS Benchmarking Tool  November 2015 ➔ present 148 | 149 | A tool for benchmarking the performance of various AWS services, including EC2, S3, and Lambda. Written in Python. 150 | 151 | ➔ 100% code coverage and fully typed 152 | ➔ Can run as a CLI or as a web service, with OpenAPI documentation 153 | ➔ Full support for parallel execution 154 | 155 | AWS, Benchmarking, Python, Infrastructure 156 | 157 | https://github.com/janec/aws-benchmarking-tool 158 | 159 | 160 | ────────────────────────────────────────────────────── Interests ─────────────────────────────────────────────────────── 161 | 162 | Open Source 163 | 164 | Software Development, Linux, Maintenance 165 | 166 | 167 | Music 168 | 169 | Guitar, Folk Rock -------------------------------------------------------------------------------- /tests/test_data/resumes/full.resume.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://raw.githubusercontent.com/jsonresume/resume-schema/v1.0.0/schema.json", 3 | "basics": { 4 | "name": "John Doe", 5 | "label": "Programmer", 6 | "image": "http://example.com/image.jpg", 7 | "email": "john@gmail.com", 8 | "phone": "(912) 555-4321", 9 | "url": "https://johndoe.com", 10 | "summary": "A summary of John Doe that is more than 200 characters long so that it can be tested and verified to see if it works and how linebreaks are processed.", 11 | "location": { 12 | "address": "2712 Broadway St\nSecond Street, San Francisco, CA 94111", 13 | "postalCode": "CA 94115", 14 | "city": "San Francisco", 15 | "countryCode": "US", 16 | "region": "California" 17 | }, 18 | "profiles": [ 19 | { 20 | "network": "Reddit" 21 | }, 22 | { 23 | "network": "Instagram", 24 | "url": "https://www.instagram.com/johndoe" 25 | }, 26 | { 27 | "network": "TikTok", 28 | "username": "johndoe" 29 | }, 30 | { 31 | "network": "Twitter", 32 | "username": "john", 33 | "url": "https://twitter.com/john" 34 | }, 35 | { 36 | "network": "LinkedIn", 37 | "username": "johndoe", 38 | "url": "https://linkedin.com/johndoe" 39 | }, 40 | { 41 | "network": "Facebook", 42 | "username": "john.doe", 43 | "url": "https://facebook.com/john.doe" 44 | }, 45 | { 46 | "network": "GitHub", 47 | "username": "johndoe", 48 | "url": "https://github.com/johndoe" 49 | } 50 | ] 51 | }, 52 | "work": [ 53 | { 54 | "name": "Foogle", 55 | "location": "San Francisco, CA", 56 | "description": "A search engine for food", 57 | "position": "Clerk", 58 | "url": "https://example.com", 59 | "startDate": "2016-01-30", 60 | "summary": "Description of the work that was done.", 61 | "highlights": [ 62 | "Started the company", 63 | "Worked on the company's first product", 64 | "Worked on the company's second product", 65 | "Worked on the company's third product" 66 | ] 67 | }, 68 | { 69 | "name": "Moogle", 70 | "location": "San Antonio, TX", 71 | "description": "A search engine for motorcycles", 72 | "position": "Developer", 73 | "url": "https://example.com", 74 | "startDate": "2019-09-01", 75 | "summary": "I don't even like motorcycles, what the heck.", 76 | "highlights": [ 77 | "Started the company", 78 | "Worked on the company's first product", 79 | "Worked on the company's second product", 80 | "Worked on the company's third product, which was a motorcycle calender app that I was able to make in a week using blockchain technology." 81 | ] 82 | }, 83 | { 84 | "name": "Poogle", 85 | "location": "???", 86 | "description": "A search engine for the unspeakable", 87 | "highlights": [] 88 | }, 89 | { 90 | "name": "Troogle", 91 | "description": "A search engine for the truth", 92 | "position": "Oracle", 93 | "summary": "I am the truth.", 94 | "endDate": "2019-09-01" 95 | }, 96 | { 97 | "name": "Yoogle", 98 | "position": "Tester", 99 | "startDate": "2045-09-30", 100 | "endDate": "2045-10-30", 101 | "url": "https://example.com/yoghurt" 102 | }, 103 | { 104 | "name": "Woogle", 105 | "position": "Desk Person #3092", 106 | "startDate": "1984-01-01", 107 | "endDate": "1984-12-31", 108 | "location": "Airstrip One, Oceania" 109 | }, 110 | { 111 | "name": "Droogle", 112 | "startDate": "2002-12-31", 113 | "endDate": "2014-12-31" 114 | } 115 | ], 116 | "volunteer": [ 117 | { 118 | "organization": "Somewhat Red Cross", 119 | "position": "Volunteer Organizer", 120 | "url": "https://example.com/", 121 | "startDate": "2012-01-01", 122 | "endDate": "2013-01-01", 123 | "summary": "Description of the work that was done, and the impact it had on the organization (quite a bit of text here).\nThere's more than one line here, so that we can test if linebreaks are processed correctly.", 124 | "highlights": [ 125 | "Awarded 'Volunteer of the Month of February'", 126 | "Awarded 'Volunteer of the Month of March'", 127 | "Awarded 'Volunteer of the Month of April'", 128 | "Awarded 'Volunteer of the Year 2012'" 129 | ] 130 | }, 131 | { 132 | "organization": "Animal Rescue League", 133 | "position": "Pet Officer", 134 | "url": "https://example.com/", 135 | "startDate": "2002-03-01", 136 | "endDate": "2020-03-30", 137 | "summary": "Description of the work that was done, and the impact it had on the organization (quite a bit of text here).\nThere's more than one line here, so that we can test if linebreaks are processed correctly.", 138 | "highlights": [ 139 | "Awarded 'Goodest Boy of the Month of February'", 140 | "Awarded 'Goodest Boy of the Month of March'", 141 | "Awarded 'Goodest Boy of the Month of April'", 142 | "Awarded 'Goodest Boy of the Year 2010'" 143 | ] 144 | }, 145 | { 146 | "organization": "Beer Rescue League", 147 | "position": "Principal Disposer", 148 | "startDate": "2123-03-01", 149 | "endDate": "2390-03-30", 150 | "summary": "Environmentally conscious people got together to dispose of beer. I was the principal disposer." 151 | }, 152 | { 153 | "organization": "Monsters, Inc.", 154 | "startDate": "2002-01-31", 155 | "highlights": [ 156 | "Scaring toddlers" 157 | ] 158 | }, 159 | { 160 | "organization": "Colored Cross", 161 | "summary": "We did some things." 162 | } 163 | ], 164 | "education": [ 165 | { 166 | "institution": "Massachusets Institute of Harvard-Stanford", 167 | "url": "https://example.com/", 168 | "area": "Computer Science", 169 | "studyType": "Bachelor", 170 | "startDate": "1999-12-31", 171 | "endDate": "2013-01-01", 172 | "score": "3.9", 173 | "courses": [ 174 | "DB1337 - Basic SQL", 175 | "DB1338 - Advanced SQL", 176 | "DB1339 - SQL for Data Analysis" 177 | ] 178 | }, 179 | { 180 | "institution": "Homeschooled", 181 | "url": "https://example.com/", 182 | "area": "Everything", 183 | "studyType": "Master", 184 | "startDate": "2013-01-01", 185 | "endDate": "2016-01-01", 186 | "score": "2.1", 187 | "courses": [ 188 | "HH0001 - Washing the Dishes", 189 | "HH0002 - Cleaning the Laundry", 190 | "HH0003 - Making the Bed", 191 | "HH0004 - Cleaning the Bathroom" 192 | ] 193 | }, 194 | { 195 | "institution": "Spy school of espionage", 196 | "url": "https://example.com/what", 197 | "score": "0.2", 198 | "courses": [ 199 | "Spying 101", 200 | "Spying 102" 201 | ] 202 | }, 203 | { 204 | "institution": "Acting school", 205 | "area": "Method acting", 206 | "startDate": "2008-03-01", 207 | "score": "1337" 208 | } 209 | ], 210 | "awards": [ 211 | { 212 | "title": "Biggest Volunteer of the Day", 213 | "date": "2014-11-13", 214 | "awarder": "Self-Award", 215 | "summary": "There is probably greater honor." 216 | }, 217 | { 218 | "title": "Top Gun Award", 219 | "date": "2010-12-12", 220 | "awarder": "City of San Francisco", 221 | "summary": "All I did was show up." 222 | }, 223 | { 224 | "title": "German Television Award", 225 | "date": "2008-10-12", 226 | "awarder": "ARD, ZDF, RTL, Sat.1", 227 | "summary": "I did not accept this one." 228 | }, 229 | { 230 | "title": "French Television Award", 231 | "summary": "I did accept this one." 232 | }, 233 | { 234 | "title": "Best Actor Award", 235 | "date": "2008-10-12" 236 | }, 237 | { 238 | "title": "Bad title", 239 | "summary": "This award doesn't have a good title." 240 | }, 241 | { 242 | "title": "Eighth-best Actor Award" 243 | } 244 | ], 245 | "certificates": [ 246 | { 247 | "name": "Certificate of Excellence", 248 | "date": "2021-11-07", 249 | "issuer": "Legitimate Certificate Authority", 250 | "url": "https://certificate.com" 251 | }, 252 | { 253 | "name": "Certificate of Merit", 254 | "date": "2020-11-11", 255 | "issuer": "United States of America", 256 | "url": "https://example.com" 257 | }, 258 | { 259 | "name": "Certificate of Existence" 260 | }, 261 | { 262 | "name": "Certificate of Musical Talent", 263 | "url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ" 264 | }, 265 | { 266 | "name": "Certificate of Nothing", 267 | "date": "2020-11-11" 268 | } 269 | ], 270 | "publications": [ 271 | { 272 | "name": "A Publication", 273 | "publisher": "IEEE", 274 | "releaseDate": "2014-10-29", 275 | "url": "https://publication.com", 276 | "summary": "This paper is about how I did this and that, despite the fact that I did not write it.\nCome to think of it, I probably committed a felony." 277 | }, 278 | { 279 | "name": "Hex-Quantum Fields of Becker-Mendel Swirls in the Presence of a Non-Abelian Group", 280 | "publisher": "Physics of the Literally Impossible", 281 | "releaseDate": "2009-03-04", 282 | "url": "https://publication.com", 283 | "summary": "No one knows what this paper is about.\nAlmost every person thought it was about quantum mechanics, but I am not sure.\nIn fact, they called it impossible. They were right." 284 | }, 285 | { 286 | "name": "Cryptanalysis of the RSA Algorithm Using a Quantum Computer", 287 | "url": "https://example.com/rsa", 288 | "summary": "This paper is extremely advanced not. It is about how to break RSA using a quantum computer." 289 | }, 290 | { 291 | "name": "Ferrum-Quantum Fields of Becker-Mendel Swirls", 292 | "releaseDate": "2002-02-02", 293 | "url": "https://example.com/ferrum" 294 | }, 295 | { 296 | "name": "Acidic toxins in the context of quantum mechanics", 297 | "summary": "What is this even supposed to be" 298 | } 299 | ], 300 | "skills": [ 301 | { 302 | "name": "Web Development", 303 | "level": "Master", 304 | "keywords": [ 305 | "HTML", 306 | "CSS", 307 | "JavaScript" 308 | ] 309 | }, 310 | { 311 | "level": "Master of None", 312 | "keywords": [ 313 | "No", 314 | "Name", 315 | "Given" 316 | ] 317 | }, 318 | { 319 | "name": "Compression", 320 | "level": "Novice", 321 | "keywords": [ 322 | "GZIP", 323 | "BZIP2" 324 | ] 325 | }, 326 | { 327 | "name": "Backend Development" 328 | }, 329 | { 330 | "name": "Fullstack Development", 331 | "keywords": [ 332 | "COBOL", 333 | "Fortran" 334 | ] 335 | }, 336 | { 337 | "name": "Relaxing", 338 | "level": "Amateur", 339 | "keywords": [ 340 | "Couching", 341 | "Napping", 342 | "Sleeping", 343 | "Eating", 344 | "Reading", 345 | "Watching TV", 346 | "Playing video games", 347 | "Playing board games", 348 | "Playing card games", 349 | "Watching the sky", 350 | "Watching the clouds", 351 | "Watching the stars" 352 | ] 353 | } 354 | ], 355 | "languages": [ 356 | { 357 | "language": "Spanish", 358 | "fluency": "Native Speaker" 359 | }, 360 | { 361 | "fluency": "No language given" 362 | }, 363 | { 364 | "language": "English", 365 | "fluency": "Professional Proficiency" 366 | }, 367 | { 368 | "language": "French", 369 | "fluency": "Beginner" 370 | }, 371 | { 372 | "language": "German", 373 | "fluency": "Beginner" 374 | }, 375 | { 376 | "language": "Portuguese" 377 | } 378 | ], 379 | "interests": [ 380 | { 381 | "name": "Wildlife", 382 | "keywords": [ 383 | "Ferrets", 384 | "Unicorns" 385 | ] 386 | }, 387 | { 388 | "keywords": [ 389 | "An", 390 | "interest", 391 | "without", 392 | "a", 393 | "name" 394 | ] 395 | }, 396 | { 397 | "name": "Cars", 398 | "keywords": [ 399 | "BMW", 400 | "Mercedes", 401 | "Porsche" 402 | ] 403 | }, 404 | { 405 | "name": "Computers", 406 | "keywords": [ 407 | "Microsoft", 408 | "Apple", 409 | "Linux" 410 | ] 411 | }, 412 | { 413 | "name": "Choir" 414 | }, 415 | { 416 | "name": "Electronics", 417 | "keywords": [ 418 | "Soldering", 419 | "Programming", 420 | "Circuit Design", 421 | "PCB Design", 422 | "PCB Fabrication", 423 | "PCB Assembly", 424 | "PCB Testing", 425 | "PCB Repair", 426 | "PCB Reverse Engineering", 427 | "PCB Inspection", 428 | "C", 429 | "C++", 430 | "Rust", 431 | "Python", 432 | "IEEE 754", 433 | "IEEE 69", 434 | "IEEE 420" 435 | ] 436 | } 437 | ], 438 | "references": [ 439 | { 440 | "name": "Raphael from Burger World at the corner of Main Avenue and Market Street", 441 | "reference": "Please hire this person, they are a great person. They absolutely never stop talking about the best burger in the world, which you can get right across the street from our restaurant.\nThey are also a great person to have around, mostly." 442 | }, 443 | { 444 | "name": "Manuela Trouchot", 445 | "reference": "Le 20 juin 1789, alors qu'elle ne comprend encore que le Tiers et une partie du Clergé, l'Assemblée nationale prend l'arrêté auquel l'histoire donne le nom de Serment du Jeu de paume. Elle s'y déclare « appelée à fixer la constitution du royaume, opérer la régénération de l'ordre public et maintenir les vrais principes de la monarchie » et s'y engage à « ne jamais se séparer, et [à] se rassembler partout où les circonstances l'exigeront, jusqu'à ce que la constitution du royaume soit établie sur des fondements solides »." 446 | }, 447 | { 448 | "name": "Boaty McBoatface" 449 | }, 450 | { 451 | "reference": "A ghostly reference without an author..." 452 | }, 453 | { 454 | "name": "Boaty McBoatface", 455 | "reference": "Blubb." 456 | } 457 | ], 458 | "projects": [ 459 | { 460 | "name": "Calculater", 461 | "description": "A calculator that can add, subtract, multiply, and divide but takes a long time to do so. It also has a lot of bugs.", 462 | "highlights": [ 463 | "Won best project award (probably)", 464 | "Used Enterprise Java Beans technology" 465 | ], 466 | "keywords": [ 467 | "HTML", 468 | "Java", 469 | "JavaScript" 470 | ], 471 | "startDate": "1998-08-03", 472 | "endDate": "2021-04-28", 473 | "url": "https://calculater.com/", 474 | "roles": [ 475 | "Team Lead", 476 | "Developer", 477 | "Tester", 478 | "Project Manager", 479 | "Scrum Master", 480 | "Product Owner", 481 | "CTO", 482 | "CFO", 483 | "CEO" 484 | ], 485 | "entity": "Coders for Change", 486 | "type": "Application" 487 | }, 488 | { 489 | "name": "Better Cat Petter", 490 | "description": "An app to visit friendly nextdoor cats to pet them. It is a good app. Neighborhood cats are happy this app finally landed in the app store.", 491 | "highlights": [ 492 | "Never miss a cat", 493 | "Solitude is a thing of the past now", 494 | "Don't be allergic to cats" 495 | ], 496 | "keywords": [ 497 | "CSS", 498 | "JavaScript", 499 | "x86 Assembly" 500 | ], 501 | "startDate": "2019-04-01", 502 | "endDate": "2019-04-01", 503 | "url": "https://betterpetcatter.com/", 504 | "roles": [ 505 | "Top Level Engineer", 506 | "Chief Cat Officer" 507 | ], 508 | "type": "Application" 509 | }, 510 | { 511 | "name": "iPhone 34", 512 | "description": "Always needing the latest and greatest, I got sick of waiting and built my own next-next-next-next-nextgen iPhone. It's a great phone, but it's not the best phone. I am also looking for a lawyer now.", 513 | "highlights": [ 514 | "Bricked", 515 | "Woops" 516 | ], 517 | "keywords": [ 518 | "Hammer", 519 | "Nails", 520 | "Some cables" 521 | ], 522 | "startDate": "2020-04-01", 523 | "endDate": "2020-04-01", 524 | "url": "https://iphone34.com", 525 | "roles": [ 526 | "Boss", 527 | "Defendant" 528 | ], 529 | "entity": "Self-Employed", 530 | "type": "Device" 531 | }, 532 | { 533 | "name": "Sniffing Dog", 534 | "description": "A dog that can sniff out and distinguish different types of inkjet printers. It's a good dog, albeit a bit useless" 535 | }, 536 | { 537 | "name": "youPhone 1", 538 | "description": "A phone that can do everything. It's a good phone. My personal competitor to Pear.", 539 | "highlights": [ 540 | "Also bricked", 541 | "Goshdarnit" 542 | ], 543 | "startDate": "1978-04-01", 544 | "roles": [ 545 | "Big Boss" 546 | ], 547 | "type": "Device" 548 | } 549 | ], 550 | "meta": { 551 | "version": "v1.0.0", 552 | "canonical": "https://github.com/jsonresume/resume-schema/blob/v1.0.0/schema.json", 553 | "ancv": { 554 | "template": "Sequential", 555 | "theme": "lollipop", 556 | "ascii_only": false, 557 | "language": "en", 558 | "dec31_as_year": true 559 | } 560 | } 561 | } 562 | -------------------------------------------------------------------------------- /tests/test_data/resumes/partial.resume.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://raw.githubusercontent.com/jsonresume/resume-schema/v1.0.0/schema.json", 3 | "basics": { 4 | "name": "John Doe", 5 | "label": "Programmer", 6 | "image": "http://example.com/image.jpg", 7 | "email": "john@gmail.com", 8 | "phone": "(912) 555-4321", 9 | "url": "https://johndoe.com", 10 | "summary": "A summary of John Doe that is more than 200 characters long so that it can be tested and verified to see if it works and how linebreaks are processed.", 11 | "location": { 12 | "address": "2712 Broadway St\nSecond Street, San Francisco, CA 94111", 13 | "postalCode": "CA 94115", 14 | "city": "San Francisco", 15 | "countryCode": "US", 16 | "region": "California" 17 | }, 18 | "profiles": [ 19 | { 20 | "network": "Reddit" 21 | }, 22 | { 23 | "network": "Instagram", 24 | "url": "https://www.instagram.com/johndoe" 25 | }, 26 | { 27 | "network": "TikTok", 28 | "username": "johndoe" 29 | }, 30 | { 31 | "network": "Twitter", 32 | "username": "john", 33 | "url": "https://twitter.com/john" 34 | }, 35 | { 36 | "network": "LinkedIn", 37 | "username": "johndoe", 38 | "url": "https://linkedin.com/johndoe" 39 | }, 40 | { 41 | "network": "Facebook", 42 | "username": "john.doe", 43 | "url": "https://facebook.com/john.doe" 44 | }, 45 | { 46 | "network": "GitHub", 47 | "username": "johndoe", 48 | "url": "https://github.com/johndoe" 49 | } 50 | ] 51 | }, 52 | "work": [], 53 | "meta": { 54 | "version": "v1.0.0", 55 | "canonical": "https://github.com/jsonresume/resume-schema/blob/v1.0.0/schema.json", 56 | "ancv": { 57 | "template": "Sequential", 58 | "theme": "lollipop", 59 | "ascii_only": false, 60 | "language": "en", 61 | "dec31_as_year": true 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /tests/test_main.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import pytest 4 | from typer.testing import CliRunner 5 | 6 | from ancv import PROJECT_ROOT 7 | from ancv.__main__ import app 8 | from tests import RESUMES 9 | 10 | RUNNER = CliRunner() 11 | 12 | 13 | def test_help_exists() -> None: 14 | result = RUNNER.invoke(app, ["--help"]) 15 | assert result.exit_code == 0 16 | 17 | 18 | def test_serve_exists() -> None: 19 | result = RUNNER.invoke(app, ["serve"]) 20 | assert result.exit_code == 0 21 | 22 | 23 | def test_serve_file_exists() -> None: 24 | result = RUNNER.invoke(app, ["serve", "file", "--help"]) 25 | assert result.exit_code == 0 26 | 27 | 28 | def test_serve_api_exists() -> None: 29 | result = RUNNER.invoke(app, ["serve", "api", "--help"]) 30 | assert result.exit_code == 0 31 | 32 | 33 | def test_version_exists() -> None: 34 | result = RUNNER.invoke(app, ["version"]) 35 | assert result.exit_code == 0 36 | 37 | 38 | def test_list_exists() -> None: 39 | result = RUNNER.invoke(app, ["list"]) 40 | assert result.exit_code == 0 41 | 42 | 43 | def test_generate_schema_exists() -> None: 44 | result = RUNNER.invoke(app, ["generate-schema"]) 45 | assert result.exit_code == 0 46 | 47 | 48 | def test_generate_schema_is_current() -> None: 49 | result = RUNNER.invoke(app, ["generate-schema"]) 50 | assert result.exit_code == 0 51 | assert result.stdout == Path(PROJECT_ROOT, "..", "schema.json").read_text( 52 | encoding="utf-8" 53 | ) 54 | 55 | 56 | @pytest.mark.parametrize("filename", RESUMES.values()) 57 | # All resumes as a single fixture wouldn't be too bad either but doesn't work: 58 | # https://stackoverflow.com/q/56672179/11477374 59 | class TestCli: 60 | def test_validate(self, filename: Path) -> None: 61 | result = RUNNER.invoke(app, ["validate", str(filename)]) 62 | assert result.exit_code == 0 63 | 64 | def test_render(self, filename: Path) -> None: 65 | result = RUNNER.invoke(app, ["render", str(filename)]) 66 | assert result.exit_code == 0 67 | -------------------------------------------------------------------------------- /tests/test_timing.py: -------------------------------------------------------------------------------- 1 | import time 2 | from datetime import timedelta 3 | 4 | import pytest 5 | 6 | from ancv.timing import Stopwatch 7 | 8 | 9 | def test_stopwatch_disallows_same_segments() -> None: 10 | stopwatch = Stopwatch() 11 | with pytest.raises(ValueError): 12 | stopwatch("segment1") 13 | stopwatch("segment1") 14 | 15 | 16 | def sleep(seconds: float) -> None: 17 | """Sleep for the given number of seconds. 18 | 19 | This is a replacement for the built-in `time.sleep()` function which will send 20 | control back to the OS, introducing an uncertainty depending on the OS. 21 | To keep our tests fast, we want to sleep for brief periods, but that will yield a 22 | large relative error from the approx. constant OS thread sleep uncertainties. 23 | """ 24 | now = time.time() 25 | while time.time() <= (now + seconds): 26 | time.sleep(0.001) 27 | 28 | 29 | @pytest.mark.flaky(reruns=3) 30 | def test_stopwatch_basics() -> None: 31 | stopwatch = Stopwatch() 32 | stopwatch("segment1") 33 | sleep(0.1) 34 | stopwatch("segment2") 35 | sleep(0.1) 36 | stopwatch("segment3") 37 | sleep(0.2) 38 | stopwatch.stop() 39 | sleep(0.1) 40 | stopwatch("segment4") 41 | sleep(0.5) 42 | stopwatch.stop() 43 | 44 | expected_timings = { 45 | "segment1": timedelta(seconds=0.1), 46 | "segment2": timedelta(seconds=0.1), 47 | "segment3": timedelta(seconds=0.2), 48 | "segment4": timedelta(seconds=0.5), 49 | } 50 | for real, expected in zip(stopwatch.timings.values(), expected_timings.values()): 51 | # https://stackoverflow.com/a/1133888/11477374 : 52 | os_thread_sleep_uncertainty_microseconds = 25_000 53 | assert ( 54 | pytest.approx( 55 | real.microseconds, abs=os_thread_sleep_uncertainty_microseconds 56 | ) 57 | == expected.microseconds 58 | ) 59 | -------------------------------------------------------------------------------- /tests/visualization/test_templates.py: -------------------------------------------------------------------------------- 1 | import json 2 | from datetime import date 3 | from pathlib import Path 4 | from typing import Optional 5 | 6 | import pytest 7 | from babel.core import Locale 8 | from babel.dates import parse_pattern 9 | from rich.console import NewLine, RenderableType 10 | from rich.style import Style 11 | 12 | from ancv.data.models.resume import Meta, ResumeSchema, TemplateConfig 13 | from ancv.exceptions import ResumeConfigError 14 | from ancv.visualization.templates import ( 15 | Sequential, 16 | Template, 17 | ensure_single_trailing_newline, 18 | ) 19 | from ancv.visualization.themes import DateFormat, Emphasis, Theme 20 | from ancv.visualization.translations import Translation 21 | from tests import ACTUAL_OUTPUTS_DIR, EXPECTED_OUTPUTS_DIR, RESUMES_DIR 22 | 23 | 24 | @pytest.mark.parametrize( 25 | ["input_sequence", "expected_sequence"], 26 | [ 27 | ([], [NewLine()]), 28 | ([1], [1, NewLine()]), 29 | ([1, 2], [1, 2, NewLine()]), 30 | ([1, 2, NewLine()], [1, 2, NewLine()]), 31 | ([1, 2, NewLine(), NewLine()], [1, 2, NewLine()]), 32 | ([1, NewLine(), NewLine(), NewLine(), NewLine()], [1, NewLine()]), 33 | ([NewLine(), NewLine(), NewLine(), NewLine()], [NewLine()]), 34 | ], 35 | ) 36 | def test_ensure_single_trailing_newline( 37 | input_sequence: list[RenderableType], expected_sequence: list[RenderableType] 38 | ) -> None: 39 | ensure_single_trailing_newline(input_sequence) 40 | 41 | for result, expected in zip(input_sequence, expected_sequence, strict=True): 42 | if isinstance(result, NewLine): # Cannot compare for equality 43 | assert isinstance(expected, NewLine) 44 | else: 45 | assert result == expected 46 | 47 | 48 | @pytest.mark.parametrize( 49 | [ 50 | "start", 51 | "end", 52 | "datefmt", 53 | "locale", 54 | "range_sep", 55 | "present", 56 | "collapse", 57 | "dec31_as_year", 58 | "expected", 59 | ], 60 | [ 61 | ( 62 | None, 63 | None, 64 | DateFormat( 65 | full=parse_pattern("MMMM yyyy"), year_only=parse_pattern("yyyy") 66 | ), 67 | Locale("en"), 68 | "-", 69 | "present", 70 | False, 71 | False, 72 | "", 73 | ), 74 | ( 75 | None, 76 | date(2900, 3, 1), 77 | DateFormat( 78 | full=parse_pattern("MMMM yyyy"), year_only=parse_pattern("yyyy") 79 | ), 80 | Locale("en"), 81 | "-", 82 | "present", 83 | False, 84 | False, 85 | "- March 2900", 86 | ), 87 | ( 88 | date(163, 12, 1), 89 | None, 90 | DateFormat( 91 | full=parse_pattern("MMMM yyyy"), year_only=parse_pattern("yyyy") 92 | ), 93 | Locale("en"), 94 | "-", 95 | "present", 96 | False, 97 | False, 98 | "December 0163 - present", 99 | ), 100 | ( 101 | date(2021, 1, 1), 102 | None, 103 | DateFormat( 104 | full=parse_pattern("MMMM yyyy"), year_only=parse_pattern("yyyy") 105 | ), 106 | Locale("en"), 107 | "-", 108 | "today", 109 | False, 110 | False, 111 | "January 2021 - today", 112 | ), 113 | ( 114 | date(2021, 1, 1), 115 | date(2021, 2, 1), 116 | DateFormat( 117 | full=parse_pattern("MMMM yyyy"), year_only=parse_pattern("yyyy") 118 | ), 119 | Locale("en"), 120 | "-", 121 | "present", 122 | False, 123 | False, 124 | "January 2021 - February 2021", 125 | ), 126 | ( 127 | date(1999, 4, 1), 128 | date(2018, 9, 1), 129 | DateFormat(full=parse_pattern("yyyy-MM"), year_only=parse_pattern("yyyy")), 130 | Locale("en"), 131 | "-", 132 | "present", 133 | False, 134 | False, 135 | "1999-04 - 2018-09", 136 | ), 137 | ( 138 | date(1999, 4, 1), 139 | date(2018, 9, 1), 140 | DateFormat(full=parse_pattern("yyyy-MM"), year_only=parse_pattern("yyyy")), 141 | Locale("en"), 142 | "***", 143 | "present", 144 | False, 145 | False, 146 | "1999-04 *** 2018-09", 147 | ), 148 | ( 149 | date(1999, 3, 1), 150 | date(2018, 10, 1), 151 | DateFormat( 152 | full=parse_pattern("MMMM yyyy"), year_only=parse_pattern("yyyy") 153 | ), 154 | Locale("de"), 155 | "***", 156 | "heute", 157 | False, 158 | False, 159 | "März 1999 *** Oktober 2018", 160 | ), 161 | ( 162 | date(1999, 3, 1), 163 | date(2018, 10, 1), 164 | DateFormat( 165 | full=parse_pattern("MMMM yyyy"), year_only=parse_pattern("yyyy") 166 | ), 167 | Locale("es"), 168 | "***", 169 | "heute", 170 | False, 171 | False, 172 | "marzo 1999 *** octubre 2018", 173 | ), 174 | ( 175 | date(2018, 3, 1), 176 | date(2018, 4, 1), 177 | DateFormat( 178 | full=parse_pattern("MMMM yyyy"), year_only=parse_pattern("yyyy") 179 | ), 180 | Locale("en"), 181 | "***", 182 | "present", 183 | True, 184 | False, 185 | "March 2018 *** April 2018", 186 | ), 187 | ( 188 | date(2018, 4, 1), 189 | date(2018, 4, 1), 190 | DateFormat( 191 | full=parse_pattern("MMMM yyyy"), year_only=parse_pattern("yyyy") 192 | ), 193 | Locale("en"), 194 | "***", 195 | "present", 196 | True, 197 | False, 198 | "April 2018", 199 | ), 200 | ( 201 | None, 202 | date(2000, 12, 31), 203 | DateFormat( 204 | full=parse_pattern("MMMM yyyy"), year_only=parse_pattern("yyyy") 205 | ), 206 | Locale("en"), 207 | "---", 208 | "present", 209 | True, 210 | True, 211 | "--- 2000", 212 | ), 213 | ( 214 | date(2000, 12, 31), 215 | date(2000, 12, 31), 216 | DateFormat( 217 | full=parse_pattern("MMMM yyyy"), year_only=parse_pattern("yyyy") 218 | ), 219 | Locale("en"), 220 | "---", 221 | "present", 222 | True, 223 | True, 224 | "2000", 225 | ), 226 | ( 227 | date(2000, 12, 31), 228 | None, 229 | DateFormat( 230 | full=parse_pattern("MMMM yyyy"), year_only=parse_pattern("yyyy") 231 | ), 232 | Locale("en"), 233 | "---", 234 | "present", 235 | True, 236 | True, 237 | "2000 --- present", 238 | ), 239 | ( 240 | date(2000, 12, 31), 241 | date(2002, 12, 31), 242 | DateFormat( 243 | full=parse_pattern("MMMM yyyy"), year_only=parse_pattern("yyyy") 244 | ), 245 | Locale("en"), 246 | "---", 247 | "present", 248 | True, 249 | True, 250 | "2000 --- 2002", 251 | ), 252 | ( 253 | date(2000, 12, 30), 254 | date(2002, 12, 30), 255 | DateFormat( 256 | full=parse_pattern("MMMM yyyy"), year_only=parse_pattern("yyyy") 257 | ), 258 | Locale("en"), 259 | "---", 260 | "present", 261 | True, 262 | True, 263 | "December 2000 --- December 2002", 264 | ), 265 | ( 266 | date(2000, 12, 31), 267 | date(2002, 12, 31), 268 | DateFormat( 269 | full=parse_pattern("MMMM yyyy"), year_only=parse_pattern("yyyy") 270 | ), 271 | Locale("en"), 272 | "---", 273 | "present", 274 | True, 275 | False, 276 | "December 2000 --- December 2002", 277 | ), 278 | ( 279 | date(1995, 12, 31), 280 | date(1999, 12, 31), 281 | DateFormat( 282 | full=parse_pattern("MMMM yyyy"), year_only=parse_pattern("''yy") 283 | ), 284 | Locale("en"), 285 | "---", 286 | "present", 287 | True, 288 | True, 289 | "'95 --- '99", 290 | ), 291 | ( 292 | date(1995, 12, 31), 293 | date(1999, 12, 31), 294 | DateFormat( 295 | full=parse_pattern("MMMM yyyy"), year_only=parse_pattern("'Anno' yy") 296 | ), 297 | Locale("en"), 298 | "to", 299 | "present", 300 | True, 301 | True, 302 | "Anno 95 to Anno 99", 303 | ), 304 | ], 305 | ) 306 | def test_default_date_range( 307 | start: Optional[date], 308 | end: Optional[date], 309 | datefmt: DateFormat, 310 | locale: Locale, 311 | range_sep: str, 312 | present: str, 313 | collapse: bool, 314 | dec31_as_year: bool, 315 | expected: str, 316 | ) -> None: 317 | irrelevant = "." 318 | template = Sequential( 319 | ResumeSchema(), 320 | Theme( 321 | bullet=irrelevant, 322 | emphasis=Emphasis( 323 | maximum=Style(), 324 | strong=Style(), 325 | medium=Style(), 326 | weak=Style(), 327 | ), 328 | sep=irrelevant, 329 | range_sep=range_sep, 330 | rulechar=irrelevant, 331 | datefmt=datefmt, 332 | ), 333 | Translation( 334 | grade=irrelevant, 335 | awarded_by=irrelevant, 336 | issued_by=irrelevant, 337 | roles=irrelevant, 338 | skills=irrelevant, 339 | work=irrelevant, 340 | volunteer=irrelevant, 341 | education=irrelevant, 342 | awards=irrelevant, 343 | certificates=irrelevant, 344 | publications=irrelevant, 345 | languages=irrelevant, 346 | references=irrelevant, 347 | interests=irrelevant, 348 | projects=irrelevant, 349 | present=present, 350 | ), 351 | locale=locale, 352 | ascii_only=False, 353 | dec31_as_year=dec31_as_year, 354 | ) 355 | assert template._format_date_range(start, end, collapse) == expected 356 | 357 | 358 | @pytest.mark.parametrize( 359 | ["model", "expectation"], 360 | [ 361 | ( 362 | ResumeSchema(meta=Meta(ancv=TemplateConfig(template="DOESNT_EXIST"))), 363 | pytest.raises(ResumeConfigError, match="^Unknown template: DOESNT_EXIST$"), 364 | ), 365 | ( 366 | ResumeSchema(meta=Meta(ancv=TemplateConfig(theme="DOESNT_EXIST"))), 367 | pytest.raises(ResumeConfigError, match="^Unknown theme: DOESNT_EXIST$"), 368 | ), 369 | ( 370 | ResumeSchema(meta=Meta(ancv=TemplateConfig(language="zz"))), 371 | pytest.raises(ResumeConfigError, match="^Unknown language: zz$"), 372 | ), 373 | ], 374 | ) 375 | def test_rejects_unknown_configs(model, expectation) -> None: 376 | with expectation: 377 | Template.from_model_config(model) 378 | 379 | 380 | @pytest.mark.parametrize( 381 | ["path"], [(file,) for file in sorted(Path(RESUMES_DIR).glob("*.resume.json"))] 382 | ) 383 | def test_expected_outputs(path: Path) -> None: 384 | """For each resume, compare with expected rendered output in sibling directory.""" 385 | 386 | with open(path, encoding="utf8") as f: 387 | json_data = json.load(f) 388 | 389 | rendered_resume = Template.from_model_config( 390 | ResumeSchema.model_validate(json_data) 391 | ).render() 392 | 393 | while path.suffix: 394 | path = path.with_suffix("") 395 | 396 | expected_output = EXPECTED_OUTPUTS_DIR / f"{path.name}.resume.output.txt" 397 | 398 | is_equal = rendered_resume == expected_output.read_text(encoding="utf-8") 399 | 400 | # Provide a mechanism for debugging tests easier: write out to a file. The diff 401 | # printed by `pytest -vv` is not very helpful, as the ANSI escape codes are rendered 402 | # and cannot be inspected 'raw'. Open the below file in a text editor to see raw 403 | # bytes and compare. 404 | with open( 405 | ACTUAL_OUTPUTS_DIR 406 | / f"{'OK' if is_equal else 'FAIL'}-{path.name}.resume.output.txt", 407 | "w", 408 | encoding="utf8", 409 | ) as f: 410 | f.write(rendered_resume) 411 | 412 | assert is_equal 413 | -------------------------------------------------------------------------------- /tests/web/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from aiohttp.web import Application 3 | 4 | from ancv.reflection import METADATA 5 | from ancv.web.server import APIHandler, FileHandler 6 | from tests import EXPECTED_OUTPUTS_DIR, GH_TOKEN, RESUMES 7 | 8 | 9 | @pytest.fixture(scope="function") 10 | def api_client_app() -> Application: 11 | return APIHandler( 12 | requester=f"{METADATA.name}-PYTEST-REQUESTER", 13 | token=GH_TOKEN, 14 | terminal_landing_page=f"{METADATA.name}-PYTEST-HOMEPAGE", 15 | browser_landing_page=f"{METADATA.name}-PYTEST-LANDING_PAGE", 16 | ).app 17 | 18 | 19 | @pytest.fixture(scope="function") 20 | def file_handler_app() -> Application: 21 | return FileHandler( 22 | file=RESUMES["full.resume.json"], 23 | ).app 24 | 25 | 26 | @pytest.fixture(scope="function") 27 | def showcase_output() -> str: 28 | with open( 29 | EXPECTED_OUTPUTS_DIR / "showcase.resume.output.txt", encoding="utf8" 30 | ) as f: 31 | return f.read() 32 | -------------------------------------------------------------------------------- /tests/web/test_client.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from contextlib import AbstractContextManager 3 | from contextlib import nullcontext as does_not_raise 4 | 5 | import aiohttp 6 | import pytest 7 | from gidgethub.aiohttp import GitHubAPI 8 | 9 | from ancv import SIPrefix 10 | from ancv.exceptions import ResumeLookupError 11 | from ancv.reflection import METADATA 12 | from ancv.timing import Stopwatch 13 | from ancv.web.client import get_resume 14 | from tests import GH_TOKEN, gh_rate_limited 15 | 16 | 17 | @pytest.fixture(scope="function") 18 | def stopwatch(): 19 | return Stopwatch() 20 | 21 | 22 | @pytest.fixture(scope="function") 23 | async def client_session() -> aiohttp.ClientSession: 24 | assert ( 25 | asyncio.get_running_loop() 26 | ), "`aiohttp.ClientSession` constructor will need loop running." 27 | 28 | # The constructor accesses the async event loop, and if none is running errors. So 29 | # this pytest fixture function needs to be marked `async` for `pytest-asyncio` with 30 | # the `asyncio_mode = "auto"` option set to pick it up automatically and *provide* a 31 | # loop. 32 | return aiohttp.ClientSession() 33 | 34 | 35 | @pytest.fixture(scope="function") 36 | def gh_api(client_session: aiohttp.ClientSession): 37 | return GitHubAPI( 38 | client_session, 39 | requester=f"{METADATA.name}-PYTEST-REQUESTER", 40 | oauth_token=GH_TOKEN, 41 | ) 42 | 43 | 44 | @pytest.mark.parametrize( 45 | ["username", "size_limit", "filename", "expectation"], 46 | [ 47 | ( 48 | "alexpovel", 49 | 1 * SIPrefix.MEGA, 50 | "resume.json", 51 | does_not_raise(), 52 | ), 53 | ( 54 | "alexpovel", 55 | 0, 56 | "resume.json", 57 | pytest.raises( 58 | ResumeLookupError, 59 | match=r"^Resume file too large \(limit: 0 Bytes, got \d+\.\d+ kB\)\.$", 60 | ), 61 | ), 62 | ( 63 | "alexpovel", 64 | 1 * SIPrefix.MEGA, 65 | "resume.invalid-json", 66 | pytest.raises(ResumeLookupError, match=r"^Got malformed JSON\.$"), 67 | ), 68 | ( 69 | "alexpovel", 70 | 1 * SIPrefix.MEGA, 71 | "resume.invalid-schema.json", 72 | pytest.raises( 73 | ResumeLookupError, 74 | match=r"^Got legal JSON but wrong schema \(cf\. https://jsonresume\.org/schema/\)$", 75 | ), 76 | ), 77 | ], 78 | ) 79 | @gh_rate_limited 80 | async def test_get_resume_validations( 81 | username: str, 82 | size_limit: int, 83 | filename: str, 84 | expectation: AbstractContextManager[pytest.ExceptionInfo[BaseException]], # Unsure 85 | # Fixtures: 86 | gh_api: GitHubAPI, 87 | stopwatch: Stopwatch, 88 | ) -> None: 89 | assert asyncio.get_running_loop() 90 | 91 | with expectation: 92 | await get_resume( 93 | user=username, 94 | github=gh_api, 95 | stopwatch=stopwatch, 96 | filename=filename, 97 | size_limit=size_limit, 98 | ) 99 | -------------------------------------------------------------------------------- /tests/web/test_server.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from contextlib import AbstractContextManager 3 | from contextlib import nullcontext as does_not_raise 4 | from datetime import timedelta 5 | from http import HTTPStatus 6 | from typing import Any, Optional 7 | 8 | import aiohttp 9 | import aiohttp.web 10 | import pytest 11 | from aiohttp.client import ClientResponse 12 | from aiohttp.web import Application, Response, json_response 13 | 14 | from ancv.web.server import ( 15 | SHOWCASE_RESUME, 16 | SHOWCASE_USERNAME, 17 | WebHandler, 18 | is_terminal_client, 19 | server_timing_header, 20 | ) 21 | from tests import gh_rate_limited 22 | 23 | 24 | @pytest.mark.parametrize( 25 | ["user_agent", "expected"], 26 | [ 27 | ("curl", True), 28 | ("curl/7.83.1", True), 29 | ("wget", True), 30 | ("Wget/1.21", True), 31 | ("Wget/1.21 (cygwin)", True), 32 | ("powershell", True), 33 | ("powershell/5.1.1", True), 34 | ( 35 | "Mozilla/5.0 (Windows NT; Windows NT 10.0; en-US) WindowsPowerShell/5.1.19041.1682", 36 | True, 37 | ), 38 | ("PowerShell/7.2.5", True), 39 | ( 40 | "Mozilla/5.0 (Windows NT 10.0; Microsoft Windows 10.0.19044; en-US) PowerShell/7.2.5", 41 | True, 42 | ), 43 | ( 44 | "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/78.0.3904.108 Safari/537.36", 45 | False, 46 | ), 47 | ( 48 | "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/78.0.3904.108 Safari/537.36", 49 | False, 50 | ), 51 | ( 52 | "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/78.0.3904.108 Safari/537.36", 53 | False, 54 | ), 55 | ( 56 | "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/78.0.3904.108 Safari/537.36 Edge/18.18362", 57 | False, 58 | ), 59 | ], 60 | ) 61 | def test_is_terminal_client(user_agent: str, expected: bool) -> None: 62 | assert is_terminal_client(user_agent) == expected 63 | 64 | 65 | @pytest.mark.filterwarnings("ignore:Request.message is deprecated") # No idea... 66 | @pytest.mark.filterwarnings("ignore:Exception ignored in") # No idea... 67 | class TestApiHandler: 68 | @pytest.mark.parametrize( 69 | ["user_agent", "expected_http_code"], 70 | [ 71 | ("curl", HTTPStatus.OK), 72 | ("powershell", HTTPStatus.OK), 73 | ("wget", HTTPStatus.OK), 74 | ("Some Browser/1.0", HTTPStatus.FOUND), 75 | ( 76 | "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:103.0) Gecko/20100101 Firefox/103.0", 77 | HTTPStatus.FOUND, 78 | ), 79 | ], 80 | ) 81 | async def test_root_endpoint( 82 | self, 83 | user_agent: str, 84 | expected_http_code: HTTPStatus, 85 | aiohttp_client: Any, 86 | api_client_app: Application, 87 | ) -> None: 88 | assert asyncio.get_running_loop() 89 | 90 | client = await aiohttp_client(api_client_app) 91 | 92 | resp: ClientResponse = await client.get( 93 | "/", 94 | headers={"User-Agent": user_agent}, 95 | allow_redirects=False, # So we can test the redirect 96 | ) 97 | assert resp.status == expected_http_code 98 | 99 | @pytest.mark.parametrize( 100 | ["username", "expected_http_code", "expected_error_message"], 101 | [ 102 | ( 103 | "alexpovel", 104 | HTTPStatus.OK, 105 | None, 106 | ), 107 | ( 108 | "thomasdavis", 109 | HTTPStatus.OK, 110 | None, 111 | ), 112 | ( 113 | "python", # An organization without any gists... 114 | HTTPStatus.NOT_FOUND, 115 | "No 'resume.json' file found in any gist of 'python'.", 116 | ), 117 | ( # Let's hope this never turns into a real username... 118 | "gj5489gujv4398x73x23x892", 119 | HTTPStatus.NOT_FOUND, 120 | "User gj5489gujv4398x73x23x892 not found.", 121 | ), 122 | ( 123 | "readme", # GitHub blog of some sort 124 | HTTPStatus.NOT_FOUND, 125 | "User readme not found.", 126 | ), 127 | ( 128 | "this-user-should-fail-validation-because-its-username-is-way-too-long", 129 | HTTPStatus.BAD_REQUEST, 130 | "400: Invalid username: this-user-should-fail-validation-because-its-username-is-way-too-long", 131 | ), 132 | ( 133 | "-invalid", 134 | HTTPStatus.BAD_REQUEST, 135 | "400: Invalid username: -invalid", 136 | ), 137 | ], 138 | ) 139 | @gh_rate_limited 140 | async def test_username_endpoint( 141 | self, 142 | username: str, 143 | expected_http_code: HTTPStatus, 144 | expected_error_message: Optional[str], 145 | aiohttp_client: Any, 146 | api_client_app: Application, 147 | ) -> None: 148 | assert asyncio.get_running_loop() 149 | 150 | client = await aiohttp_client(api_client_app) 151 | 152 | resp = await client.get(f"/{username}") 153 | assert resp.status == expected_http_code 154 | 155 | if expected_error_message is not None: 156 | assert await resp.text() == expected_error_message 157 | 158 | async def test_showcase_endpoint( 159 | self, 160 | aiohttp_client: Any, 161 | api_client_app: Application, 162 | ) -> None: 163 | assert asyncio.get_running_loop() 164 | 165 | client = await aiohttp_client(api_client_app) 166 | 167 | resp: ClientResponse = await client.get(f"/{SHOWCASE_USERNAME}") 168 | assert resp.status == HTTPStatus.OK 169 | assert await resp.text() == SHOWCASE_RESUME 170 | 171 | @pytest.mark.parametrize( 172 | ["username", "expected_contained_text"], 173 | [ 174 | ("alexpovel", "Experience"), 175 | ], 176 | ) 177 | @gh_rate_limited 178 | async def test_return_content( 179 | self, 180 | username: str, 181 | expected_contained_text: str, 182 | aiohttp_client: Any, 183 | api_client_app: Application, 184 | ) -> None: 185 | assert asyncio.get_running_loop() 186 | 187 | client = await aiohttp_client(api_client_app) 188 | 189 | resp = await client.get(f"/{username}") 190 | 191 | text = await resp.text() 192 | assert expected_contained_text in text 193 | 194 | 195 | @pytest.mark.filterwarnings("ignore:Request.message is deprecated") # No idea... 196 | @pytest.mark.filterwarnings("ignore:Exception ignored in") # No idea... 197 | class TestFileHandler: 198 | @pytest.mark.parametrize( 199 | ["expected_http_code", "expected_str_content"], 200 | [ 201 | (HTTPStatus.OK, "John Doe"), 202 | (HTTPStatus.OK, "Experience"), 203 | (HTTPStatus.OK, "Skills"), 204 | ], 205 | ) 206 | async def test_root_endpoint( 207 | self, 208 | expected_http_code: HTTPStatus, 209 | expected_str_content: str, 210 | aiohttp_client: Any, 211 | file_handler_app: Application, 212 | ) -> None: 213 | assert asyncio.get_running_loop() 214 | 215 | client = await aiohttp_client(file_handler_app) 216 | 217 | resp: ClientResponse = await client.get("/") 218 | assert resp.status == expected_http_code 219 | assert expected_str_content in await resp.text() 220 | 221 | 222 | @pytest.mark.parametrize( 223 | ["timings", "expected", "expectation"], 224 | [ 225 | (None, None, pytest.raises(AttributeError)), 226 | ( 227 | {}, 228 | "", 229 | does_not_raise(), 230 | ), 231 | ( 232 | { 233 | "Spaces Work As Well": timedelta(seconds=0.1), 234 | }, 235 | "Spaces-Work-As-Well;dur=100", 236 | does_not_raise(), 237 | ), 238 | ( 239 | { 240 | "A": timedelta(seconds=0), 241 | }, 242 | "A;dur=0", 243 | does_not_raise(), 244 | ), 245 | ( 246 | { 247 | "A": timedelta(seconds=0.1), 248 | }, 249 | "A;dur=100", 250 | does_not_raise(), 251 | ), 252 | ( 253 | { 254 | "A": timedelta(seconds=1), 255 | "B": timedelta(seconds=2), 256 | }, 257 | "A;dur=1000, B;dur=2000", 258 | does_not_raise(), 259 | ), 260 | ( 261 | { 262 | "A": timedelta(seconds=1), 263 | "B": timedelta(seconds=2), 264 | "C": timedelta(seconds=3), 265 | "D": timedelta(seconds=4), 266 | "E": timedelta(seconds=5), 267 | "F": timedelta(seconds=6), 268 | "G": timedelta(seconds=7), 269 | "H": timedelta(seconds=8), 270 | "I": timedelta(seconds=9), 271 | "J": timedelta(seconds=10), 272 | "K": timedelta(seconds=11), 273 | "L": timedelta(seconds=12), 274 | "M": timedelta(seconds=13), 275 | "N": timedelta(seconds=14), 276 | "O": timedelta(seconds=15), 277 | "P": timedelta(seconds=16), 278 | "Q": timedelta(seconds=17), 279 | "R": timedelta(seconds=18), 280 | "S": timedelta(seconds=19), 281 | "T": timedelta(seconds=20), 282 | "U": timedelta(seconds=21), 283 | "V": timedelta(seconds=22), 284 | "W": timedelta(seconds=23), 285 | "X": timedelta(seconds=24), 286 | "Y": timedelta(seconds=25), 287 | "Z": timedelta(seconds=26), 288 | }, 289 | "A;dur=1000, B;dur=2000, C;dur=3000, D;dur=4000, E;dur=5000, F;dur=6000, G;dur=7000, H;dur=8000, I;dur=9000, J;dur=10000, K;dur=11000, L;dur=12000, M;dur=13000, N;dur=14000, O;dur=15000, P;dur=16000, Q;dur=17000, R;dur=18000, S;dur=19000, T;dur=20000, U;dur=21000, V;dur=22000, W;dur=23000, X;dur=24000, Y;dur=25000, Z;dur=26000", 290 | does_not_raise(), 291 | ), 292 | ], 293 | ) 294 | def test_server_timing_header( 295 | timings: dict[str, timedelta], 296 | expected: str, 297 | expectation: AbstractContextManager[pytest.ExceptionInfo[BaseException]], # Unsure 298 | ) -> None: 299 | with expectation: 300 | assert server_timing_header(timings) == expected 301 | 302 | 303 | def test_exact_showcase_output(showcase_output: str) -> None: 304 | assert SHOWCASE_RESUME == showcase_output 305 | 306 | 307 | @pytest.mark.filterwarnings("ignore:Request.message is deprecated") 308 | @pytest.mark.filterwarnings("ignore:Exception ignored in") 309 | class TestWebHandler: 310 | async def test_web_handler_basic_functionality( 311 | self, 312 | aiohttp_client: Any, 313 | aiohttp_server: Any, 314 | ) -> None: 315 | hitcount = 0 316 | 317 | # Set up a mock resume server 318 | async def mock_resume_handler(request: aiohttp.web.Request) -> Response: 319 | nonlocal hitcount 320 | hitcount += 1 321 | return json_response( 322 | {"basics": {"name": "Test User", "label": "Developer"}} 323 | ) 324 | 325 | # Create and start mock server 326 | mock_app = Application() 327 | mock_app.router.add_get("/resume.json", mock_resume_handler) 328 | mock_server = await aiohttp_server(mock_app) 329 | 330 | # Create WebHandler pointing to our mock server 331 | destination = f"http://localhost:{mock_server.port}/resume.json" 332 | refresh_interval = timedelta(seconds=1) 333 | handler = WebHandler(destination, refresh_interval=refresh_interval) 334 | 335 | # Create test client 336 | client = await aiohttp_client(handler.app) 337 | 338 | # Test initial fetch 339 | resp = await client.get("/") 340 | assert resp.status == HTTPStatus.OK 341 | assert hitcount == 1 # First hit 342 | content = await resp.text() 343 | assert "Test User" in content 344 | assert "Developer" in content 345 | 346 | # Test caching 347 | first_response = content 348 | resp = await client.get("/") 349 | assert await resp.text() == first_response 350 | assert hitcount == 1 # Still one hit, response was cached 351 | 352 | # Test refresh after interval 353 | await asyncio.sleep(refresh_interval.total_seconds() + 0.1) 354 | resp = await client.get("/") 355 | assert resp.status == HTTPStatus.OK 356 | assert await resp.text() == first_response 357 | assert hitcount == 2 # Second hit after cache expired 358 | 359 | async def test_web_handler_error_handling( 360 | self, 361 | aiohttp_client: Any, 362 | aiohttp_server: Any, 363 | ) -> None: 364 | # Set up server that returns errors 365 | async def error_handler(request: aiohttp.web.Request) -> Response: 366 | return Response(status=500) 367 | 368 | mock_app = Application() 369 | mock_app.router.add_get("/error.json", error_handler) 370 | mock_server = await aiohttp_server(mock_app) 371 | 372 | # Create WebHandler pointing to error endpoint 373 | destination = f"http://localhost:{mock_server.port}/error.json" 374 | handler = WebHandler(destination) 375 | 376 | client = await aiohttp_client(handler.app) 377 | 378 | # Test error response 379 | resp = await client.get("/") 380 | assert resp.status == HTTPStatus.INTERNAL_SERVER_ERROR 381 | --------------------------------------------------------------------------------