├── .env.template ├── .github ├── FUNDING.yml └── workflows │ └── testing.yml ├── .gitignore ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── action.yml ├── compose.yml ├── containerfile ├── docker-compose.yml ├── dockerfile ├── main.py ├── pyproject.toml └── tests ├── __init__.py ├── sample_data.json └── test_main.py /.env.template: -------------------------------------------------------------------------------- 1 | # Do NOT edit this file, make 2 | # a copy and rename as `.env` 3 | 4 | INPUT_GH_TOKEN= 5 | INPUT_WAKATIME_API_KEY= 6 | # meta 7 | INPUT_API_BASE_URL= 8 | INPUT_REPOSITORY= 9 | # content 10 | INPUT_SHOW_TITLE= 11 | INPUT_SECTION_NAME= 12 | INPUT_BLOCKS= 13 | INPUT_CODE_LANG= 14 | INPUT_TIME_RANGE= 15 | INPUT_LANG_COUNT= 16 | INPUT_SHOW_TIME= 17 | INPUT_SHOW_TOTAL= 18 | INPUT_SHOW_MASKED_TIME= 19 | INPUT_STOP_AT_OTHER= 20 | INPUT_IGNORED_LANGUAGES= 21 | # commit 22 | INPUT_COMMIT_MESSAGE= 23 | INPUT_TARGET_BRANCH= 24 | INPUT_TARGET_PATH= 25 | INPUT_COMMITTER_NAME= 26 | INPUT_COMMITTER_EMAIL= 27 | INPUT_AUTHOR_NAME= 28 | INPUT_AUTHOR_EMAIL= 29 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | ko_fi: athulcyriac 4 | custom: https://www.buymeacoffee.com/athulca 5 | -------------------------------------------------------------------------------- /.github/workflows/testing.yml: -------------------------------------------------------------------------------- 1 | name: WakaReadme CI 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | pull_request: 7 | branches: [master] 8 | workflow_dispatch: 9 | 10 | jobs: 11 | UnitTests: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v3 15 | - name: Build docker image 16 | run: | 17 | # Clear existing cache 18 | docker builder prune --force 19 | 20 | # Build and run container (executes unit tests) 21 | docker compose -p waka-readme -f ./compose.yml up --no-color --pull always --build --force-recreate 22 | 23 | # Cleanup 24 | docker compose -p waka-readme -f ./compose.yml down --rmi all 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | poetry.lock 103 | 104 | # pdm 105 | # Similar to poetry.lock, it is generally recommended to include pdm.lock in version control. 106 | # https://pdm.fming.dev/latest/usage/project/#working-with-version-control 107 | # However, this project does not rely on pdm for production. 108 | pdm.lock 109 | .pdm-python 110 | .pdm-build 111 | 112 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 113 | __pypackages__/ 114 | 115 | # Celery stuff 116 | celerybeat-schedule 117 | celerybeat.pid 118 | 119 | # SageMath parsed files 120 | *.sage.py 121 | 122 | # Environments 123 | *.env 124 | env.sh 125 | .venv 126 | env/ 127 | venv/ 128 | ENV/ 129 | env.bak/ 130 | venv.bak/ 131 | 132 | # Spyder project settings 133 | .spyderproject 134 | .spyproject 135 | 136 | # Rope project settings 137 | .ropeproject 138 | 139 | # mkdocs documentation 140 | /site 141 | 142 | # mypy 143 | .mypy_cache/ 144 | .dmypy.json 145 | dmypy.json 146 | 147 | # Pyre type checker 148 | .pyre/ 149 | 150 | # pytype static type analyzer 151 | .pytype/ 152 | 153 | # Cython debug symbols 154 | cython_debug/ 155 | 156 | # PyCharm 157 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 158 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 159 | # and can be added to the global gitignore or merged into this file. For a more nuclear 160 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 161 | #.idea/ 162 | 163 | # VSCode 164 | .vscode/ 165 | 166 | # asdf/rtx 167 | .tool-versions 168 | .rtx.toml 169 | .mise.toml 170 | 171 | # ruff 172 | .ruff_cache 173 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | ![python_ver](https://img.shields.io/badge/Python-%3E%3D3.12-blue.svg) 4 | 5 | > First off, thank you! Please follow along. 6 | 7 | **You need to _`fork`_ this repository & _`clone`_ it onto your system.** Inside the cloned folder, create a `.env` file with the following contents (without `# comments`): 8 | 9 | ```ini 10 | INPUT_GH_TOKEN=EXAMPLE_GITHUB_PAT # required (for development) 11 | INPUT_WAKATIME_API_KEY=EXAMPLE-WAKATIME-API-KEY # required 12 | INPUT_API_BASE_URL=https://wakatime.com/api # required 13 | INPUT_REPOSITORY=GITHUB_USERNAME/REPOSITORY_NAME # required 14 | INPUT_COMMIT_MESSAGE=Updated WakaReadme graph with new metrics 15 | INPUT_SHOW_TITLE=true 16 | INPUT_SECTION_NAME=waka 17 | INPUT_BLOCKS=-> 18 | INPUT_SHOW_TIME=true 19 | INPUT_SHOW_TOTAL=true 20 | INPUT_TIME_RANGE=last_7_days 21 | INPUT_SHOW_MASKED_TIME=false 22 | INPUT_LANG_COUNT=0 23 | INPUT_STOP_AT_OTHER=true 24 | INPUT_IGNORED_LANGUAGES= 25 | ``` 26 | 27 | **NEVER commit this `.env` file!** 28 | 29 | ## Using containers (recommended) 30 | 31 | > Assumes that you already have latest version of either [`podman`](https://podman.io/) or [`docker`](https://www.docker.com/) (with [`compose`](https://docs.docker.com/compose/)) installed & configured. 32 | > 33 | > Replace `podman` with `docker` everywhere, if you're using the latter. 34 | 35 | ```sh 36 | # Build and watch logs 37 | $ podman-compose -p waka-readme -f ./docker-compose.yml up 38 | # Cleanup 39 | $ podman-compose -p waka-readme -f ./docker-compose.yml down 40 | ``` 41 | 42 | --- 43 | 44 | ## Using virtual environments 45 | 46 | > Assumes you've already installed & configured latest version of [python](https://www.python.org/). 47 | 48 | 1. Inside the cloned folder run the following commands to install dependencies 49 | 50 | ```sh 51 | $ python -m venv .venv 52 | $ . ./.venv/bin/activate 53 | $ python -m pip install . 54 | # ... install decencies ... 55 | ``` 56 | 57 | to activate virtual environment & install dependencies. 58 | 59 | 2. To test or execute the program in development, run: 60 | 61 | ```sh 62 | (.venv)$ python -m unittest discover # run tests 63 | (.venv)$ python -m main --dev # execute program in dev mode 64 | ``` 65 | 66 | > You can use any other virtual environment & dependency manager as well. 67 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 ATHUL CYRIAC AJAY 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 |

2 | waka-readme 8 |

9 | 10 | # Dev Metrics in Readme [![Unit Tests](https://github.com/athul/waka-readme/actions/workflows/testing.yml/badge.svg?branch=master)](https://github.com/athul/waka-readme/actions/workflows/testing.yml) 11 | 12 | [WakaTime](https://wakatime.com) coding metrics on your profile readme. 13 | 14 | 15 | 16 | 18 | new_secrets_actions 20 | 21 | 22 | 23 | :speech_balloon: **Forum** | [GitHub discussions][gh_discuss] 24 | 25 | ## New to WakaTime? 26 | 27 | > Nope? Skip to [#Prep work](#prep-work). 28 | 29 | WakaTime gives you an idea of the time you spent on coding. 30 | This helps you boost your productivity and competitive edge (aka _flex_ :muscle:). 31 | 32 | 1. Head over to and create an account. 33 | 2. After logging in get your WakaTime API Key from . 34 | 3. Install [WakaTime plugin][waka_plugins] in your favorite editor / IDE. 35 | 4. Paste in your API key to start telemetry. 36 | 37 | :information_source: **Info** | You can read [WakaTime help][waka_help] to know more about configurations. 38 | Alternatively, you can fetch data from WakaTime compatible services such as [Wakapi][wakapi] or [Hakatime][hakatime]. 39 | 40 | ## Prep Work 41 | 42 | A GitHub repository and a `README.md` file is required. We'll be making use of readme in the [profile repository][profile_readme]. 43 | 44 | - Save the `README.md` file after copy-pasting the following special comments. Your dev-metics will show up in between. 45 | 46 | ```md 47 | 48 | 49 | ``` 50 | 51 | `` and `` are placeholders and must be retained as is. Whereas "`waka`" can be replaced by any alphanumeric string. See [#Tweaks](#tweaks) section for more. 52 | 53 | - Navigate to your repo's `Settings`: 54 | - Go to `Secrets` (at `https://github.com/USERNAME/USERNAME/settings/secrets/actions/new` by replacing the `USERNAME` with your own username) and add a new secret "_Named_" `WAKATIME_API_KEY` with your API key as it's "_Secret_". 55 | 56 | 57 | 58 | 60 | new_secrets_actions 62 | 63 | 64 | 65 | > If you're not using [profile repository][profile_readme], add another secret "_Named_" `GH_TOKEN` and in place of "_Secret_" insert your [GitHub token][gh_access_token]. 66 | 67 | - Go to `Workflow permissions` under `Actions` (at `https://github.com/USERNAME/USERNAME/settings/actions` by replacing the `USERNAME` with your own username) and set `Read and write permissions`. 68 | 69 | 70 | 71 | 73 | new_secrets_actions 75 | 76 | 77 | 78 | - Create a new workflow file named `waka-readme.yml` inside `.github/workflows/` folder of your repository. 79 | - Clear all existing contents, add following lines and save the file. 80 | 81 | ```yml 82 | name: Waka Readme 83 | 84 | on: 85 | # for manual workflow trigger 86 | workflow_dispatch: 87 | schedule: 88 | # runs at 12 AM UTC (5:30 AM IST) 89 | - cron: "0 0 * * *" 90 | 91 | jobs: 92 | update-readme: 93 | name: WakaReadme DevMetrics 94 | runs-on: ubuntu-latest 95 | steps: 96 | - uses: athul/waka-readme@master # this action name 97 | with: 98 | WAKATIME_API_KEY: ${{ secrets.WAKATIME_API_KEY }} 99 | ``` 100 | 101 | Refer [#Example](#example) section for a full blown workflow file. 102 | 103 | ## Tweaks 104 | 105 | There are many flags that you can modify as you see fit. 106 | 107 | ### Meta Tweaks 108 | 109 | | Environment flag | Options (`Default`, `Other`, ...) | Description | 110 | | ---------------- | ---------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- | 111 | | `API_BASE_URL` | `https://wakatime.com/api`, `https://wakapi.dev/api`, `https://hakatime.mtx-dev.xyz/api` | Use WakaTime compatible services like [Wakapi][wakapi] & [Hakatime][hakatime] | 112 | | `REPOSITORY` | `/`, `/` | Waka-readme stats will appear on the provided repository | 113 | 114 | ### Content Tweaks 115 | 116 | | Environment flag | Options (`Default`, `Other`, ...) | Description | 117 | | ------------------- | ----------------------------------------------------------------------- | --------------------------------------------------------------------------------- | 118 | | `SHOW_TITLE` | `false`, `true` | Add title to waka-readme stats blob | 119 | | `SECTION_NAME` | `waka`, any alphanumeric string | The generator will look for section name to fill up the readme. | 120 | | `BLOCKS` | `░▒▓█`, `⣀⣄⣤⣦⣶⣷⣿`, `-#`, `=>`, you can be creative | Ascii art used to build stats graph | 121 | | `CODE_LANG` | `txt`, `python` `ruby` `json` , you can use other languages also | Language syntax based highlighted text | 122 | | `TIME_RANGE` | `last_7_days`, `last_30_days`, `last_6_months`, `last_year`, `all_time` | String representing a dispensation from which stats are aggregated | 123 | | `LANG_COUNT` | `5`, any plausible number | Number of languages to be displayed | 124 | | `SHOW_TIME` | `true`, `false` | Displays the amount of time spent for each language | 125 | | `SHOW_TOTAL` | `false`, `true` | Show total coding time | 126 | | `SHOW_MASKED_TIME` | `false`, `true` | Adds total coding time including unclassified languages (overrides: `SHOW_TOTAL`) | 127 | | `STOP_AT_OTHER` | `false`, `true` | Stop when language marked as `Other` is retrieved (overrides: `LANG_COUNT`) | 128 | | `IGNORED_LANGUAGES` | , `Binary YAML JSON TOML` | Hide languages from your stats | 129 | 130 | ### Commit Tweaks 131 | 132 | | Environment flag | Options (`Default`, `Other`, ...) | 133 | | ----------------- | -------------------------------------------------------------------- | 134 | | `COMMIT_MESSAGE` | `Updated waka-readme graph with new metrics`, any reasonable message | 135 | | `TARGET_BRANCH` | `NOT_SET`, target branch name | 136 | | `TARGET_PATH` | `NOT_SET`, `/path/to/target/file` | 137 | | `COMMITTER_NAME` | `NOT_SET`, committer name | 138 | | `COMMITTER_EMAIL` | `NOT_SET`, committer email | 139 | | `AUTHOR_NAME` | `NOT_SET`, author name | 140 | | `AUTHOR_EMAIL` | `NOT_SET`, author email | 141 | 142 | The first option is the _default_ value of the _flag_, subsequent options are valid values available for the _flag_. 143 | 144 | ## Example 145 | 146 | **`waka-readme.yml`** 147 | 148 | ```yml 149 | name: Waka Readme 150 | 151 | on: 152 | # for manual workflow trigger 153 | workflow_dispatch: 154 | schedule: 155 | # runs at 12 AM UTC (5:30 AM IST) 156 | - cron: "0 0 * * *" 157 | 158 | jobs: 159 | update-readme: 160 | name: WakaReadme DevMetrics 161 | runs-on: ubuntu-latest 162 | steps: 163 | # this action name 164 | - uses: athul/waka-readme@master # do NOT replace with anything else 165 | with: 166 | GH_TOKEN: ${{ secrets.GH_TOKEN }} # optional if on profile readme 167 | WAKATIME_API_KEY: ${{ secrets.WAKATIME_API_KEY }} # required 168 | ### meta 169 | API_BASE_URL: https://wakatime.com/api # optional 170 | REPOSITORY: YOUR_GITHUB_USERNAME/YOUR_REPOSITORY_NAME # optional 171 | ### content 172 | SHOW_TITLE: true # optional 173 | SECTION_NAME: waka # optional 174 | BLOCKS: -> # optional 175 | CODE_LANG: rust # optional 176 | TIME_RANGE: all_time # optional 177 | LANG_COUNT: 10 # optional 178 | SHOW_TIME: true # optional 179 | SHOW_TOTAL: true # optional 180 | SHOW_MASKED_TIME: false # optional 181 | STOP_AT_OTHER: true # optional 182 | IGNORED_LANGUAGES: YAML JSON TOML # optional 183 | ### commit 184 | COMMIT_MESSAGE: Updated waka-readme graph with new metrics # optional 185 | TARGET_BRANCH: master # optional 186 | TARGET_PATH: README.md # optional 187 | COMMITTER_NAME: GitHubActionBot # optional 188 | COMMITTER_EMAIL: action-bot@github.com # optional 189 | AUTHOR_NAME: YOUR_NAME # optional 190 | AUTHOR_EMAIL: YOUR@EMAIL.com # optional 191 | # you can populate email-id with secrets instead 192 | ``` 193 | 194 | _Rendered `markdown`:_ 195 | 196 | 197 | 198 | ```rust 199 | From: 10 July 2020 - To: 06 August 2022 200 | 201 | Total Time: 1,464 hrs 54 mins 202 | 203 | Python 859 hrs 29 mins >>>>>>>>>>>>>>----------- 54.68 % 204 | Markdown 132 hrs 33 mins >>----------------------- 08.43 % 205 | TeX 103 hrs 52 mins >>----------------------- 06.61 % 206 | HTML 94 hrs 48 mins >>----------------------- 06.03 % 207 | Nim 64 hrs 31 mins >------------------------ 04.11 % 208 | Other 47 hrs 58 mins >------------------------ 03.05 % 209 | ``` 210 | 211 | 212 | 213 | ## Notes 214 | 215 | - Flags `REPOSITORY` and `GH_TOKEN` are required ONLY if, you are NOT using [profile readme][profile_readme]. 216 | - If you are using `GH_TOKEN`, make sure set the [fine grained token](https://github.com/settings/tokens?type=beta) scope to repository contents with `read-and-write` access. See [#141 (comment)](https://github.com/athul/waka-readme/issues/141#issuecomment-1679831949). 217 | - `WAKATIME_API_KEY` is a **required** secret. All other environment variables are optional. 218 | - The above example does NOT show proper default values, refer [#Tweaks](#tweaks) for the same. 219 | - `IGNORED_LANGUAGES` is suggested for [.NET](https://dotnet.microsoft.com) users, as WakaTime assumes you're working with `Binary`, while debugging. 220 | 221 | ## Why only the language stats (and not other data) from the API? 222 | 223 | I am a fan of minimal designs and the profile readme is a great way to show off your skills and interests. The WakaTime API, gets us a **lot of data** about a person's **coding activity including the editors and Operating Systems you used and the projects you worked on**. Some of these projects maybe secretive and should not be shown out to the public. Using up more data via the Wakatime API will clutter the profile readme and hinder your chances on displaying what you provide **value to the community** like the pinned Repositories. I believe that **Coding Stats is nerdiest of all** since you can tell the community that you are **_exercising these languages or learning a new language_**, this will also show that you spend some amount of time to learn and exercise your development skills. That's what matters in the end :heart: 224 | 225 | [//]: #(Links) 226 | [wakapi]: https://wakapi.dev 227 | [hakatime]: https://github.com/mujx/hakatime 228 | [waka_plugins]: https://wakatime.com/plugins 229 | [waka_help]: https://wakatime.com/help/editors 230 | [profile_readme]: https://docs.github.com/en/account-and-profile/setting-up-and-managing-your-github-profile/customizing-your-profile/managing-your-profile-readme 231 | [gh_access_token]: https://docs.github.com/en/actions/configuring-and-managing-workflows/authenticating-with-the-github_token 232 | [gh_discuss]: https://github.com/athul/waka-readme/discussions 233 | -------------------------------------------------------------------------------- /action.yml: -------------------------------------------------------------------------------- 1 | name: "Waka - Readme" 2 | author: "Athul Cyriac Ajay" 3 | description: "WakaTime coding activity graph in your profile readme" 4 | 5 | inputs: 6 | GH_TOKEN: 7 | description: "GitHub access token with Repo scope" 8 | default: ${{ github.token }} 9 | required: true 10 | WAKATIME_API_KEY: 11 | description: "Your Wakatime/Wakapi/Hakatime API Key" 12 | required: true 13 | 14 | # meta tweaks 15 | API_BASE_URL: 16 | description: "Alternative API base URL when using a third-party WakaTime-ish backend" 17 | default: "https://wakatime.com/api" 18 | required: false 19 | REPOSITORY: 20 | description: "Your GitHub repository" 21 | default: ${{ github.repository }} 22 | required: false 23 | 24 | # content tweaks 25 | SHOW_TITLE: 26 | description: "Displays the week number and days in Readme as title" 27 | default: "false" 28 | required: false 29 | SECTION_NAME: 30 | description: "Section name for data to appear in readme" 31 | required: false 32 | default: "waka" 33 | BLOCKS: 34 | description: "Add the progress blocks of your choice" 35 | default: "░▒▓█" 36 | required: false 37 | CODE_LANG: 38 | description: "Add syntax formatter for generated code" 39 | default: "txt" 40 | required: false 41 | TIME_RANGE: 42 | description: "Time range of the queried statistics" 43 | default: "last_7_days" 44 | required: false 45 | LANG_COUNT: 46 | description: "Maximum number of languages to be shown" 47 | default: "5" 48 | required: false 49 | SHOW_TIME: 50 | description: "Displays the amount of time spent for each language" 51 | default: "true" 52 | required: false 53 | SHOW_TOTAL: 54 | description: "Displays total coding time" 55 | default: "false" 56 | required: false 57 | SHOW_MASKED_TIME: 58 | description: "Displays total coding time including unclassified languages" 59 | default: "false" 60 | required: false 61 | STOP_AT_OTHER: 62 | description: "Stop data retrieval when language marked 'Other' is reached" 63 | default: "false" 64 | required: false 65 | IGNORED_LANGUAGES: 66 | description: "Ignore space separated, listed languages" 67 | default: "" 68 | required: false 69 | 70 | # commit tweaks 71 | COMMIT_MESSAGE: 72 | description: "Add a commit message of your choice" 73 | default: "Updated waka-readme graph with new metrics" 74 | required: false 75 | TARGET_BRANCH: 76 | description: "Target branch" 77 | default: "NOT_SET" 78 | required: false 79 | TARGET_PATH: 80 | description: "Target file path" 81 | default: "NOT_SET" 82 | required: false 83 | COMMITTER_NAME: 84 | description: "Committer name" 85 | default: "NOT_SET" 86 | required: false 87 | COMMITTER_EMAIL: 88 | description: "Committer email" 89 | default: "NOT_SET" 90 | required: false 91 | AUTHOR_NAME: 92 | description: "Author name" 93 | default: "NOT_SET" 94 | required: false 95 | AUTHOR_EMAIL: 96 | description: "Author email" 97 | default: "NOT_SET" 98 | required: false 99 | 100 | runs: 101 | using: "docker" 102 | image: "dockerfile" 103 | 104 | branding: 105 | icon: "info" 106 | color: "blue" 107 | -------------------------------------------------------------------------------- /compose.yml: -------------------------------------------------------------------------------- 1 | # for CI testing 2 | services: 3 | waka-readme: 4 | env_file: 5 | - .env.template 6 | build: 7 | context: . 8 | dockerfile: containerfile 9 | image: waka-readme:testing 10 | container_name: WakaReadmeTesting 11 | -------------------------------------------------------------------------------- /containerfile: -------------------------------------------------------------------------------- 1 | FROM docker.io/python:3-slim 2 | 3 | ENV INPUT_GH_TOKEN \ 4 | INPUT_WAKATIME_API_KEY \ 5 | # meta 6 | INPUT_API_BASE_URL \ 7 | INPUT_REPOSITORY \ 8 | # content 9 | INPUT_SHOW_TITLE \ 10 | INPUT_SECTION_NAME \ 11 | INPUT_BLOCKS \ 12 | INPUT_CODE_LANG \ 13 | INPUT_TIME_RANGE \ 14 | INPUT_LANG_COUNT \ 15 | INPUT_SHOW_TIME \ 16 | INPUT_SHOW_TOTAL \ 17 | INPUT_SHOW_MASKED_TIME \ 18 | INPUT_STOP_AT_OTHER \ 19 | INPUT_IGNORED_LANGUAGES \ 20 | # commit 21 | INPUT_COMMIT_MESSAGE \ 22 | INPUT_TARGET_BRANCH \ 23 | INPUT_TARGET_PATH \ 24 | INPUT_COMMITTER_NAME \ 25 | INPUT_COMMITTER_EMAIL \ 26 | INPUT_AUTHOR_NAME \ 27 | INPUT_AUTHOR_EMAIL 28 | 29 | 30 | ENV PATH="${PATH}:/root/.local/bin" \ 31 | # python 32 | PYTHONFAULTHANDLER=1 \ 33 | PYTHONUNBUFFERED=1 \ 34 | PYTHONHASHSEED=random \ 35 | PYTHONDONTWRITEBYTECODE=1 \ 36 | # pip 37 | PIP_DISABLE_PIP_VERSION_CHECK=1 \ 38 | PIP_NO_CACHE_DIR=1 \ 39 | PIP_DEFAULT_TIMEOUT=100 40 | 41 | # copy project files 42 | COPY --chown=root:root pyproject.toml main.py /app/ 43 | 44 | # install dependencies 45 | RUN python -m pip install /app/ 46 | 47 | # copy tests 48 | COPY --chown=root:root tests /app/tests/ 49 | 50 | # run tests 51 | CMD python -m unittest discover /app/ 52 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | waka-readme: 3 | env_file: 4 | - .env 5 | build: 6 | context: . 7 | dockerfile: dockerfile 8 | image: waka-readme:dev 9 | container_name: WakaReadmeDev 10 | -------------------------------------------------------------------------------- /dockerfile: -------------------------------------------------------------------------------- 1 | FROM docker.io/python:3-slim 2 | 3 | ENV INPUT_GH_TOKEN \ 4 | INPUT_WAKATIME_API_KEY \ 5 | # meta 6 | INPUT_API_BASE_URL \ 7 | INPUT_REPOSITORY \ 8 | # content 9 | INPUT_SHOW_TITLE \ 10 | INPUT_SECTION_NAME \ 11 | INPUT_BLOCKS \ 12 | INPUT_CODE_LANG \ 13 | INPUT_TIME_RANGE \ 14 | INPUT_LANG_COUNT \ 15 | INPUT_SHOW_TIME \ 16 | INPUT_SHOW_TOTAL \ 17 | INPUT_SHOW_MASKED_TIME \ 18 | INPUT_STOP_AT_OTHER \ 19 | INPUT_IGNORED_LANGUAGES \ 20 | # commit 21 | INPUT_COMMIT_MESSAGE \ 22 | INPUT_TARGET_BRANCH \ 23 | INPUT_TARGET_PATH \ 24 | INPUT_COMMITTER_NAME \ 25 | INPUT_COMMITTER_EMAIL \ 26 | INPUT_AUTHOR_NAME \ 27 | INPUT_AUTHOR_EMAIL 28 | 29 | 30 | ENV PATH="${PATH}:/root/.local/bin" \ 31 | # python 32 | PYTHONFAULTHANDLER=1 \ 33 | PYTHONUNBUFFERED=1 \ 34 | PYTHONHASHSEED=random \ 35 | PYTHONDONTWRITEBYTECODE=1 \ 36 | # pip 37 | PIP_DISABLE_PIP_VERSION_CHECK=1 \ 38 | PIP_NO_CACHE_DIR=1 \ 39 | PIP_DEFAULT_TIMEOUT=100 40 | 41 | # copy project files 42 | COPY --chown=root:root pyproject.toml main.py /app/ 43 | 44 | # install dependencies 45 | RUN python -m pip install /app/ 46 | 47 | # execute program 48 | CMD python /app/main.py 49 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | """WakaReadme : WakaTime progress visualizer. 2 | 3 | Wakatime Metrics on your Profile Readme. 4 | 5 | Title: 6 | 7 | ```txt 8 | From: 15 February, 2022 - To: 22 February, 2022 9 | ```` 10 | 11 | Byline: 12 | 13 | ```txt 14 | Total: 34 hrs 43 mins 15 | ``` 16 | 17 | Body: 18 | 19 | ```txt 20 | Python 27 hrs 29 mins ⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣦⣀⣀⣀⣀⣀ 77.83 % 21 | YAML 2 hrs 14 mins ⣿⣦⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀ 06.33 % 22 | Markdown 1 hr 54 mins ⣿⣤⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀ 05.39 % 23 | TOML 1 hr 48 mins ⣿⣤⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀ 05.11 % 24 | Other 35 mins ⣦⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀ 01.68 % 25 | ``` 26 | 27 | Contents := Title + Byline + Body 28 | """ 29 | 30 | # standard 31 | from base64 import b64encode 32 | from dataclasses import dataclass 33 | from datetime import datetime 34 | from functools import partial 35 | import logging as logger 36 | import os 37 | from random import SystemRandom 38 | import re 39 | import sys 40 | from time import sleep 41 | from typing import Any 42 | 43 | # external 44 | from faker import Faker 45 | from github import ContentFile, Github, GithubException, InputGitAuthor, Repository 46 | from requests import get as rq_get 47 | from requests.exceptions import RequestException 48 | 49 | ################### setup ################### 50 | 51 | 52 | print() 53 | # hush existing loggers 54 | for lgr_name in logger.root.manager.loggerDict: 55 | # to disable log propagation completely set '.propagate = False' 56 | logger.getLogger(lgr_name).setLevel(logger.WARNING) 57 | # somehow github.Requester gets missed out from loggerDict 58 | logger.getLogger("github.Requester").setLevel(logger.WARNING) 59 | # configure logger 60 | logger.basicConfig( 61 | datefmt="%Y-%m-%d %H:%M:%S", 62 | format="[%(asctime)s] ln. %(lineno)-3d %(levelname)-8s %(message)s", 63 | level=logger.DEBUG, 64 | ) 65 | try: 66 | if len(sys.argv) == 2 and sys.argv[1] == "--dev": 67 | # get env-vars from .env file for development 68 | from dotenv import load_dotenv 69 | 70 | # comment this out to disable colored logging 71 | from loguru import logger 72 | 73 | # load from .env before class def gets parsed 74 | load_dotenv() 75 | except ImportError as im_err: 76 | logger.warning(im_err) 77 | 78 | 79 | ################### lib-func ################### 80 | 81 | 82 | def strtobool(val: str | bool): 83 | """Strtobool. 84 | 85 | PEP 632 https://www.python.org/dev/peps/pep-0632/ is depreciating distutils. 86 | This is from the official source code with slight modifications. 87 | 88 | Converts a string representation of truth to `True` or `False`. 89 | 90 | Args: 91 | val: 92 | Value to be converted to bool. 93 | 94 | Returns: 95 | (Literal[True]): 96 | If `val` is any of 'y', 'yes', 't', 'true', 'on', or '1'. 97 | (Literal[False]): 98 | If `val` is any of 'n', 'no', 'f', 'false', 'off', and '0'. 99 | 100 | Raises: 101 | ValueError: If `val` is anything else. 102 | """ 103 | if isinstance(val, bool): 104 | return val 105 | 106 | val = val.lower() 107 | 108 | if val in {"y", "yes", "t", "true", "on", "1"}: 109 | return True 110 | 111 | if val in {"n", "no", "f", "false", "off", "0"}: 112 | return False 113 | 114 | raise ValueError(f"invalid truth value for {val}") 115 | 116 | 117 | ################### data ################### 118 | 119 | 120 | @dataclass(slots=True) 121 | class WakaInput: 122 | """WakaReadme Input Env Variables.""" 123 | 124 | # constants 125 | prefix_length: int = 16 126 | graph_length: int = 25 127 | 128 | # mapped environment variables 129 | # # required 130 | gh_token: str | None = os.getenv("INPUT_GH_TOKEN") 131 | waka_key: str | None = os.getenv("INPUT_WAKATIME_API_KEY") 132 | api_base_url: str | None = os.getenv("INPUT_API_BASE_URL", "https://wakatime.com/api") 133 | repository: str | None = os.getenv("INPUT_REPOSITORY") 134 | # # depends 135 | commit_message: str = os.getenv( 136 | "INPUT_COMMIT_MESSAGE", "Updated WakaReadme graph with new metrics" 137 | ) 138 | code_lang: str = os.getenv("INPUT_CODE_LANG", "txt") 139 | _section_name: str = os.getenv("INPUT_SECTION_NAME", "waka") 140 | start_comment: str = f"" 141 | end_comment: str = f"" 142 | waka_block_pattern: str = f"{start_comment}[\\s\\S]+{end_comment}" 143 | # # optional 144 | show_title: str | bool = os.getenv("INPUT_SHOW_TITLE") or False 145 | block_style: str = os.getenv("INPUT_BLOCKS", "░▒▓█") 146 | time_range: str = os.getenv("INPUT_TIME_RANGE", "last_7_days") 147 | show_time: str | bool = os.getenv("INPUT_SHOW_TIME") or False 148 | show_total_time: str | bool = os.getenv("INPUT_SHOW_TOTAL") or False 149 | show_masked_time: str | bool = os.getenv("INPUT_SHOW_MASKED_TIME") or False 150 | language_count: str | int = os.getenv("INPUT_LANG_COUNT") or 5 151 | stop_at_other: str | bool = os.getenv("INPUT_STOP_AT_OTHER") or False 152 | ignored_languages: str = os.getenv("INPUT_IGNORED_LANGUAGES", "") 153 | # # optional meta 154 | target_branch: str = os.getenv("INPUT_TARGET_BRANCH", "NOT_SET") 155 | target_path: str = os.getenv("INPUT_TARGET_PATH", "NOT_SET") 156 | committer_name: str = os.getenv("INPUT_COMMITTER_NAME", "NOT_SET") 157 | committer_email: str = os.getenv("INPUT_COMMITTER_EMAIL", "NOT_SET") 158 | author_name: str = os.getenv("INPUT_AUTHOR_NAME", "NOT_SET") 159 | author_email: str = os.getenv("INPUT_AUTHOR_EMAIL", "NOT_SET") 160 | 161 | def validate_input(self): 162 | """Validate Input Env Variables.""" 163 | logger.debug("Validating input variables") 164 | if not self.gh_token or not self.waka_key or not self.api_base_url or not self.repository: 165 | logger.error("Invalid inputs") 166 | logger.info("Refer https://github.com/athul/waka-readme") 167 | return False 168 | 169 | if len(self.commit_message) < 1: 170 | logger.error("Commit message length must be greater than 1 character long") 171 | return False 172 | 173 | try: 174 | self.show_title = strtobool(self.show_title) 175 | self.show_time = strtobool(self.show_time) 176 | self.show_total_time = strtobool(self.show_total_time) 177 | self.show_masked_time = strtobool(self.show_masked_time) 178 | self.stop_at_other = strtobool(self.stop_at_other) 179 | except (ValueError, AttributeError) as err: 180 | logger.error(err) 181 | return False 182 | 183 | if not self._section_name.isalnum(): 184 | logger.warning("Section name must be in any of [[a-z][A-Z][0-9]]") 185 | logger.debug("Using default section name: waka") 186 | self._section_name = "waka" 187 | self.start_comment = f"" 188 | self.end_comment = f"" 189 | self.waka_block_pattern = f"{self.start_comment}[\\s\\S]+{self.end_comment}" 190 | 191 | if len(self.block_style) < 2: 192 | logger.warning("Graph block must be longer than 2 characters") 193 | logger.debug("Using default blocks: ░▒▓█") 194 | self.block_style = "░▒▓█" 195 | 196 | if self.time_range not in { 197 | "last_7_days", 198 | "last_30_days", 199 | "last_6_months", 200 | "last_year", 201 | "all_time", 202 | }: # "all_time" is un-documented, should it be used? 203 | logger.warning("Invalid time range") 204 | logger.debug("Using default time range: last_7_days") 205 | self.time_range = "last_7_days" 206 | 207 | try: 208 | self.language_count = int(self.language_count) 209 | if self.language_count < -1: 210 | raise ValueError 211 | except ValueError: 212 | logger.warning("Invalid language count") 213 | logger.debug("Using default language count: 5") 214 | self.language_count = 5 215 | 216 | for option in ( 217 | "target_branch", 218 | "target_path", 219 | "committer_name", 220 | "committer_email", 221 | "author_name", 222 | "author_email", 223 | ): 224 | if not getattr(self, option): 225 | logger.warning(f"Improper '{option}' configuration") 226 | logger.debug(f"Using default '{option}'") 227 | setattr(self, option, "NOT_SET") 228 | 229 | logger.debug("Input validation complete\n") 230 | return True 231 | 232 | 233 | ################### logic ################### 234 | 235 | 236 | def make_title(dawn: str | None, dusk: str | None, /): 237 | """WakaReadme Title. 238 | 239 | Makes title for WakaReadme. 240 | """ 241 | logger.debug("Making title") 242 | if not dawn or not dusk: 243 | logger.error("Cannot find start/end date\n") 244 | sys.exit(1) 245 | api_dfm, msg_dfm = "%Y-%m-%dT%H:%M:%SZ", "%d %B %Y" 246 | try: 247 | start_date = datetime.strptime(dawn, api_dfm).strftime(msg_dfm) 248 | end_date = datetime.strptime(dusk, api_dfm).strftime(msg_dfm) 249 | except ValueError as err: 250 | logger.error(f"{err}\n") 251 | sys.exit(1) 252 | 253 | logger.debug("Title was made\n") 254 | return f"From: {start_date} - To: {end_date}" 255 | 256 | 257 | def make_graph(block_style: str, percent: float, gr_len: int, lg_nm: str = "", /): 258 | """WakaReadme Graph. 259 | 260 | Makes time graph from the API's data. 261 | """ 262 | logger.debug(f"Generating graph for '{lg_nm or '...'}'") 263 | markers = len(block_style) - 1 264 | proportion = percent / 100 * gr_len 265 | graph_bar = block_style[-1] * int(proportion + 0.5 / markers) 266 | remainder_block = int((proportion - len(graph_bar)) * markers + 0.5) 267 | graph_bar += block_style[remainder_block] if remainder_block > 0 else "" 268 | graph_bar += block_style[0] * (gr_len - len(graph_bar)) 269 | 270 | logger.debug(f"'{lg_nm or '...'}' graph generated") 271 | return graph_bar 272 | 273 | 274 | def _extract_ignored_languages(): 275 | if not wk_i.ignored_languages: 276 | return "" 277 | temp = "" 278 | for igl in wk_i.ignored_languages.strip().split(): 279 | if igl.startswith(('"', "'")): 280 | temp = igl.lstrip('"').lstrip("'") 281 | continue 282 | if igl.endswith(('"', "'")): 283 | igl = f"{temp} {igl.rstrip('"').rstrip("'")}" 284 | temp = "" 285 | yield igl 286 | 287 | 288 | def prep_content(stats: dict[str, Any], /): 289 | """WakaReadme Prepare Markdown. 290 | 291 | Prepared markdown content from the fetched statistics. 292 | ``` 293 | """ 294 | logger.debug("Making contents") 295 | contents = "" 296 | 297 | # make title 298 | if wk_i.show_title: 299 | contents += make_title(stats.get("start"), stats.get("end")) + "\n\n" 300 | 301 | # make byline 302 | if wk_i.show_masked_time and ( 303 | total_time := stats.get("human_readable_total_including_other_language") 304 | ): 305 | # overrides "human_readable_total" 306 | contents += f"Total Time: {total_time}\n\n" 307 | elif wk_i.show_total_time and (total_time := stats.get("human_readable_total")): 308 | contents += f"Total Time: {total_time}\n\n" 309 | 310 | lang_info: list[dict[str, int | float | str]] | None = [] 311 | 312 | # Check if any language data exists 313 | if not (lang_info := stats.get("languages")): 314 | logger.debug("The API data seems to be empty, please wait for a day") 315 | contents += "No activity tracked" 316 | return contents.rstrip("\n") 317 | 318 | # make lang content 319 | pad_len = len( 320 | # comment if it feels way computationally expensive 321 | max((str(lng["name"]) for lng in lang_info), key=len) 322 | # and then do not for get to set `pad_len` to say 13 :) 323 | ) 324 | language_count, stop_at_other = int(wk_i.language_count), bool(wk_i.stop_at_other) 325 | if language_count == 0 and not wk_i.stop_at_other: 326 | logger.debug( 327 | "Set INPUT_LANG_COUNT to -1 to retrieve all language" 328 | + " or specify a positive number (ie. above 0)" 329 | ) 330 | return contents.rstrip("\n") 331 | 332 | ignored_languages = set(_extract_ignored_languages()) 333 | logger.debug(f"Ignoring {', '.join(ignored_languages)}") 334 | for idx, lang in enumerate(lang_info): 335 | lang_name = str(lang["name"]) 336 | if lang_name in ignored_languages: 337 | continue 338 | lang_time = str(lang["text"]) if wk_i.show_time else "" 339 | lang_ratio = float(lang["percent"]) 340 | lang_bar = make_graph(wk_i.block_style, lang_ratio, wk_i.graph_length, lang_name) 341 | contents += ( 342 | f"{lang_name.ljust(pad_len)} " 343 | + f"{lang_time: <16}{lang_bar} " 344 | + f"{lang_ratio:.2f}".zfill(5) 345 | + " %\n" 346 | ) 347 | if language_count == -1: 348 | continue 349 | if stop_at_other and (lang_name == "Other"): 350 | break 351 | if idx + 1 >= language_count > 0: # idx starts at 0 352 | break 353 | 354 | logger.debug("Contents were made\n") 355 | return contents.rstrip("\n") 356 | 357 | 358 | def fetch_stats(): 359 | """WakaReadme Fetch Stats. 360 | 361 | Returns statistics as JSON string. 362 | """ 363 | attempts = 4 364 | statistic: dict[str, dict[str, Any]] = {} 365 | encoded_key = str(b64encode(bytes(str(wk_i.waka_key), "utf-8")), "utf-8") 366 | logger.debug(f"Pulling WakaTime stats from {' '.join(wk_i.time_range.split('_'))}") 367 | while attempts > 0: 368 | resp_message, fake_ua = "", cryptogenic.choice([str(fake.user_agent()) for _ in range(5)]) 369 | # making a request 370 | if ( 371 | resp := rq_get( 372 | url=f"{str(wk_i.api_base_url).rstrip('/')}/v1/users/current/stats/{wk_i.time_range}", 373 | headers={ 374 | "Authorization": f"Basic {encoded_key}", 375 | "User-Agent": fake_ua, 376 | }, 377 | timeout=(30.0 * (5 - attempts)), 378 | ) 379 | ).status_code != 200: 380 | resp_message += f" • {conn_info}" if (conn_info := resp.json().get("message")) else "" 381 | logger.debug( 382 | f"API response #{5 - attempts}: {resp.status_code} •" + f" {resp.reason}{resp_message}" 383 | ) 384 | if resp.status_code == 200 and (statistic := resp.json()): 385 | logger.debug("Fetched WakaTime statistics") 386 | break 387 | logger.debug(f"Retrying in {30 * (5 - attempts )}s ...") 388 | sleep(30 * (5 - attempts)) 389 | attempts -= 1 390 | 391 | if err := (statistic.get("error") or statistic.get("errors")): 392 | logger.error(f"{err}\n") 393 | sys.exit(1) 394 | 395 | print() 396 | return statistic.get("data") 397 | 398 | 399 | def churn(old_readme: str, /): 400 | """WakaReadme Churn. 401 | 402 | Composes WakaTime stats within markdown code snippet. 403 | """ 404 | # check if placeholder pattern exists in readme 405 | if not re.findall(wk_i.waka_block_pattern, old_readme): 406 | logger.warning(f"Cannot find `{wk_i.waka_block_pattern}` pattern in readme") 407 | return None 408 | # getting contents 409 | if not (waka_stats := fetch_stats()): 410 | logger.error("Unable to fetch data, please rerun workflow\n") 411 | sys.exit(1) 412 | # preparing contents 413 | try: 414 | generated_content = prep_content(waka_stats) 415 | except (AttributeError, KeyError, ValueError) as err: 416 | logger.error(f"Unable to read API data | {err}\n") 417 | sys.exit(1) 418 | print(generated_content, "\n", sep="") 419 | # substituting old contents 420 | new_readme = re.sub( 421 | pattern=wk_i.waka_block_pattern, 422 | repl=f"{wk_i.start_comment}\n\n```{wk_i.code_lang}\n{generated_content}\n```\n\n{wk_i.end_comment}", 423 | string=old_readme, 424 | ) 425 | if len(sys.argv) == 2 and sys.argv[1] == "--dev": 426 | logger.debug("Detected run in `dev` mode.") 427 | # to avoid accidentally writing back to Github 428 | # when developing or testing waka-readme 429 | return None 430 | 431 | return None if new_readme == old_readme else new_readme 432 | 433 | 434 | def qualify_target(gh_repo: Repository.Repository): 435 | """Qualify target repository defaults.""" 436 | 437 | @dataclass 438 | class TargetRepository: 439 | this: ContentFile.ContentFile 440 | path: str 441 | commit_message: str 442 | sha: str 443 | branch: str 444 | committer: InputGitAuthor | None 445 | author: InputGitAuthor | None 446 | 447 | gh_branch = gh_repo.default_branch 448 | if wk_i.target_branch != "NOT_SET": 449 | gh_branch = gh_repo.get_branch(wk_i.target_branch) 450 | 451 | target = gh_repo.get_readme() 452 | if wk_i.target_path != "NOT_SET": 453 | target = gh_repo.get_contents( 454 | path=wk_i.target_path, 455 | ref=gh_branch if isinstance(gh_branch, str) else gh_branch.commit.sha, 456 | ) 457 | 458 | if isinstance(target, list): 459 | raise RuntimeError("Cannot handle multiple files.") 460 | 461 | committer, author = None, None 462 | if wk_i.committer_name != "NOT_SET" and wk_i.committer_email != "NOT_SET": 463 | committer = InputGitAuthor(name=wk_i.committer_name, email=wk_i.committer_email) 464 | if wk_i.author_name != "NOT_SET" and wk_i.author_email != "NOT_SET": 465 | author = InputGitAuthor(name=wk_i.author_name, email=wk_i.author_email) 466 | 467 | return TargetRepository( 468 | this=target, 469 | path=target.path, 470 | commit_message=wk_i.commit_message, 471 | sha=target.sha, 472 | branch=gh_branch if isinstance(gh_branch, str) else gh_branch.name, 473 | committer=committer, 474 | author=author, 475 | ) 476 | 477 | 478 | def genesis(): 479 | """Run Program.""" 480 | logger.debug("Connecting to GitHub") 481 | gh_connect = Github(wk_i.gh_token) 482 | # since a validator is being used earlier, casting 483 | # `wk_i.ENV_VARIABLE` to a string here, is okay 484 | gh_repo = gh_connect.get_repo(str(wk_i.repository)) 485 | target = qualify_target(gh_repo) 486 | logger.debug("Decoding readme contents\n") 487 | 488 | readme_contents = str(target.this.decoded_content, encoding="utf-8") 489 | if not (new_content := churn(readme_contents)): 490 | logger.info("WakaReadme was not updated") 491 | return 492 | 493 | logger.debug("WakaReadme stats has changed") 494 | update_metric = partial( 495 | gh_repo.update_file, 496 | path=target.path, 497 | message=target.commit_message, 498 | content=new_content, 499 | sha=target.sha, 500 | branch=target.branch, 501 | ) 502 | if target.committer: 503 | update_metric = partial(update_metric, committer=target.committer) 504 | if target.author: 505 | update_metric = partial(update_metric, author=target.author) 506 | update_metric() 507 | logger.info("Stats updated successfully") 508 | 509 | 510 | ################### driver ################### 511 | 512 | 513 | if __name__ == "__main__": 514 | # faker data preparation 515 | fake = Faker() 516 | Faker.seed(0) 517 | cryptogenic = SystemRandom() 518 | 519 | # initial waka-readme setup 520 | logger.debug("Initialize WakaReadme") 521 | wk_i = WakaInput() 522 | if not wk_i.validate_input(): 523 | logger.error("Environment variables are misconfigured\n") 524 | sys.exit(1) 525 | 526 | # run 527 | try: 528 | genesis() 529 | except KeyboardInterrupt: 530 | print("\r", end=" ") 531 | logger.error("Interrupt signal received\n") 532 | sys.exit(1) 533 | except RuntimeError as err: 534 | logger.error(f"{type(err).__name__}: {err}\n") 535 | sys.exit(1) 536 | except (GithubException, RequestException) as rq_exp: 537 | logger.critical(f"{rq_exp}\n") 538 | sys.exit(1) 539 | print("\nThanks for using WakaReadme!\n") 540 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | #################### 2 | # Metadata # 3 | #################### 4 | 5 | [project] 6 | name = "waka-readme" 7 | version = "0.3.1" 8 | description = "Wakatime Weekly Metrics on your Profile Readme." 9 | authors = [{ name = "Athul Cyriac Ajay", email = "athul8720@gmail.com" }] 10 | license = { text = "MIT" } 11 | readme = "README.md" 12 | keywords = ["readme", "profile-page", "wakatime"] 13 | classifiers = [ 14 | "Development Status :: 5 - Production/Stable", 15 | "Programming Language :: Python", 16 | "Typing :: Typed", 17 | ] 18 | requires-python = ">=3.12" 19 | dependencies = ["faker>=26.0.0", "pygithub>=2.3.0", "requests>=2.32.3"] 20 | 21 | [project.urls] 22 | Homepage = "https://github.com/athul/waka-readme" 23 | Documentation = "https://github.com/athul/waka-readme#readme" 24 | Repository = "https://github.com/athul/waka-readme" 25 | Changelog = "https://github.com/athul/waka-readme/commits/master" 26 | 27 | ############################# 28 | # Optional Dependencies # 29 | ############################# 30 | 31 | [project.optional-dependencies] 32 | extra = ["loguru>=0.7.2", "python-dotenv>=1.0.1"] 33 | 34 | ############################# 35 | # Development Dependencies # 36 | ############################# 37 | 38 | [tool.pdm.dev-dependencies] 39 | tooling = ["bandit>=1.7.9", "black>=24.4.2", "ruff>=0.5.5", "pyright>=1.1.374"] 40 | 41 | #################### 42 | # Configurations # 43 | #################### 44 | 45 | [tool.bandit] 46 | exclude_dirs = [".github", "tests", ".venv", ".vscode"] 47 | 48 | [tool.black] 49 | line-length = 100 50 | target-version = ["py312"] 51 | 52 | [tool.pyright] 53 | exclude = ["**/__pycache__", ".venv/"] 54 | pythonVersion = "3.12" 55 | pythonPlatform = "All" 56 | typeCheckingMode = "strict" 57 | 58 | [tool.ruff] 59 | select = [ 60 | # Pyflakes 61 | "F", 62 | # pycodestyle 63 | "W", 64 | "E", 65 | # mccabe 66 | # C90 67 | # isort 68 | "I", 69 | # pep8-naming 70 | "N", 71 | # pydocstyle 72 | "D", 73 | ] 74 | line-length = 100 75 | target-version = "py312" 76 | extend-exclude = ["**/__pycache__"] 77 | 78 | [tool.ruff.isort] 79 | # case-sensitive = true 80 | combine-as-imports = true 81 | force-sort-within-sections = true 82 | force-wrap-aliases = true 83 | relative-imports-order = "closest-to-furthest" 84 | 85 | [tool.ruff.pydocstyle] 86 | convention = "google" 87 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | """Initialize test module.""" 2 | 3 | # standard 4 | import logging 5 | 6 | # comment to enable logging w/ tests 7 | logging.disable(logging.CRITICAL) 8 | -------------------------------------------------------------------------------- /tests/test_main.py: -------------------------------------------------------------------------------- 1 | """Unit Tests.""" 2 | 3 | # standard 4 | from dataclasses import dataclass # , field 5 | from importlib import import_module 6 | from itertools import product 7 | import os 8 | import sys 9 | import unittest 10 | 11 | # from pathlib import Path 12 | # from inspect import cleandoc 13 | # from typing import Any 14 | # from json import load 15 | 16 | try: 17 | prime = import_module("main") 18 | # works when running as 19 | # python -m unittest discover 20 | except ImportError as err: 21 | print(err) 22 | # sys.exit(1) 23 | 24 | 25 | @dataclass 26 | class TestData: 27 | """Test Data.""" 28 | 29 | # for future tests 30 | # waka_json: dict[str, dict[str, Any]] = field( 31 | # default_factory=lambda: {} 32 | # ) 33 | bar_percent: tuple[int | float, ...] | None = None 34 | graph_blocks: tuple[str, ...] | None = None 35 | waka_graphs: tuple[list[str], ...] | None = None 36 | dummy_readme: str = "" 37 | 38 | def populate(self) -> None: 39 | """Populate Test Data.""" 40 | # for future tests 41 | # with open( 42 | # file=Path(__file__).parent / "sample_data.json", 43 | # encoding="utf-8", 44 | # mode="rt", 45 | # ) as wkf: 46 | # self.waka_json = load(wkf) 47 | 48 | self.bar_percent = (0, 100, 49.999, 50, 25, 75, 3.14, 9.901, 87.334, 87.333, 4.666, 4.667) 49 | 50 | self.graph_blocks = ("░▒▓█", "⚪⚫", "⓪①②③④⑤⑥⑦⑧⑨⑩") 51 | 52 | self.waka_graphs = ( 53 | [ 54 | "░░░░░░░░░░░░░░░░░░░░░░░░░", 55 | "█████████████████████████", 56 | "████████████▒░░░░░░░░░░░░", 57 | "████████████▓░░░░░░░░░░░░", 58 | "██████▒░░░░░░░░░░░░░░░░░░", 59 | "██████████████████▓░░░░░░", 60 | "▓░░░░░░░░░░░░░░░░░░░░░░░░", 61 | "██▒░░░░░░░░░░░░░░░░░░░░░░", 62 | "██████████████████████░░░", 63 | "█████████████████████▓░░░", 64 | "█░░░░░░░░░░░░░░░░░░░░░░░░", 65 | "█▒░░░░░░░░░░░░░░░░░░░░░░░", 66 | ], 67 | [ 68 | "⚪⚪⚪⚪⚪⚪⚪⚪⚪⚪⚪⚪⚪⚪⚪⚪⚪⚪⚪⚪⚪⚪⚪⚪⚪", 69 | "⚫⚫⚫⚫⚫⚫⚫⚫⚫⚫⚫⚫⚫⚫⚫⚫⚫⚫⚫⚫⚫⚫⚫⚫⚫", 70 | "⚫⚫⚫⚫⚫⚫⚫⚫⚫⚫⚫⚫⚪⚪⚪⚪⚪⚪⚪⚪⚪⚪⚪⚪⚪", 71 | "⚫⚫⚫⚫⚫⚫⚫⚫⚫⚫⚫⚫⚫⚪⚪⚪⚪⚪⚪⚪⚪⚪⚪⚪⚪", 72 | "⚫⚫⚫⚫⚫⚫⚪⚪⚪⚪⚪⚪⚪⚪⚪⚪⚪⚪⚪⚪⚪⚪⚪⚪⚪", 73 | "⚫⚫⚫⚫⚫⚫⚫⚫⚫⚫⚫⚫⚫⚫⚫⚫⚫⚫⚫⚪⚪⚪⚪⚪⚪", 74 | "⚫⚪⚪⚪⚪⚪⚪⚪⚪⚪⚪⚪⚪⚪⚪⚪⚪⚪⚪⚪⚪⚪⚪⚪⚪", 75 | "⚫⚫⚪⚪⚪⚪⚪⚪⚪⚪⚪⚪⚪⚪⚪⚪⚪⚪⚪⚪⚪⚪⚪⚪⚪", 76 | "⚫⚫⚫⚫⚫⚫⚫⚫⚫⚫⚫⚫⚫⚫⚫⚫⚫⚫⚫⚫⚫⚫⚪⚪⚪", 77 | "⚫⚫⚫⚫⚫⚫⚫⚫⚫⚫⚫⚫⚫⚫⚫⚫⚫⚫⚫⚫⚫⚫⚪⚪⚪", 78 | "⚫⚪⚪⚪⚪⚪⚪⚪⚪⚪⚪⚪⚪⚪⚪⚪⚪⚪⚪⚪⚪⚪⚪⚪⚪", 79 | "⚫⚪⚪⚪⚪⚪⚪⚪⚪⚪⚪⚪⚪⚪⚪⚪⚪⚪⚪⚪⚪⚪⚪⚪⚪", 80 | ], 81 | [ 82 | "⓪⓪⓪⓪⓪⓪⓪⓪⓪⓪⓪⓪⓪⓪⓪⓪⓪⓪⓪⓪⓪⓪⓪⓪⓪", 83 | "⑩⑩⑩⑩⑩⑩⑩⑩⑩⑩⑩⑩⑩⑩⑩⑩⑩⑩⑩⑩⑩⑩⑩⑩⑩", 84 | "⑩⑩⑩⑩⑩⑩⑩⑩⑩⑩⑩⑩⑤⓪⓪⓪⓪⓪⓪⓪⓪⓪⓪⓪⓪", 85 | "⑩⑩⑩⑩⑩⑩⑩⑩⑩⑩⑩⑩⑤⓪⓪⓪⓪⓪⓪⓪⓪⓪⓪⓪⓪", 86 | "⑩⑩⑩⑩⑩⑩③⓪⓪⓪⓪⓪⓪⓪⓪⓪⓪⓪⓪⓪⓪⓪⓪⓪⓪", 87 | "⑩⑩⑩⑩⑩⑩⑩⑩⑩⑩⑩⑩⑩⑩⑩⑩⑩⑩⑧⓪⓪⓪⓪⓪⓪", 88 | "⑧⓪⓪⓪⓪⓪⓪⓪⓪⓪⓪⓪⓪⓪⓪⓪⓪⓪⓪⓪⓪⓪⓪⓪⓪", 89 | "⑩⑩⑤⓪⓪⓪⓪⓪⓪⓪⓪⓪⓪⓪⓪⓪⓪⓪⓪⓪⓪⓪⓪⓪⓪", 90 | "⑩⑩⑩⑩⑩⑩⑩⑩⑩⑩⑩⑩⑩⑩⑩⑩⑩⑩⑩⑩⑩⑧⓪⓪⓪", 91 | "⑩⑩⑩⑩⑩⑩⑩⑩⑩⑩⑩⑩⑩⑩⑩⑩⑩⑩⑩⑩⑩⑧⓪⓪⓪", 92 | "⑩②⓪⓪⓪⓪⓪⓪⓪⓪⓪⓪⓪⓪⓪⓪⓪⓪⓪⓪⓪⓪⓪⓪⓪", 93 | "⑩②⓪⓪⓪⓪⓪⓪⓪⓪⓪⓪⓪⓪⓪⓪⓪⓪⓪⓪⓪⓪⓪⓪⓪", 94 | ], 95 | ) 96 | 97 | # self.dummy_readme = cleandoc(""" 98 | # My Test Readme Start 99 | # 100 | # 101 | # My Test Readme End 102 | # """) 103 | 104 | 105 | class TestMain(unittest.TestCase): 106 | """Testing Main Module.""" 107 | 108 | def test_make_graph(self) -> None: 109 | """Test graph maker.""" 110 | if not tds.graph_blocks or not tds.waka_graphs or not tds.bar_percent: 111 | raise AssertionError("Data population failed") 112 | 113 | for (idx, grb), (jdy, bpc) in product( 114 | enumerate(tds.graph_blocks), enumerate(tds.bar_percent) 115 | ): 116 | self.assertEqual(prime.make_graph(grb, bpc, 25), tds.waka_graphs[idx][jdy]) 117 | 118 | def test_make_title(self) -> None: 119 | """Test title maker.""" 120 | self.assertRegex( 121 | prime.make_title("2022-01-11T23:18:19Z", "2021-12-09T10:22:06Z"), 122 | r"From: \d{2} \w{3,9} \d{4} - To: \d{2} \w{3,9} \d{4}", 123 | ) 124 | 125 | def test_strtobool(self) -> None: 126 | """Test string to bool.""" 127 | self.assertTrue(prime.strtobool("Yes")) 128 | self.assertFalse(prime.strtobool("nO")) 129 | self.assertTrue(prime.strtobool(True)) 130 | self.assertRaises(AttributeError, prime.strtobool, None) 131 | self.assertRaises(ValueError, prime.strtobool, "yo!") 132 | self.assertRaises(AttributeError, prime.strtobool, 20.5) 133 | 134 | 135 | tds = TestData() 136 | tds.populate() 137 | 138 | if __name__ == "__main__": 139 | try: 140 | sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) 141 | import main as prime 142 | 143 | # works when running as 144 | # python tests/test_main.py 145 | except ImportError as im_er: 146 | print(im_er) 147 | sys.exit(1) 148 | unittest.main() 149 | --------------------------------------------------------------------------------