├── .all-contributorsrc ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── dependabot.yml ├── pull_request_template.md └── workflows │ ├── checks.yml │ ├── gh-shell.yml │ └── pypi_publish.yml ├── .gitignore ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── assets ├── daterange.png ├── grid.png ├── lang.png ├── list.png ├── starcli-demo2.gif ├── starcli-small-cover.png ├── starcli_cover.PNG └── table.png ├── requirements.txt ├── requirements_dev.txt ├── setup.py ├── starcli ├── __init__.py ├── __main__.py ├── layouts.py ├── search.py └── spoken-languages.json └── tests ├── conftest.py ├── test_cli.py ├── test_search.py └── test_shorten_count.py /.all-contributorsrc: -------------------------------------------------------------------------------- 1 | { 2 | "files": [ 3 | "README.md" 4 | ], 5 | "imageSize": 100, 6 | "commit": false, 7 | "contributors": [ 8 | { 9 | "login": "Shagilton", 10 | "name": "Shagilton", 11 | "avatar_url": "https://avatars0.githubusercontent.com/u/21122143?v=4", 12 | "profile": "https://github.com/Shagilton", 13 | "contributions": [ 14 | "code" 15 | ] 16 | }, 17 | { 18 | "login": "hexbee", 19 | "name": "hexbee", 20 | "avatar_url": "https://avatars2.githubusercontent.com/u/26668583?v=4", 21 | "profile": "https://github.com/hexbee", 22 | "contributions": [ 23 | "bug" 24 | ] 25 | }, 26 | { 27 | "login": "swellander", 28 | "name": "Sam Wellander", 29 | "avatar_url": "https://avatars0.githubusercontent.com/u/22231097?v=4", 30 | "profile": "https://github.com/swellander", 31 | "contributions": [ 32 | "code" 33 | ] 34 | }, 35 | { 36 | "login": "shivam212", 37 | "name": "Shivam Sinha", 38 | "avatar_url": "https://avatars0.githubusercontent.com/u/32016929?v=4", 39 | "profile": "https://www.shivamsinha.xyz/", 40 | "contributions": [ 41 | "code" 42 | ] 43 | }, 44 | { 45 | "login": "willmcgugan", 46 | "name": "Will McGugan", 47 | "avatar_url": "https://avatars3.githubusercontent.com/u/554369?v=4", 48 | "profile": "https://www.willmcgugan.com", 49 | "contributions": [ 50 | "code" 51 | ] 52 | }, 53 | { 54 | "login": "ashikjm", 55 | "name": "Ashik J M", 56 | "avatar_url": "https://avatars1.githubusercontent.com/u/12744524?v=4", 57 | "profile": "https://github.com/ashikjm", 58 | "contributions": [ 59 | "code" 60 | ] 61 | }, 62 | { 63 | "login": "ylchao", 64 | "name": "Yu-Lin Chao", 65 | "avatar_url": "https://avatars0.githubusercontent.com/u/15059429?v=4", 66 | "profile": "https://github.com/ylchao", 67 | "contributions": [ 68 | "code" 69 | ] 70 | }, 71 | { 72 | "login": "Saif807380", 73 | "name": "Saif Kazi", 74 | "avatar_url": "https://avatars2.githubusercontent.com/u/50794619?v=4", 75 | "profile": "https://github.com/Saif807380", 76 | "contributions": [ 77 | "code", 78 | "doc" 79 | ] 80 | }, 81 | { 82 | "login": "arcanearronax", 83 | "name": "arcanearronax", 84 | "avatar_url": "https://avatars3.githubusercontent.com/u/16456078?v=4", 85 | "profile": "http://arcanedomain.duckdns.org", 86 | "contributions": [ 87 | "test", 88 | "code" 89 | ] 90 | }, 91 | { 92 | "login": "jSadoski", 93 | "name": "jSadoski", 94 | "avatar_url": "https://avatars1.githubusercontent.com/u/1865629?v=4", 95 | "profile": "https://github.com/jSadoski", 96 | "contributions": [ 97 | "doc", 98 | "code" 99 | ] 100 | }, 101 | { 102 | "login": "odmishien", 103 | "name": "odmishien(Tetsuya MISHIMA)", 104 | "avatar_url": "https://avatars3.githubusercontent.com/u/25533384?v=4", 105 | "profile": "https://www.odmishien.fun", 106 | "contributions": [ 107 | "code" 108 | ] 109 | }, 110 | { 111 | "login": "ineelshah", 112 | "name": "Neel Shah", 113 | "avatar_url": "https://avatars1.githubusercontent.com/u/40118578?v=4", 114 | "profile": "http://linkedin.com/in/ineelshah", 115 | "contributions": [ 116 | "code" 117 | ] 118 | }, 119 | { 120 | "login": "0xflotus", 121 | "name": "0xflotus", 122 | "avatar_url": "https://avatars3.githubusercontent.com/u/26602940?v=4", 123 | "profile": "https://github.com/0xflotus", 124 | "contributions": [ 125 | "code" 126 | ] 127 | }, 128 | { 129 | "login": "AkashD-Developer", 130 | "name": "Akash Dhanwani", 131 | "avatar_url": "https://avatars.githubusercontent.com/u/44431401?v=4", 132 | "profile": "https://github.com/AkashD-Developer", 133 | "contributions": [ 134 | "code" 135 | ] 136 | }, 137 | { 138 | "login": "davised", 139 | "name": "Ed Davis", 140 | "avatar_url": "https://avatars.githubusercontent.com/u/16343359?v=4", 141 | "profile": "http://cqls.oregonstate.edu", 142 | "contributions": [ 143 | "code" 144 | ] 145 | }, 146 | { 147 | "login": "tizee", 148 | "name": "Jeff Chiang", 149 | "avatar_url": "https://avatars.githubusercontent.com/u/33030965?v=4", 150 | "profile": "https://jeetizee.com", 151 | "contributions": [ 152 | "code" 153 | ] 154 | }, 155 | { 156 | "login": "dzmitry-kankalovich", 157 | "name": "Dmitry Kankalovich", 158 | "avatar_url": "https://avatars.githubusercontent.com/u/6346981?v=4", 159 | "profile": "https://dmitrykankalovich.com/", 160 | "contributions": [ 161 | "code" 162 | ] 163 | } 164 | ], 165 | "contributorsPerLine": 7, 166 | "projectName": "starcli", 167 | "projectOwner": "hedyhli", 168 | "repoType": "github", 169 | "repoHost": "https://github.com", 170 | "skipCi": true, 171 | "commitConvention": "angular" 172 | } 173 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | ko_fi: #hedyli 2 | issuehunt: hedyhli/starcli 3 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: 'Bug: ' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | 11 | 12 | 18 | 19 | 20 | 21 | **Python version** 22 | 23 | 24 | 25 | **your operating system (and terminal type or shell of needed)** 26 | 27 | 28 | **starcli version** 29 | 30 | 36 | 37 | 39 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: "Feat:" 5 | labels: enhancement 6 | assignees: '' 7 | --- 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: pip 4 | directory: "/" 5 | schedule: 6 | interval: weekly 7 | time: "11:00" 8 | open-pull-requests-limit: 10 9 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | 3 | 4 | **Checklist** 5 | 6 | - [ ] My code is properly formatted using the latest [black](https://github.com/psf/black) 7 | - [ ] I have added/updated [tests](https://github.com/hedythedev/starcli/tree/main/tests) if needed 8 | - [ ] I have tried running my code manually 9 | - [ ] I have checked for spelling errors 10 | 14 | 15 | 16 | **Description** 17 | 18 | 19 | 25 | 26 | -------------------------------------------------------------------------------- /.github/workflows/checks.yml: -------------------------------------------------------------------------------- 1 | name: checks 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | 10 | jobs: 11 | lint: 12 | if: "! contains(toJSON(github.event.commits.*.message), '[skip ci]')" 13 | runs-on: ubuntu-latest 14 | strategy: 15 | matrix: 16 | python-version: [3.7, 3.8, 3.9, '3.10', '3.11'] 17 | 18 | steps: 19 | - uses: actions/checkout@v2 20 | - name: Set up Python ${{ matrix.python-version }} 21 | uses: actions/setup-python@v2 22 | with: 23 | python-version: ${{ matrix.python-version }} 24 | - name: Install dependencies 25 | run: | 26 | python -m pip install --upgrade pip 27 | pip install pylint pytest black codespell 28 | if [ -f requirements.txt ]; then pip install -r requirements.txt; fi 29 | - name: GitHub Action for pylint 30 | uses: cclauss/GitHub-Action-for-pylint@0.7.0 31 | - run: black . --check || true 32 | - uses: codespell-project/actions-codespell@master 33 | with: 34 | skip: "*.json,*.png,*.gif,*.PNG,.git" 35 | - run: pip install -r requirements.txt || true 36 | - run: python -m pytest --auth=${{secrets.STARCLI_AUTH}} 37 | -------------------------------------------------------------------------------- /.github/workflows/gh-shell.yml: -------------------------------------------------------------------------------- 1 | name: "Shell" 2 | on: 3 | workflow_dispatch: 4 | inputs: 5 | command: 6 | description: 'command' 7 | required: true 8 | jobs: 9 | run: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v2 13 | - name: Set up Python 14 | uses: actions/setup-python@v2 15 | with: 16 | python-version: 3.6 17 | - run: ${{ github.event.inputs.command }} 18 | env: 19 | GIT_COMMITTER_NAME: GitHub Actions 20 | GIT_AUTHOR_NAME: GitHub Actions 21 | EMAIL: github-actions[bot]@users.noreply.github.com 22 | -------------------------------------------------------------------------------- /.github/workflows/pypi_publish.yml: -------------------------------------------------------------------------------- 1 | # This workflows will upload a Python Package using Twine when a release is created 2 | # For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries 3 | 4 | name: Upload Python Package 5 | 6 | on: 7 | release: 8 | types: [created] 9 | 10 | jobs: 11 | deploy: 12 | 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v2 17 | - name: Set up Python 18 | uses: actions/setup-python@v2 19 | with: 20 | python-version: '3.7' 21 | - name: Install dependencies 22 | run: | 23 | python -m pip install --upgrade pip 24 | pip install setuptools wheel twine 25 | - name: Build and publish 26 | env: 27 | TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} 28 | TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} 29 | run: | 30 | python setup.py sdist bdist_wheel 31 | twine upload dist/* 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.gitignore.io/api/python 3 | # Edit at https://www.gitignore.io/?templates=python 4 | 5 | ### Python ### 6 | # Byte-compiled / optimized / DLL files 7 | __pycache__/ 8 | *.py[cod] 9 | *$py.class 10 | 11 | # C extensions 12 | *.so 13 | 14 | # Distribution / packaging 15 | .Python 16 | build/ 17 | develop-eggs/ 18 | dist/ 19 | downloads/ 20 | eggs/ 21 | .eggs/ 22 | lib/ 23 | lib64/ 24 | parts/ 25 | sdist/ 26 | var/ 27 | wheels/ 28 | pip-wheel-metadata/ 29 | share/python-wheels/ 30 | *.egg-info/ 31 | .installed.cfg 32 | *.egg 33 | MANIFEST 34 | 35 | # PyInstaller 36 | # Usually these files are written by a python script from a template 37 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 38 | *.manifest 39 | *.spec 40 | 41 | # Installer logs 42 | pip-log.txt 43 | pip-delete-this-directory.txt 44 | 45 | # Unit test / coverage reports 46 | htmlcov/ 47 | .tox/ 48 | .nox/ 49 | .coverage 50 | .coverage.* 51 | .cache 52 | nosetests.xml 53 | coverage.xml 54 | *.cover 55 | .hypothesis/ 56 | .pytest_cache/ 57 | 58 | # Translations 59 | *.mo 60 | *.pot 61 | 62 | # Scrapy stuff: 63 | .scrapy 64 | 65 | # Sphinx documentation 66 | docs/_build/ 67 | 68 | # PyBuilder 69 | target/ 70 | 71 | # pyenv 72 | .python-version 73 | 74 | # pipenv 75 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 76 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 77 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 78 | # install all needed dependencies. 79 | #Pipfile.lock 80 | 81 | # celery beat schedule file 82 | celerybeat-schedule 83 | 84 | # SageMath parsed files 85 | *.sage.py 86 | 87 | # Spyder project settings 88 | .spyderproject 89 | .spyproject 90 | 91 | # Rope project settings 92 | .ropeproject 93 | 94 | # Mr Developer 95 | .mr.developer.cfg 96 | .project 97 | .pydevproject 98 | 99 | # mkdocs documentation 100 | /site 101 | 102 | # mypy 103 | .mypy_cache/ 104 | .dmypy.json 105 | dmypy.json 106 | 107 | # Pyre type checker 108 | .pyre/ 109 | 110 | # End of https://www.gitignore.io/api/python 111 | 112 | .vscode/ 113 | venv/ 114 | .vim/ 115 | # Byte-compiled / optimized / DLL files 116 | __pycache__/ 117 | *.py[cod] 118 | *$py.class 119 | 120 | # C extensions 121 | *.so 122 | 123 | # Distribution / packaging 124 | .Python 125 | build/ 126 | develop-eggs/ 127 | dist/ 128 | downloads/ 129 | eggs/ 130 | .eggs/ 131 | lib/ 132 | lib64/ 133 | parts/ 134 | sdist/ 135 | var/ 136 | wheels/ 137 | share/python-wheels/ 138 | *.egg-info/ 139 | .installed.cfg 140 | *.egg 141 | MANIFEST 142 | 143 | # PyInstaller 144 | # Usually these files are written by a python script from a template 145 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 146 | *.manifest 147 | *.spec 148 | 149 | # Installer logs 150 | pip-log.txt 151 | pip-delete-this-directory.txt 152 | 153 | # Unit test / coverage reports 154 | htmlcov/ 155 | .tox/ 156 | .nox/ 157 | .coverage 158 | .coverage.* 159 | .cache 160 | nosetests.xml 161 | coverage.xml 162 | *.cover 163 | *.py,cover 164 | .hypothesis/ 165 | .pytest_cache/ 166 | cover/ 167 | 168 | # Translations 169 | *.mo 170 | *.pot 171 | 172 | # Django stuff: 173 | *.log 174 | local_settings.py 175 | db.sqlite3 176 | db.sqlite3-journal 177 | 178 | # Flask stuff: 179 | instance/ 180 | .webassets-cache 181 | 182 | # Scrapy stuff: 183 | .scrapy 184 | 185 | # Sphinx documentation 186 | docs/_build/ 187 | 188 | # PyBuilder 189 | .pybuilder/ 190 | target/ 191 | 192 | # Jupyter Notebook 193 | .ipynb_checkpoints 194 | 195 | # IPython 196 | profile_default/ 197 | ipython_config.py 198 | 199 | # pyenv 200 | # For a library or package, you might want to ignore these files since the code is 201 | # intended to run in multiple environments; otherwise, check them in: 202 | # .python-version 203 | 204 | # pipenv 205 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 206 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 207 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 208 | # install all needed dependencies. 209 | #Pipfile.lock 210 | 211 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 212 | __pypackages__/ 213 | 214 | # Celery stuff 215 | celerybeat-schedule 216 | celerybeat.pid 217 | 218 | # SageMath parsed files 219 | *.sage.py 220 | 221 | # Environments 222 | .env 223 | .venv 224 | env/ 225 | venv/ 226 | ENV/ 227 | env.bak/ 228 | venv.bak/ 229 | 230 | # Spyder project settings 231 | .spyderproject 232 | .spyproject 233 | 234 | # Rope project settings 235 | .ropeproject 236 | 237 | # mkdocs documentation 238 | /site 239 | 240 | # mypy 241 | .mypy_cache/ 242 | .dmypy.json 243 | dmypy.json 244 | 245 | # Pyre type checker 246 | .pyre/ 247 | 248 | # pytype static type analyzer 249 | .pytype/ 250 | 251 | # Cython debug symbols 252 | cython_debug/ 253 | 254 | # IDE specific 255 | .DS_Store 256 | .idea/ 257 | 258 | # Cache file 259 | .cached_result.json -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to make participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socioeconomic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies within all project spaces, and it also applies when 49 | an individual is representing the project or its community in public spaces. 50 | Examples of representing a project or community include using an official 51 | project e-mail address, posting via an official social media account, or acting 52 | as an appointed representative at an online or offline event. Representation of 53 | a project may be further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at [INSERT EMAIL ADDRESS]. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | 4 | **Working on your first Pull Request?** You can learn how from this free 5 | series [How to Contribute to an Open Source Project on 6 | GitHub](https://egghead.io/series/how-to-contribute-to-an-open-source-project-on-github) 7 | 8 | 9 | Thanks for taking the time to look at `CONTRIBUTING.md`. 10 | 11 | All contributions to this project should follow the `CODE_OF_CONDUCT.md`. 12 | 13 | ### Reporting issues and providing feedback 14 | 15 | If you found any issues or bugs, be sure to open up an issue so someone can 16 | check it out! 17 | 18 | 19 | ### Opening a pull request 20 | 21 | Once you've worked on your feature/bugfix etc, you can open a pull request using 22 | the `main` branch as the base branch. Write a clear and concise PR title, and a 23 | detailed description of why you made the change, whether it is related to any 24 | issues etc. And I will review it as soon as I can. 25 | 26 | ### Setting up development environment 27 | 28 | This project is written in Python, requires **Python 3.6 or higher**, and uses 29 | `pip` with `setup.py`. 30 | 31 | To set it up, just [fork](https://github.com/hedyhli/starcli/fork) + clone it, 32 | create a [virtual environment](https://virtualenv.pypa.io/en/latest/) and 33 | install all the dependencies: 34 | 35 | ```bash 36 | $ pip install -r requirements_dev.txt 37 | ``` 38 | 39 | The command will install all the requirements needed to run starcli, as well as 40 | dev-dependencies like [black](https://github.com/psf/black), 41 | [pylint](https://www.pylint.org/), 42 | [codespell](https://github.com/codespell-project/codespell) and 43 | [pytest](https://pytest.org). 44 | 45 | > Remember to use the `python3` and `pip3` commands instead of `python` and 46 | > `pip` if your system also has Python 2 installed. 47 | 48 | Alternatively, if you're going to use `pipenv`, you will need to use the `--pre` 49 | flag when installing in order for `black` to work: 50 | 51 | ```bash 52 | $ pipenv install -r requirements_dev.txt --pre 53 | ``` 54 | 55 | Check if the setup worked by running starcli from your local folder. 56 | 57 | ```bash 58 | $ python -m starcli --help 59 | ``` 60 | 61 | If the above command displayed the help and usage, you are good to go 👍 you can 62 | also test all the other features like list and table output, debug, etc. 63 | 64 | **Running tests** 65 | ```bash 66 | python -m pytest 67 | ``` 68 | 69 | or, for authenticated requests: 70 | 71 | ```bash 72 | python -m pytest --auth username:token 73 | ``` 74 | 75 | Where **username** is your GitHub username and **token** is a 76 | [personal access token](https://docs.github.com/en/github/authenticating-to-github/creating-a-personal-access-token). 77 | Authenticating your request will allow you to test authentication features and 78 | use a higher rate limit with the GitHub API. *This token does not need any permissions!* 79 | 80 | Note that if the the value passed to `--auth` is invalid, some tests will fail. 81 | So check with `starcli --auth` if some tests are mysteriously failing when using 82 | `--auth`. 83 | 84 | **Linting checks** 85 | 86 | ```bash 87 | pylint *.py 88 | ``` 89 | 90 | **Formatting & code spell** 91 | ```bash 92 | black . && codespell --skip=".git,*.json,demo-pics/,venv/" 93 | ``` 94 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020-2024 hedy 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 | ___ ______ _ ____ ____ _ _ 3 | / \ * | | /\ | | \ / | * | \ | \ 4 | \ \ | | /\ \ | |* / | | | | | | 5 | \ \ | | /--\ \ | | \ | | | | | | 6 | *_\./ ._| / ._\ |_| \_ \.|__. |_|_. |_| 7 | 8 | ``` 9 | 10 | Browse trending projects on Github from your command line `$ _` 11 | 12 | 13 | ![checks](https://github.com/hedyhli/starcli/workflows/checks/badge.svg) 14 | [![pypi version](https://img.shields.io/pypi/v/starcli)](https://pypi.org/project/starcli/) 15 | [![pypi downloads per month](https://img.shields.io/pypi/dm/starcli)](https://pypi.org/project/starcli/) 16 | [![Python Requirements](https://img.shields.io/pypi/pyversions/starcli)](https://pypi.org/project/starcli/) 17 | [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) 18 | [![GitHub license MIT](https://img.shields.io/github/license/hedyhli/starcli.svg)](https://github.com/hedyhli/starcli/blob/main/LICENSE) 19 | 20 |
21 | 22 | 23 | ![starcli demo.gif](https://raw.githubusercontent.com/hedyhli/starcli/main/images/starcli-demo2.gif) 24 | 25 | ## Features 26 | 27 | * Filters 28 | * Stars 29 | * Pushed date 30 | * Created date 31 | * User 32 | * Topic 33 | * Language 34 | * Spoken language 35 | * Use generic GitHub search API or GitHub trending 36 | * Auth token (optional) 37 | * Paged output 38 | * Different layouts 39 | 40 | 41 | ## Prerequisites 42 | 43 | * Requires Python 3.6 or greater 44 | 45 | ## Installation 46 | 47 | ```sh 48 | pip3 install starcli 49 | ``` 50 | 51 | ## Usage 52 | 53 | ``` 54 | Usage: starcli [OPTIONS] 55 | 56 | Search and query GitHub repositories 57 | 58 | Options: 59 | -l, --lang TEXT Language filter eg: python. (can be used 60 | multiple times) 61 | -S, --spoken-language TEXT Spoken Language filter eg: en for English, 62 | zh for Chinese 63 | -c, --created TEXT Specify repo creation date in YYYY-MM-DD, 64 | use >date, <=date etc to be more specific. 65 | -t, --topic TEXT Date of last push in YYYY-MM-DD (>, <, >=, 66 | <= specifiers supported) 67 | -p, --pushed TEXT Specify date of last push in YYYY-MM-DD, >=< 68 | allowed 69 | -L, --layout [list|table|grid] The output format (list, table, or grid), 70 | default is list 71 | -s, --stars TEXT Number of stars, default is '>=100'. eg: 72 | '>0', '123', '<50000 73 | -n, --num-results INTEGER The number of items in the results. Default: 74 | 7 75 | -o, --order [desc|asc] Order of repos by stars, 'desc' or 'asc', 76 | default: desc 77 | --long-stats Print the actual stats number (1300 instead 78 | of 1.3k) 79 | -d, --date-range [day|week|month] 80 | View stars received within time, choose 81 | from: day, week, month. Uses GitHub trending 82 | for fetching results, hence some other 83 | filter options may not work. 84 | -u, --user TEXT Filter for trending repositories by username 85 | --auth TEXT Optionally use GitHub personal access token 86 | in the format 'username:password'. 87 | -P, --pager Use $PAGER to page output. (put -r in $LESS 88 | to enable ANSI styles) 89 | --debug Turn on debugging mode 90 | --help Show this message and exit. 91 | ``` 92 | 93 | 94 | ### Layouts 95 | 96 | Switch layouts using `--layout {list|table|grid}`, or use the short option `-L` 97 | 98 | **list** 99 | 100 | demo list 101 | 102 | **table** 103 | 104 | 105 | 106 | **grid** 107 | 108 | demo grid 109 | 110 | All three of the layout options support clickable links for repository names. If 111 | your terminal supports links, you can directly click on the name and it will 112 | take you to the GitHub repository in your browser. 113 | 114 | 115 | ### Filtering by language 116 | 117 | For example, you only want to find popular Python repos: using `--lang` or `-l`: 118 | 119 | ``` 120 | starcli --lang python 121 | ``` 122 | 123 | Here's another example `starcli -l python -L grid`, which is python with grid 124 | layout: 125 | 126 | demo grid 127 | 128 | ### Filtering by spoken language 129 | 130 | If you wanted to find repos in your native language, you can use 131 | `--spoken-language` or `-S`: 132 | 133 | ``` 134 | starcli --spoken-language zh 135 | ``` 136 | 137 | The above command lists down repos written in Chinese. 138 | 139 | A full list of language codes is available 140 | [here](./starcli/spoken-languages.json) 141 | 142 | Note that (as with `--date-range`) options like `--topics`, `--pushed`, 143 | `--created` won't take effect because `-d` uses a different search mechanism to 144 | find results. 145 | 146 | ### Specify the number (or range) of stars 147 | 148 | (Recommended to be used with `--created`) 149 | 150 | The default range is >=100 stars. 151 | 152 | Use `--stars` or `-s` to specify what you want, for example, if you want to find 153 | repos that has more than 100 stars, you can use: 154 | 155 | ``` 156 | starcli -s '>100' 157 | ``` 158 | 159 | Note that if you do something like `>1000` not many repos can have more than 160 | 1000 and is created within around 200 days (which is the default for 161 | `--created`), to specify date of creation, use `--created`, see below. 162 | 163 | ### Filter by stars daily, weekly or monthly 164 | 165 | You can view the number of stars a repo received today, this week or this month 166 | by using the `--date-range` or `-d` option: 167 | 168 | ``` 169 | starcli -d this-week -L table 170 | ``` 171 | 172 | This command will also display the number of stars received for each repo this 173 | week in the form of a table. 174 | 175 | `-d` uses GitHub Trending search for repositories, hence options `--topic`, 176 | `--pushed`, `--created` won't take effect. 177 | 178 | ### Specify the date of creation 179 | 180 | `--created`/`-c` accepts a date in ISO8601 format: yyyy-mm-dd 181 | 182 | For example, for repos created on 1st January 2014, use: 183 | ``` 184 | starcli --created 2014-01-01 185 | ``` 186 | 187 | To search for repos that are created *on or after* 1st January 2014, use: 188 | ``` 189 | starcli --created '>=2014-01-01' 190 | ``` 191 | 192 | ### Filtering by topics 193 | 194 | This option lets you filter by topics. You can use `--topics` or `-t` to include 195 | a topic in search. 196 | 197 | This option can be used multiple times. 198 | 199 | ``` 200 | starcli -l python -d 2020-07-06 -t deep-learning -t pytorch 201 | ``` 202 | 203 | ### Specifying last pushed date 204 | 205 | Use `--pushed`/`-p` when you want to find popular repos that are last updated on 206 | a given date, say 2020-01-01 for 1st of Jan 2020: 207 | 208 | ``` 209 | starcli -p 2020-01-01 210 | ``` 211 | 212 | You can also prefix the value with ">=<" like: 213 | 214 | ``` 215 | starcli -p '>=2020-01-01' 216 | ``` 217 | 218 | This is find repos that have last pushed after or on January the 1st, 2020. 219 | 220 | Read more about the >=< syntax on [GitHub 221 | Docs](https://docs.github.com/en/github/searching-for-information-on-github/understanding-the-search-syntax#query-for-values-greater-or-less-than-another-value). 222 | 223 | ### Searching by user 224 | 225 | Recommended to be used with `--stars` and/or `--date-created`. 226 | 227 | Finding trending projects by GitHub username is supported too. Use `--user` or 228 | `-u` to do so. 229 | 230 | Just provide a valid GitHub username after it, like: 231 | 232 | ``` 233 | starcli -u torvalds 234 | starcli -u gvanrossum 235 | ``` 236 | 237 | ### Using date ranges 238 | 239 | You can use `--date-range` or `-d` and specify today, this-week, or this-month, 240 | so that GitHub Trending search function will be used to find popular repos and 241 | tell you how much stars are gained this day/week/month depending on the option 242 | you used. 243 | 244 | ``` 245 | starcli -d this-week 246 | ``` 247 | 248 | demo date range 249 | 250 | Note that (like `--spoken-language`) options like `--topics`, `--pushed`, 251 | `--created` won't take effect because `-d` uses a different search mechanism to 252 | find results. 253 | 254 | ### Limit the number of results shown 255 | 256 | Don't like the default 7? You can change it to something else, using 257 | `--limit-results` or `-r` followed by an integer: 258 | 259 | ``` 260 | starcli -r 2 261 | ``` 262 | 263 | The above will only give you two repos. This is useful if you want to put it in 264 | your `.bashrc`, `.zshrc`, or `fish_greeting` function. 265 | 266 | Just add `starcli -r 3 -L grid` in there, and every time you open your terminal, 267 | you will find 3 trending repos printed neatly in a grid format, great way to 268 | start your day (a bit like the [Hacker Tab 269 | Extension](https://chrome.google.com/webstore/detail/hacker-tab/ibomigipadcieapbemkegkmadbbanbgm?hl=en)). 270 | 271 | 272 | ### Paging 273 | 274 | Result output can be displayed through your OS pager using the `--pager`/`-p` 275 | flag. 276 | 277 | If you're using less, add `R` to your `LESS` environment variable so colors and 278 | styling can be displayed correctly. 279 | 280 | 281 | ### GitHub Authentication 282 | 283 | Rate limit may be hit if starcli sends many repeated requests to GitHub within a 284 | short perod of time. 285 | 286 | To avoid this, provide an authentication token using `--auth`: 287 | 288 | ``` 289 | starcli --auth 'username:token' 290 | ``` 291 | 292 | [Read more about authentication tokens on GitHub 293 | Docs](https://docs.github.com/en/github/authenticating-to-github/creating-a-personal-access-token") 294 | 295 | 296 | ## Issues, feature request, and feedback 297 | 298 | * Issues, bug reports, or feature request: Don't hesitate to open an issue in 299 | this repo 300 | * Feedback: any general feedback or questions about using StarCLI you can leave 301 | a comment on the [Product Hunt 302 | page](https://www.producthunt.com/posts/starcli) 303 | 304 | 305 | ## Development [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat-square)](http://makeapullrequest.com) 306 | 307 | For contributing guidelines and how to set up your development environment, 308 | please read 309 | [`CONTRIBUTING.md`](https://github.com/hedyhli/starcli/blob/main/CONTRIBUTING.md). 310 | Remember that all contributions to this project should follow its [CODE OF 311 | CONDUCT](https://github.com/hedyhli/starcli/blob/main/CODE_OF_CONDUCT.md). 312 | 313 | 314 | ## Uses 315 | 316 | * CommandLine Argument parser: [Click](https://github.com/pallets/click) 317 | * Colored and table console print: [`rich`](https://github.com/willmcgugan/rich) 318 | (with click and colorama) 319 | * HTTP library to send requests: [`requests`](https://github.com/psf/requests) 320 | 321 | 322 | 323 | ## Contributors ✨ 324 | 325 | Thanks goes to all of these wonderful people ([emoji 326 | key](https://allcontributors.org/docs/en/emoji-key)): 327 | 328 | 329 | 330 | 331 | 332 | 333 | 334 | 335 | 336 | 337 | 338 | 339 | 340 | 341 | 342 | 343 | 344 | 345 | 346 | 347 | 348 | 349 | 350 | 351 | 352 | 353 | 354 | 355 | 356 | 357 |
Shagilton
Shagilton

💻
hexbee
hexbee

🐛
Sam Wellander
Sam Wellander

💻
Shivam Sinha
Shivam Sinha

💻
Will McGugan
Will McGugan

💻
Ashik J M
Ashik J M

💻
Yu-Lin Chao
Yu-Lin Chao

💻
Saif Kazi
Saif Kazi

💻 📖
arcanearronax
arcanearronax

⚠️ 💻
jSadoski
jSadoski

📖 💻
odmishien(Tetsuya MISHIMA)
odmishien(Tetsuya MISHIMA)

💻
Neel Shah
Neel Shah

💻
0xflotus
0xflotus

💻
Akash Dhanwani
Akash Dhanwani

💻
Ed Davis
Ed Davis

💻
Jeff Chiang
Jeff Chiang

💻
Dmitry Kankalovich
Dmitry Kankalovich

💻
358 | 359 | 360 | 361 | 362 | 363 | 364 | This project follows the 365 | [all-contributors](https://github.com/all-contributors/all-contributors) 366 | specification. Contributions of any kind welcome! 367 | 368 | 369 | ## Credits 370 | 371 | This project was forked from [`githunt` 372 | (python)](https://github.com/SriNandan33/githunt) and its initial intention was 373 | to rewrite that project to use Rich instead of colorama + tabulate, but now it 374 | has so much more features than before, thanks to everyone's contributions 🙌 375 | -------------------------------------------------------------------------------- /assets/daterange.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hedyhli/starcli/d2d3b20bf04a6176d141c463ffb15b25caa60ad2/assets/daterange.png -------------------------------------------------------------------------------- /assets/grid.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hedyhli/starcli/d2d3b20bf04a6176d141c463ffb15b25caa60ad2/assets/grid.png -------------------------------------------------------------------------------- /assets/lang.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hedyhli/starcli/d2d3b20bf04a6176d141c463ffb15b25caa60ad2/assets/lang.png -------------------------------------------------------------------------------- /assets/list.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hedyhli/starcli/d2d3b20bf04a6176d141c463ffb15b25caa60ad2/assets/list.png -------------------------------------------------------------------------------- /assets/starcli-demo2.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hedyhli/starcli/d2d3b20bf04a6176d141c463ffb15b25caa60ad2/assets/starcli-demo2.gif -------------------------------------------------------------------------------- /assets/starcli-small-cover.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hedyhli/starcli/d2d3b20bf04a6176d141c463ffb15b25caa60ad2/assets/starcli-small-cover.png -------------------------------------------------------------------------------- /assets/starcli_cover.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hedyhli/starcli/d2d3b20bf04a6176d141c463ffb15b25caa60ad2/assets/starcli_cover.PNG -------------------------------------------------------------------------------- /assets/table.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hedyhli/starcli/d2d3b20bf04a6176d141c463ffb15b25caa60ad2/assets/table.png -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | Click~=7.0 2 | gtrending>=0.3.0,<1.0.0 3 | requests>=2.22.0,<3.0.0 4 | rich>=4.0.0,<14.0.0 5 | xdg>=5.1.1,<6.0.0 6 | -------------------------------------------------------------------------------- /requirements_dev.txt: -------------------------------------------------------------------------------- 1 | -r requirements.txt 2 | black>=19.10b0 3 | codespell>=1.17.1 4 | pylint>=2.4.4 5 | pytest>=5.4.3 6 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | """ setup """ 2 | 3 | import io 4 | 5 | from setuptools import setup 6 | 7 | with io.open("README.md", "rt", encoding="utf8") as f: 8 | LONG_DESC = f.read() 9 | 10 | VERSION = "2.18.1" 11 | 12 | # This call to setup() does all the work 13 | setup( 14 | name="starcli", 15 | version=VERSION, 16 | description="Browse popular projects on github by star trends from your command line", 17 | long_description=LONG_DESC, 18 | long_description_content_type="text/markdown", 19 | python_requires=">=3.6", 20 | url="https://github.com/hedyhli/starcli", 21 | author="hedy", 22 | author_email="hedy@tilde.cafe", 23 | license="MIT", 24 | classifiers=[ 25 | "License :: OSI Approved :: MIT License", 26 | "Programming Language :: Python :: 3", 27 | "Programming Language :: Python :: 3.7", 28 | "Programming Language :: Python :: 3.8", 29 | "Programming Language :: Python :: 3.9", 30 | "Programming Language :: Python :: 3.10", 31 | "Programming Language :: Python :: 3.11", 32 | ], 33 | packages=["starcli"], 34 | include_package_data=True, 35 | install_requires=[ 36 | "Click>=7.0,<8.0", 37 | "gtrending>=0.3.0,<1.0.0", 38 | "requests>=2.22.0,<3.0.0", 39 | "rich>=4.0.0,<14.0.0", 40 | "xdg>=5.1.1,<6.0.0", 41 | ], 42 | entry_points={ 43 | "console_scripts": [ 44 | "starcli=starcli.__main__:cli", 45 | ] 46 | }, 47 | ) 48 | -------------------------------------------------------------------------------- /starcli/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hedyhli/starcli/d2d3b20bf04a6176d141c463ffb15b25caa60ad2/starcli/__init__.py -------------------------------------------------------------------------------- /starcli/__main__.py: -------------------------------------------------------------------------------- 1 | """starcli.__main__ 2 | 3 | The main CLI module of starcli. 4 | """ 5 | 6 | import re 7 | import json 8 | import os 9 | from datetime import datetime, timedelta 10 | 11 | import click 12 | from xdg import XDG_CACHE_HOME 13 | 14 | from .layouts import print_results, shorten_count 15 | from .search import ( 16 | search, 17 | debug_requests_on, 18 | search_github_trending, 19 | # search_error, 20 | # status_actions, 21 | ) 22 | 23 | 24 | # could be made into config option in the future 25 | CACHE_DIR = XDG_CACHE_HOME / "starcli" 26 | CACHED_RESULT_PATH = CACHE_DIR / "results.json" 27 | CACHED_RESULT_PATH_LEGACY = XDG_CACHE_HOME / "starcli.json" 28 | CACHE_EXPIRATION = 1 # Minutes 29 | 30 | 31 | @click.command() 32 | @click.option( 33 | "--lang", 34 | "-l", 35 | multiple=True, 36 | type=str, 37 | default=[""], 38 | help="Language filter eg: python. (can be used multiple times)", 39 | ) 40 | @click.option( 41 | "--spoken-language", 42 | "-S", 43 | type=str, 44 | default="", 45 | help="Spoken Language filter eg: en for English, zh for Chinese", 46 | ) 47 | @click.option( 48 | "--created", 49 | "-c", 50 | default="", 51 | help="Specify repo creation date in YYYY-MM-DD, use >date, <=date etc to be more specific.", 52 | ) 53 | @click.option( 54 | "--topic", 55 | "-t", 56 | default=[], 57 | multiple=True, 58 | help="Search by repo topics. Can be used multiple times.", 59 | ) 60 | @click.option( 61 | "--pushed", 62 | "-p", 63 | default="", 64 | help="Date of last push in YYYY-MM-DD (>, <, >=, <= specifiers supported)", 65 | ) 66 | @click.option( 67 | "--layout", 68 | "-L", 69 | type=click.Choice(["list", "table", "grid"], case_sensitive=False), 70 | help="The output format (list, table, or grid), default is list", 71 | ) 72 | @click.option( 73 | "--stars", 74 | "-s", 75 | type=str, 76 | default=">=100", 77 | help="Number of stars, default is '>=100'. eg: '>0', '123', '<50000", 78 | ) 79 | @click.option( 80 | "--num-results", 81 | "-n", 82 | type=int, 83 | default=7, 84 | help="The number of items in the results. Default: 7", 85 | ) 86 | @click.option( 87 | "--order", 88 | "-o", 89 | type=click.Choice(["desc", "asc"], case_sensitive=False), 90 | default="desc", 91 | help="Order of repos by stars, 'desc' or 'asc', default: desc", 92 | ) 93 | @click.option( 94 | "--long-stats", 95 | is_flag=True, 96 | help="Print the actual stats number (1300 instead of 1.3k)", 97 | ) 98 | @click.option( 99 | "--date-range", 100 | "-d", 101 | type=click.Choice(["day", "week", "month"], case_sensitive=False), 102 | help="View stars received within time, choose from: day, week, month. Uses GitHub trending for fetching results, hence some other filter options may not work.", 103 | ) 104 | @click.option( 105 | "--user", 106 | "-u", 107 | type=str, 108 | default="", 109 | help="Filter for trending repositories by username", 110 | ) 111 | @click.option( 112 | "--auth", 113 | type=str, 114 | default="", 115 | help="Optionally use GitHub personal access token in the format 'username:password'.", 116 | ) 117 | @click.option( 118 | "--pager", 119 | "-P", 120 | is_flag=True, 121 | default=False, 122 | help="Use $PAGER to page output. (put -r in $LESS to enable ANSI styles)", 123 | ) 124 | @click.option( # Used for debugging CLI input, prevent hitting GitHub API when we don't need it 125 | "--nop", 126 | is_flag=True, 127 | default=False, 128 | hidden=True, 129 | ) 130 | @click.option("--debug", is_flag=True, default=False, help="Turn on debugging mode") 131 | def cli( 132 | lang, 133 | spoken_language, 134 | created, 135 | topic, 136 | pushed, 137 | layout, 138 | stars, 139 | num_results, 140 | order, 141 | long_stats, 142 | date_range, 143 | user, 144 | debug=False, 145 | auth="", 146 | pager=False, 147 | nop=False, 148 | ): 149 | """Find trending repos on GitHub""" 150 | if debug: 151 | import logging 152 | 153 | debug_requests_on() 154 | 155 | tmp_repos = None 156 | options_key = "{lang}_{spoken_language}_{created}_{topic}_{pushed}_{stars}_{order}_{date_range}_{user}".format( 157 | lang=lang, 158 | spoken_language=spoken_language, 159 | created=created, 160 | topic=topic, 161 | pushed=pushed, 162 | stars=stars, 163 | order=order, 164 | date_range=date_range, 165 | user=user, 166 | ) 167 | 168 | if os.path.exists(CACHED_RESULT_PATH): 169 | with open(CACHED_RESULT_PATH, "r") as f: 170 | json_file = json.load(f) 171 | result = json_file.get(options_key) 172 | if result: 173 | t = result[-1].get("time") 174 | time = datetime.strptime(t, "%Y-%m-%d %H:%M:%S.%f") 175 | diff = datetime.now() - time 176 | if diff < timedelta(minutes=CACHE_EXPIRATION): 177 | if debug: 178 | logger = logging.getLogger(__name__) 179 | logger.debug("Fetching results from cache") 180 | 181 | tmp_repos = result 182 | 183 | if not tmp_repos: # If cache expired or results not yet cached 184 | if auth and not re.search(".+:.+", auth): # Check authentication format 185 | click.secho( 186 | f"Invalid authentication format: {auth}. Must be 'username:token'", 187 | fg="bright_red", 188 | ) 189 | click.secho( 190 | "Use --help or see: " 191 | "https://docs.github.com/en/github/authenticating-to-github/" 192 | "creating-a-personal-access-token", 193 | fg="bright_red", 194 | ) 195 | auth = None 196 | 197 | if nop: 198 | return 199 | 200 | if ( 201 | not spoken_language and not date_range 202 | ): # if filtering by spoken language and date range not required 203 | tmp_repos = search( 204 | lang, created, pushed, stars, topic, user, debug, order, auth 205 | ) 206 | else: 207 | tmp_repos = search_github_trending( 208 | lang, spoken_language, order, stars, date_range, debug 209 | ) 210 | 211 | if not tmp_repos: # if search() returned None 212 | return 213 | else: # Cache results 214 | tmp_repos.append({"time": str(datetime.now())}) 215 | # make sure ~/.cache dir exists 216 | os.makedirs(CACHE_DIR, exist_ok=True) 217 | # Clean legacy cache file path 218 | try: 219 | os.remove(CACHED_RESULT_PATH_LEGACY) 220 | except FileNotFoundError: 221 | pass 222 | 223 | with open(CACHED_RESULT_PATH, "a+") as f: 224 | if os.path.getsize(CACHED_RESULT_PATH) == 0: # file is empty 225 | result_dict = {options_key: tmp_repos} 226 | f.write(json.dumps(result_dict, indent=4)) 227 | else: # file is not empty 228 | f.seek(0) 229 | result_dict = json.load(f) 230 | result_dict[options_key] = tmp_repos 231 | f.truncate(0) 232 | f.write(json.dumps(result_dict, indent=4)) 233 | 234 | repos = tmp_repos[0:num_results] 235 | 236 | if not long_stats: # shorten the stat counts when not using --long-stats 237 | for repo in repos: 238 | repo["stargazers_count"] = shorten_count(repo["stargazers_count"]) 239 | repo["forks"] = shorten_count(repo["forks"]) 240 | if "date_range" in repo.keys() and repo["date_range"]: 241 | num_stars = repo["date_range"].split()[0] 242 | repo["date_range"] = repo["date_range"].replace( 243 | num_stars, str(shorten_count(int(num_stars.replace(",", "")))) 244 | ) 245 | 246 | print_results(repos, page=pager, layout=layout) 247 | 248 | 249 | if __name__ == "__main__": 250 | # pylint: disable=no-value-for-parameter 251 | cli() 252 | -------------------------------------------------------------------------------- /starcli/layouts.py: -------------------------------------------------------------------------------- 1 | """starcli.layouts""" 2 | 3 | import math 4 | 5 | from rich.align import Align 6 | from rich.console import Console, group 7 | from rich.rule import Rule 8 | from rich.table import Table 9 | from rich.text import Text 10 | from rich.panel import Panel 11 | from rich.columns import Columns 12 | 13 | 14 | console = Console() 15 | 16 | SYMBOL_MAP = {"stars": "★", "forks": "⎇"} 17 | 18 | 19 | def shorten_count(number): 20 | """Shortens number""" 21 | if number < 1000: 22 | return str(number) 23 | 24 | number = int(number) 25 | new_number = math.ceil(round(number / 100.0, 1)) * 100 26 | 27 | if new_number % 1000 == 0: 28 | return str(new_number)[0] + "k" 29 | if new_number < 1000: 30 | # returns the same old integer if no changes were made 31 | return str(number) 32 | # returns a new string if the number was shortened 33 | return str(new_number / 1000.0) + "k" 34 | 35 | 36 | def format_stats(stars, forks): 37 | """Formatted string of repo stats""" 38 | stats = f"{stars}{SYMBOL_MAP['stars']} " if stars != "-1" else "" 39 | stats += f"{forks}{SYMBOL_MAP['forks']} " if forks != "-1" else "" 40 | return stats 41 | 42 | 43 | def format_date_range(date_range): 44 | """Formatted and styled Text object of date_range period stars""" 45 | if not date_range: 46 | return Text("") 47 | return ( 48 | Text("(", style="reset") 49 | .append( 50 | (date_range.replace(" stars", SYMBOL_MAP["stars"])), style="italic magenta" 51 | ) 52 | .append(")") 53 | ) 54 | 55 | 56 | def list_layout(repos): 57 | """Display repositories in a list layout using rich""" 58 | 59 | width = 80 60 | 61 | @group() 62 | def render_repo(repo): 63 | """Yields renderables for a single repo.""" 64 | yield Rule(style="bright_yellow") 65 | yield "" 66 | # Table with description and stats 67 | title_table = Table.grid(padding=(0, 1)) 68 | title_table.expand = True 69 | title = Text(repo["full_name"], overflow="fold") 70 | title.stylize(f"yellow link {repo['html_url']}") 71 | 72 | stats = format_stats(repo["stargazers_count"], repo["forks"]) 73 | date_range_col = format_date_range(repo.get("date_range")) 74 | 75 | title_table.add_row(title, Text(stats, style="italic blue")) 76 | title_table.columns[1].no_wrap = True 77 | title_table.columns[1].justify = "right" 78 | yield title_table 79 | yield "" 80 | lang_table = Table.grid(padding=(0, 1)) 81 | lang_table.expand = True 82 | language_col = ( 83 | Text(repo["language"], style="bold cyan") 84 | if repo["language"] 85 | else Text("no language") 86 | ) 87 | lang_table.add_row(language_col, date_range_col) 88 | lang_table.columns[1].justify = "right" 89 | yield lang_table 90 | yield "" 91 | # Description 92 | description = repo["description"] 93 | if description: 94 | yield Text(description.strip()) 95 | else: 96 | yield "[i]no description" 97 | yield "" 98 | 99 | def column(renderable): 100 | """Constrain width and align to center to create a column.""" 101 | return Align.center(renderable, width=width, pad=False) 102 | 103 | for repo in repos: 104 | console.print(column(render_repo(repo))) 105 | console.print(column(Rule(style="bright_yellow"))) 106 | 107 | 108 | def table_layout(repos): 109 | """Displays repositories in a table format using rich""" 110 | table = Table(leading=1) 111 | 112 | # make the columns 113 | table.add_column("Name", style="bold yellow") 114 | table.add_column("Language") 115 | table.add_column("Description") 116 | table.add_column("Stats", justify="right") 117 | 118 | for repo in repos: 119 | stats = Text( 120 | format_stats(repo["stargazers_count"], repo["forks"]), style="blue" 121 | ) 122 | stats.append("\n").append(format_date_range(repo.get("date_range"))) 123 | 124 | language = ( 125 | Text(repo["language"], style="cyan") 126 | if repo["language"] 127 | else Text("no language", style="italic") 128 | ) 129 | description = ( 130 | Text(repo["description"]) 131 | if repo["description"] 132 | else Text("no description", style="italic") 133 | ) 134 | 135 | name = Text(repo["name"], overflow="fold") 136 | name.stylize(f"yellow link {repo['html_url']}") 137 | 138 | table.add_row(name, language, description, stats) 139 | 140 | console.print(table) 141 | 142 | 143 | def grid_layout(repos): 144 | """Displays repositories in a grid format using rich""" 145 | 146 | max_desc_len = 90 147 | 148 | panels = [] 149 | for repo in repos: 150 | 151 | stats = format_stats(repo["stargazers_count"], repo["forks"]) 152 | # '\n' added here as it would group both text and new line together 153 | # hence if date_range isn't present the new line will also not be displayed 154 | date_range = format_date_range(repo.get("date_range")).append("\n") 155 | 156 | language = ( 157 | Text(repo["language"], style="cyan") 158 | if repo["language"] 159 | else Text("no language", style="italic") 160 | ) 161 | description = ( 162 | Text(repo["description"]) 163 | if repo["description"] 164 | else Text("no description", style="italic") 165 | ) 166 | 167 | name = Text(repo["name"], style="bold yellow") 168 | name.stylize(f"link {repo['html_url']}") 169 | stats = Text(stats, style="blue") 170 | 171 | # truncate rest of the description if 172 | # it's more than 90 (max_desc_len) chars 173 | # using truncate() is better than textwrap 174 | # because it also takes care of asian characters 175 | description.truncate(max_desc_len, overflow="ellipsis") 176 | 177 | repo_summary = Text.assemble( 178 | name, 179 | "\n", 180 | stats, 181 | " ", 182 | date_range, 183 | language, 184 | "\n", 185 | description, 186 | ) 187 | panels.append(Panel(repo_summary, expand=True)) 188 | 189 | console.print((Columns(panels, width=30, expand=True))) 190 | 191 | 192 | def print_results(*args, page=False, layout=""): 193 | """Use a specified layout to print or page the fetched results""" 194 | if page: 195 | with console.pager(): 196 | print_layout(layout=layout, *args) 197 | return 198 | print_layout( 199 | layout=layout, 200 | *args, 201 | ) 202 | 203 | 204 | def print_layout(*args, layout="list"): 205 | """Use specified layout""" 206 | if layout == "table": 207 | table_layout(*args) 208 | elif layout == "grid": 209 | grid_layout(*args) 210 | else: 211 | list_layout(*args) 212 | -------------------------------------------------------------------------------- /starcli/search.py: -------------------------------------------------------------------------------- 1 | """starcli.search 2 | 3 | The search module responsible for handling requests and querying GitHub 4 | """ 5 | 6 | # Standard library imports 7 | from datetime import datetime, timedelta 8 | from time import sleep 9 | import logging 10 | from random import randint 11 | import re 12 | import http.client 13 | import typing as t 14 | 15 | # Third party imports 16 | import requests 17 | from click import secho 18 | import gtrending 19 | from rich.logging import RichHandler 20 | 21 | API_URL = "https://api.github.com/search/repositories" 22 | 23 | # "X stars today" / "X stars this week" 24 | # today, this week, this month must be first item in the tuple for use in date_range_str() 25 | DATE_RANGE_MAP = { 26 | "daily": ("today", "day"), 27 | "weekly": ("this week", "week"), 28 | "monthly": ("this month", "month"), 29 | } 30 | 31 | STATUS_ACTIONS = { 32 | "retry": "Failed to retrieve data. Retrying in ", 33 | "invalid": "The server was unable to process the request.", 34 | "unauthorized": "The server did not accept the credentials.\n" \ 35 | "See: https://docs.github.com/en/github/authenticating-to-github/creating-a-personal-access-token\n" # 36 | "Maybe you did not give enough scopes?", 37 | "not_found": "The server indicated no data was found.", 38 | "unsupported": "The request is not supported.", 39 | "unknown": "An unknown error occurred.", 40 | "valid": "The request returned successfully, but an unknown exception occurred.", 41 | } 42 | 43 | FORMAT = "%(message)s" 44 | 45 | httpclient_logger = logging.getLogger("http.client") 46 | 47 | 48 | def httpclient_logging_debug(level=logging.DEBUG): 49 | def httpclient_log(*args): 50 | httpclient_logger.log(level, " ".join(args)) 51 | 52 | http.client.print = httpclient_log 53 | http.client.HTTPConnection.debuglevel = 1 54 | 55 | 56 | def debug_requests_on(): 57 | """Turn on the logging for requests""" 58 | logging.basicConfig( 59 | level=logging.DEBUG, 60 | format=FORMAT, 61 | datefmt="[%Y-%m-%d]", 62 | handlers=[RichHandler()], 63 | ) 64 | logger = logging.getLogger(__name__) 65 | 66 | from http.client import HTTPConnection 67 | 68 | httpclient_logging_debug() 69 | 70 | requests_log = logging.getLogger("requests.packages.urllib3") 71 | requests_log.setLevel(logging.DEBUG) 72 | requests_log.propagate = True 73 | 74 | 75 | def debug_logger(debug: bool, *args, **kwargs): 76 | """Log a message if debug mode is on""" 77 | if debug: 78 | debug_requests_on() 79 | logger = logging.getLogger(__name__) 80 | logger.debug(*args, **kwargs) 81 | 82 | 83 | def convert_datetime(date: str, date_format: str = "%Y-%m-%d"): 84 | """Safely convert a date string to datetime""" 85 | try: 86 | # try to turn the string into a date-time object 87 | tmp_date = datetime.strptime(date, date_format) 88 | except ValueError: # ValueError will be thrown if format is invalid 89 | secho( 90 | f"Invalid date format: {date}. Must be yyyy-mm-dd", 91 | fg="bright_red", 92 | ) 93 | return None 94 | return tmp_date 95 | 96 | 97 | def get_date(date): 98 | """Find the date info in a string""" 99 | prefix = "" 100 | if any(i in date[0] for i in [">", "=", "<"]): 101 | if "=" in date[1]: 102 | prefix = date[:2] 103 | date = date.strip(prefix) 104 | else: 105 | prefix = date[0] 106 | date = date.strip(prefix) 107 | tmp_date = convert_datetime(date) 108 | if not tmp_date: 109 | return None 110 | return prefix + tmp_date.strftime("%Y-%m-%d") 111 | 112 | 113 | def get_valid_request( 114 | url: str, auth: t.Optional[str] = "" 115 | ) -> t.Optional[requests.Response]: 116 | """GET an url with auth and handle a connection error""" 117 | while True: 118 | try: 119 | session = requests.Session() 120 | if auth: 121 | session.auth = (auth.split(":")[0], auth.split(":")[1]) 122 | request = session.get(url) 123 | except requests.exceptions.ConnectionError: 124 | secho("Internet connection error...", fg="bright_red") 125 | return 126 | 127 | if not request.status_code in (200, 202): 128 | handling_code = search_error(request.status_code) 129 | if handling_code == "retry": 130 | for i in range(15, 0, -1): 131 | secho( 132 | f"{STATUS_ACTIONS[handling_code]} {i} seconds...", 133 | fg="bright_yellow", 134 | ) # Print and update a timer 135 | 136 | sleep(1) 137 | elif handling_code in STATUS_ACTIONS: 138 | secho(STATUS_ACTIONS[handling_code], fg="bright_yellow") 139 | return 140 | else: 141 | secho("An invalid handling code was returned.", fg="bright_red") 142 | return 143 | else: 144 | break 145 | 146 | return request 147 | 148 | 149 | def search_error(status_code: t.Union[int, str]): 150 | """Get a directive on how to handle a given HTTP status code""" 151 | int_status_code = int(status_code) # Make sure the status code is an integer 152 | 153 | http_code_handling = { 154 | "200": "valid", 155 | "202": "valid", 156 | "204": "valid", 157 | "400": "invalid", 158 | "401": "unauthorized", 159 | "403": "retry", 160 | "404": "not_found", 161 | "405": "invalid", 162 | "422": "not_found", 163 | "500": "invalid", 164 | "501": "invalid", 165 | } 166 | 167 | try: 168 | return http_code_handling[str(int_status_code)] 169 | except KeyError: 170 | return "unsupported" 171 | 172 | 173 | def convert_date_range(param: str) -> t.Optional[str]: 174 | """Convert the date range parameter into 'since' parameter for GitHub Trending""" 175 | if not param: 176 | return None 177 | param = param.lower().replace(" ", "-") 178 | for key, aliases in DATE_RANGE_MAP.items(): 179 | if param == key or param in aliases: 180 | return key 181 | raise ValueError(f"Invalid date range parameter: {param}") 182 | 183 | 184 | def date_range_str(period_stars: int, since: int) -> str: 185 | """Generate a readable string to display the current date range stars increase 186 | 187 | Parameters: 188 | since (int): The argument used for GitHub Trending search. 189 | 190 | Returns: 191 | str: The formatted string for output. 192 | """ 193 | date_range = DATE_RANGE_MAP.get(since, (None,))[0] 194 | return f"+{period_stars} stars {date_range}" 195 | 196 | 197 | def search( 198 | languages=[""], 199 | created=None, 200 | pushed=None, 201 | stars=">=100", 202 | topics=[], 203 | user=None, 204 | debug=False, 205 | order="desc", 206 | auth="", 207 | ): 208 | """Return repositories searched from GitHub API""" 209 | date_format = "%Y-%m-%d" # date format in iso format 210 | debug_logger(debug, f"Search: created param: {created}") 211 | debug_logger(debug, f"Search: order param: {order}") 212 | 213 | day_range = 0 - randint(100, 400) # random negative from 100 to 400 214 | 215 | if not created: # if created not provided 216 | # creation date: the time now minus a random number of days 217 | # 100 to 400 days - which was stored in day_range 218 | created_str = ">=" + (datetime.utcnow() + timedelta(days=day_range)).strftime( 219 | date_format 220 | ) 221 | else: # if created is provided 222 | created_str = get_date(created) 223 | if not created_str: 224 | return None 225 | 226 | if not pushed: # if pushed not provided 227 | # pushed date: start, is the time now minus a random number of days 228 | # 100 to 400 days - which was stored in day_range 229 | pushed_str = ">=" + (datetime.utcnow() + timedelta(days=day_range)).strftime( 230 | date_format 231 | ) 232 | else: # if pushed is provided 233 | pushed_str = get_date(pushed) 234 | if not pushed_str: 235 | return None 236 | 237 | query = "" 238 | if user: 239 | query = f"user:{user}+" 240 | 241 | query += f"stars:{stars}+created:{created_str}" # construct query 242 | query += f"+pushed:{pushed_str}" # add pushed info to query 243 | query += "".join( 244 | [f"+language:{language}" for language in languages] 245 | ) # add language to query 246 | query += "".join(["+topic:" + i for i in topics]) # add topics to query 247 | 248 | url = f"{API_URL}?q={query}&sort=stars&order={order}" # use query to construct url 249 | debug_logger(debug, f"Search: url: {url}") 250 | if auth: 251 | debug_logger(debug, "Auth: on") 252 | else: 253 | debug_logger(debug, "Auth: off") 254 | 255 | request = get_valid_request(url, auth) 256 | if request is None: 257 | return request 258 | 259 | return request.json()["items"] 260 | 261 | 262 | def search_github_trending( 263 | languages=[""], 264 | spoken_language=None, 265 | order="desc", 266 | stars=">=10", 267 | date_range=None, 268 | debug=False, 269 | ) -> t.List[dict]: 270 | """Returns trending repositories from github trending page""" 271 | gtrending_repo_list = [] 272 | since = convert_date_range(date_range) 273 | 274 | debug_logger(debug, f"gtrending: since: {since}") 275 | 276 | for language in languages: 277 | if date_range: 278 | debug_logger( 279 | debug, 280 | f"gtrending: fetching repos: language={language}, spoken_language={spoken_language}, since={since}", 281 | ) 282 | gtrending_repo_list += gtrending.fetch_repos( 283 | language, spoken_language, since 284 | ) 285 | else: 286 | debug_logger( 287 | debug, 288 | f"gtrending: fetching repos: language={language}, spoken_language={spoken_language}", 289 | ) 290 | gtrending_repo_list += gtrending.fetch_repos(language, spoken_language) 291 | 292 | repositories = [] 293 | for gtrending_repo in gtrending_repo_list: 294 | repo_dict = convert_repo_dict(gtrending_repo) 295 | repo_dict["date_range"] = ( 296 | date_range_str(repo_dict["date_range"], since) if date_range else None 297 | ) 298 | # filter by number of stars 299 | num = [int(s) for s in re.findall(r"\d+", stars)][0] 300 | if ( 301 | ("<" in stars and repo_dict["stargazers_count"] < num) 302 | or ("<=" in stars and repo_dict["stargazers_count"] <= num) 303 | or (">" in stars and repo_dict["stargazers_count"] > num) 304 | or (">=" in stars and repo_dict["stargazers_count"] >= num) 305 | ): 306 | repositories.append(repo_dict) 307 | 308 | if order == "asc": 309 | return sorted(repositories, key=lambda repo: repo["stargazers_count"]) 310 | return sorted(repositories, key=lambda repo: repo["stargazers_count"], reverse=True) 311 | 312 | 313 | def convert_repo_dict(gtrending_repo: dict) -> dict: 314 | """Normalize dictionary keys returned by gtrending""" 315 | repo_dict = {} 316 | repo_dict["full_name"] = gtrending_repo.get("fullname") 317 | repo_dict["name"] = gtrending_repo.get("name") 318 | repo_dict["html_url"] = gtrending_repo.get("url") 319 | repo_dict["stargazers_count"] = gtrending_repo.get("stars", -1) 320 | repo_dict["forks"] = gtrending_repo.get("forks", -1) 321 | repo_dict["language"] = gtrending_repo.get("language") 322 | # gtrending_repo has key `description` and value is empty string if it's empty 323 | repo_dict["description"] = ( 324 | gtrending_repo.get("description") 325 | if gtrending_repo.get("description") != "" 326 | else None 327 | ) 328 | repo_dict["date_range"] = gtrending_repo.get("currentPeriodStars") 329 | return repo_dict 330 | -------------------------------------------------------------------------------- /starcli/spoken-languages.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "urlParam": "ab", 4 | "name": "Abkhazian" 5 | }, 6 | { 7 | "urlParam": "aa", 8 | "name": "Afar" 9 | }, 10 | { 11 | "urlParam": "af", 12 | "name": "Afrikaans" 13 | }, 14 | { 15 | "urlParam": "ak", 16 | "name": "Akan" 17 | }, 18 | { 19 | "urlParam": "sq", 20 | "name": "Albanian" 21 | }, 22 | { 23 | "urlParam": "am", 24 | "name": "Amharic" 25 | }, 26 | { 27 | "urlParam": "ar", 28 | "name": "Arabic" 29 | }, 30 | { 31 | "urlParam": "an", 32 | "name": "Aragonese" 33 | }, 34 | { 35 | "urlParam": "hy", 36 | "name": "Armenian" 37 | }, 38 | { 39 | "urlParam": "as", 40 | "name": "Assamese" 41 | }, 42 | { 43 | "urlParam": "av", 44 | "name": "Avaric" 45 | }, 46 | { 47 | "urlParam": "ae", 48 | "name": "Avestan" 49 | }, 50 | { 51 | "urlParam": "ay", 52 | "name": "Aymara" 53 | }, 54 | { 55 | "urlParam": "az", 56 | "name": "Azerbaijani" 57 | }, 58 | { 59 | "urlParam": "bm", 60 | "name": "Bambara" 61 | }, 62 | { 63 | "urlParam": "ba", 64 | "name": "Bashkir" 65 | }, 66 | { 67 | "urlParam": "eu", 68 | "name": "Basque" 69 | }, 70 | { 71 | "urlParam": "be", 72 | "name": "Belarusian" 73 | }, 74 | { 75 | "urlParam": "bn", 76 | "name": "Bengali" 77 | }, 78 | { 79 | "urlParam": "bh", 80 | "name": "Bihari languages" 81 | }, 82 | { 83 | "urlParam": "bi", 84 | "name": "Bislama" 85 | }, 86 | { 87 | "urlParam": "bs", 88 | "name": "Bosnian" 89 | }, 90 | { 91 | "urlParam": "br", 92 | "name": "Breton" 93 | }, 94 | { 95 | "urlParam": "bg", 96 | "name": "Bulgarian" 97 | }, 98 | { 99 | "urlParam": "my", 100 | "name": "Burmese" 101 | }, 102 | { 103 | "urlParam": "ca", 104 | "name": "Catalan, Valencian" 105 | }, 106 | { 107 | "urlParam": "ch", 108 | "name": "Chamorro" 109 | }, 110 | { 111 | "urlParam": "ce", 112 | "name": "Chechen" 113 | }, 114 | { 115 | "urlParam": "ny", 116 | "name": "Chichewa, Chewa, Nyanja" 117 | }, 118 | { 119 | "urlParam": "zh", 120 | "name": "Chinese" 121 | }, 122 | { 123 | "urlParam": "cv", 124 | "name": "Chuvash" 125 | }, 126 | { 127 | "urlParam": "kw", 128 | "name": "Cornish" 129 | }, 130 | { 131 | "urlParam": "co", 132 | "name": "Corsican" 133 | }, 134 | { 135 | "urlParam": "cr", 136 | "name": "Cree" 137 | }, 138 | { 139 | "urlParam": "hr", 140 | "name": "Croatian" 141 | }, 142 | { 143 | "urlParam": "cs", 144 | "name": "Czech" 145 | }, 146 | { 147 | "urlParam": "da", 148 | "name": "Danish" 149 | }, 150 | { 151 | "urlParam": "dv", 152 | "name": "Divehi, Dhivehi, Maldivian" 153 | }, 154 | { 155 | "urlParam": "nl", 156 | "name": "Dutch, Flemish" 157 | }, 158 | { 159 | "urlParam": "dz", 160 | "name": "Dzongkha" 161 | }, 162 | { 163 | "urlParam": "en", 164 | "name": "English" 165 | }, 166 | { 167 | "urlParam": "eo", 168 | "name": "Esperanto" 169 | }, 170 | { 171 | "urlParam": "et", 172 | "name": "Estonian" 173 | }, 174 | { 175 | "urlParam": "ee", 176 | "name": "Ewe" 177 | }, 178 | { 179 | "urlParam": "fo", 180 | "name": "Faroese" 181 | }, 182 | { 183 | "urlParam": "fj", 184 | "name": "Fijian" 185 | }, 186 | { 187 | "urlParam": "fi", 188 | "name": "Finnish" 189 | }, 190 | { 191 | "urlParam": "fr", 192 | "name": "French" 193 | }, 194 | { 195 | "urlParam": "ff", 196 | "name": "Fulah" 197 | }, 198 | { 199 | "urlParam": "gl", 200 | "name": "Galician" 201 | }, 202 | { 203 | "urlParam": "ka", 204 | "name": "Georgian" 205 | }, 206 | { 207 | "urlParam": "de", 208 | "name": "German" 209 | }, 210 | { 211 | "urlParam": "el", 212 | "name": "Greek, Modern" 213 | }, 214 | { 215 | "urlParam": "gn", 216 | "name": "Guarani" 217 | }, 218 | { 219 | "urlParam": "gu", 220 | "name": "Gujarati" 221 | }, 222 | { 223 | "urlParam": "ht", 224 | "name": "Haitian, Haitian Creole" 225 | }, 226 | { 227 | "urlParam": "ha", 228 | "name": "Hausa" 229 | }, 230 | { 231 | "urlParam": "he", 232 | "name": "Hebrew" 233 | }, 234 | { 235 | "urlParam": "hz", 236 | "name": "Herero" 237 | }, 238 | { 239 | "urlParam": "hi", 240 | "name": "Hindi" 241 | }, 242 | { 243 | "urlParam": "ho", 244 | "name": "Hiri Motu" 245 | }, 246 | { 247 | "urlParam": "hu", 248 | "name": "Hungarian" 249 | }, 250 | { 251 | "urlParam": "ia", 252 | "name": "Interlingua (International Auxil..." 253 | }, 254 | { 255 | "urlParam": "id", 256 | "name": "Indonesian" 257 | }, 258 | { 259 | "urlParam": "ie", 260 | "name": "Interlingue, Occidental" 261 | }, 262 | { 263 | "urlParam": "ga", 264 | "name": "Irish" 265 | }, 266 | { 267 | "urlParam": "ig", 268 | "name": "Igbo" 269 | }, 270 | { 271 | "urlParam": "ik", 272 | "name": "Inupiaq" 273 | }, 274 | { 275 | "urlParam": "io", 276 | "name": "Ido" 277 | }, 278 | { 279 | "urlParam": "is", 280 | "name": "Icelandic" 281 | }, 282 | { 283 | "urlParam": "it", 284 | "name": "Italian" 285 | }, 286 | { 287 | "urlParam": "iu", 288 | "name": "Inuktitut" 289 | }, 290 | { 291 | "urlParam": "ja", 292 | "name": "Japanese" 293 | }, 294 | { 295 | "urlParam": "jv", 296 | "name": "Javanese" 297 | }, 298 | { 299 | "urlParam": "kl", 300 | "name": "Kalaallisut, Greenlandic" 301 | }, 302 | { 303 | "urlParam": "kn", 304 | "name": "Kannada" 305 | }, 306 | { 307 | "urlParam": "kr", 308 | "name": "Kanuri" 309 | }, 310 | { 311 | "urlParam": "ks", 312 | "name": "Kashmiri" 313 | }, 314 | { 315 | "urlParam": "kk", 316 | "name": "Kazakh" 317 | }, 318 | { 319 | "urlParam": "km", 320 | "name": "Central Khmer" 321 | }, 322 | { 323 | "urlParam": "ki", 324 | "name": "Kikuyu, Gikuyu" 325 | }, 326 | { 327 | "urlParam": "rw", 328 | "name": "Kinyarwanda" 329 | }, 330 | { 331 | "urlParam": "ky", 332 | "name": "Kirghiz, Kyrgyz" 333 | }, 334 | { 335 | "urlParam": "kv", 336 | "name": "Komi" 337 | }, 338 | { 339 | "urlParam": "kg", 340 | "name": "Kongo" 341 | }, 342 | { 343 | "urlParam": "ko", 344 | "name": "Korean" 345 | }, 346 | { 347 | "urlParam": "ku", 348 | "name": "Kurdish" 349 | }, 350 | { 351 | "urlParam": "kj", 352 | "name": "Kuanyama, Kwanyama" 353 | }, 354 | { 355 | "urlParam": "la", 356 | "name": "Latin" 357 | }, 358 | { 359 | "urlParam": "lb", 360 | "name": "Luxembourgish, Letzeburgesch" 361 | }, 362 | { 363 | "urlParam": "lg", 364 | "name": "Ganda" 365 | }, 366 | { 367 | "urlParam": "li", 368 | "name": "Limburgan, Limburger, Limburgish" 369 | }, 370 | { 371 | "urlParam": "ln", 372 | "name": "Lingala" 373 | }, 374 | { 375 | "urlParam": "lo", 376 | "name": "Lao" 377 | }, 378 | { 379 | "urlParam": "lt", 380 | "name": "Lithuanian" 381 | }, 382 | { 383 | "urlParam": "lu", 384 | "name": "Luba-Katanga" 385 | }, 386 | { 387 | "urlParam": "lv", 388 | "name": "Latvian" 389 | }, 390 | { 391 | "urlParam": "gv", 392 | "name": "Manx" 393 | }, 394 | { 395 | "urlParam": "mk", 396 | "name": "Macedonian" 397 | }, 398 | { 399 | "urlParam": "mg", 400 | "name": "Malagasy" 401 | }, 402 | { 403 | "urlParam": "ms", 404 | "name": "Malay" 405 | }, 406 | { 407 | "urlParam": "ml", 408 | "name": "Malayalam" 409 | }, 410 | { 411 | "urlParam": "mt", 412 | "name": "Maltese" 413 | }, 414 | { 415 | "urlParam": "mi", 416 | "name": "Maori" 417 | }, 418 | { 419 | "urlParam": "mr", 420 | "name": "Marathi" 421 | }, 422 | { 423 | "urlParam": "mh", 424 | "name": "Marshallese" 425 | }, 426 | { 427 | "urlParam": "mn", 428 | "name": "Mongolian" 429 | }, 430 | { 431 | "urlParam": "na", 432 | "name": "Nauru" 433 | }, 434 | { 435 | "urlParam": "nv", 436 | "name": "Navajo, Navaho" 437 | }, 438 | { 439 | "urlParam": "nd", 440 | "name": "North Ndebele" 441 | }, 442 | { 443 | "urlParam": "ne", 444 | "name": "Nepali" 445 | }, 446 | { 447 | "urlParam": "ng", 448 | "name": "Ndonga" 449 | }, 450 | { 451 | "urlParam": "nb", 452 | "name": "Norwegian Bokmål" 453 | }, 454 | { 455 | "urlParam": "nn", 456 | "name": "Norwegian Nynorsk" 457 | }, 458 | { 459 | "urlParam": "no", 460 | "name": "Norwegian" 461 | }, 462 | { 463 | "urlParam": "ii", 464 | "name": "Sichuan Yi, Nuosu" 465 | }, 466 | { 467 | "urlParam": "nr", 468 | "name": "South Ndebele" 469 | }, 470 | { 471 | "urlParam": "oc", 472 | "name": "Occitan" 473 | }, 474 | { 475 | "urlParam": "oj", 476 | "name": "Ojibwa" 477 | }, 478 | { 479 | "urlParam": "cu", 480 | "name": "Church Slavic, Old Slavonic, Chu..." 481 | }, 482 | { 483 | "urlParam": "om", 484 | "name": "Oromo" 485 | }, 486 | { 487 | "urlParam": "or", 488 | "name": "Oriya" 489 | }, 490 | { 491 | "urlParam": "os", 492 | "name": "Ossetian, Ossetic" 493 | }, 494 | { 495 | "urlParam": "pa", 496 | "name": "Punjabi, Panjabi" 497 | }, 498 | { 499 | "urlParam": "pi", 500 | "name": "Pali" 501 | }, 502 | { 503 | "urlParam": "fa", 504 | "name": "Persian" 505 | }, 506 | { 507 | "urlParam": "pl", 508 | "name": "Polish" 509 | }, 510 | { 511 | "urlParam": "ps", 512 | "name": "Pashto, Pushto" 513 | }, 514 | { 515 | "urlParam": "pt", 516 | "name": "Portuguese" 517 | }, 518 | { 519 | "urlParam": "qu", 520 | "name": "Quechua" 521 | }, 522 | { 523 | "urlParam": "rm", 524 | "name": "Romansh" 525 | }, 526 | { 527 | "urlParam": "rn", 528 | "name": "Rundi" 529 | }, 530 | { 531 | "urlParam": "ro", 532 | "name": "Romanian, Moldavian, Moldovan" 533 | }, 534 | { 535 | "urlParam": "ru", 536 | "name": "Russian" 537 | }, 538 | { 539 | "urlParam": "sa", 540 | "name": "Sanskrit" 541 | }, 542 | { 543 | "urlParam": "sc", 544 | "name": "Sardinian" 545 | }, 546 | { 547 | "urlParam": "sd", 548 | "name": "Sindhi" 549 | }, 550 | { 551 | "urlParam": "se", 552 | "name": "Northern Sami" 553 | }, 554 | { 555 | "urlParam": "sm", 556 | "name": "Samoan" 557 | }, 558 | { 559 | "urlParam": "sg", 560 | "name": "Sango" 561 | }, 562 | { 563 | "urlParam": "sr", 564 | "name": "Serbian" 565 | }, 566 | { 567 | "urlParam": "gd", 568 | "name": "Gaelic, Scottish Gaelic" 569 | }, 570 | { 571 | "urlParam": "sn", 572 | "name": "Shona" 573 | }, 574 | { 575 | "urlParam": "si", 576 | "name": "Sinhala, Sinhalese" 577 | }, 578 | { 579 | "urlParam": "sk", 580 | "name": "Slovak" 581 | }, 582 | { 583 | "urlParam": "sl", 584 | "name": "Slovenian" 585 | }, 586 | { 587 | "urlParam": "so", 588 | "name": "Somali" 589 | }, 590 | { 591 | "urlParam": "st", 592 | "name": "Southern Sotho" 593 | }, 594 | { 595 | "urlParam": "es", 596 | "name": "Spanish, Castilian" 597 | }, 598 | { 599 | "urlParam": "su", 600 | "name": "Sundanese" 601 | }, 602 | { 603 | "urlParam": "sw", 604 | "name": "Swahili" 605 | }, 606 | { 607 | "urlParam": "ss", 608 | "name": "Swati" 609 | }, 610 | { 611 | "urlParam": "sv", 612 | "name": "Swedish" 613 | }, 614 | { 615 | "urlParam": "ta", 616 | "name": "Tamil" 617 | }, 618 | { 619 | "urlParam": "te", 620 | "name": "Telugu" 621 | }, 622 | { 623 | "urlParam": "tg", 624 | "name": "Tajik" 625 | }, 626 | { 627 | "urlParam": "th", 628 | "name": "Thai" 629 | }, 630 | { 631 | "urlParam": "ti", 632 | "name": "Tigrinya" 633 | }, 634 | { 635 | "urlParam": "bo", 636 | "name": "Tibetan" 637 | }, 638 | { 639 | "urlParam": "tk", 640 | "name": "Turkmen" 641 | }, 642 | { 643 | "urlParam": "tl", 644 | "name": "Tagalog" 645 | }, 646 | { 647 | "urlParam": "tn", 648 | "name": "Tswana" 649 | }, 650 | { 651 | "urlParam": "to", 652 | "name": "Tonga (Tonga Islands)" 653 | }, 654 | { 655 | "urlParam": "tr", 656 | "name": "Turkish" 657 | }, 658 | { 659 | "urlParam": "ts", 660 | "name": "Tsonga" 661 | }, 662 | { 663 | "urlParam": "tt", 664 | "name": "Tatar" 665 | }, 666 | { 667 | "urlParam": "tw", 668 | "name": "Twi" 669 | }, 670 | { 671 | "urlParam": "ty", 672 | "name": "Tahitian" 673 | }, 674 | { 675 | "urlParam": "ug", 676 | "name": "Uighur, Uyghur" 677 | }, 678 | { 679 | "urlParam": "uk", 680 | "name": "Ukrainian" 681 | }, 682 | { 683 | "urlParam": "ur", 684 | "name": "Urdu" 685 | }, 686 | { 687 | "urlParam": "uz", 688 | "name": "Uzbek" 689 | }, 690 | { 691 | "urlParam": "ve", 692 | "name": "Venda" 693 | }, 694 | { 695 | "urlParam": "vi", 696 | "name": "Vietnamese" 697 | }, 698 | { 699 | "urlParam": "vo", 700 | "name": "Volapük" 701 | }, 702 | { 703 | "urlParam": "wa", 704 | "name": "Walloon" 705 | }, 706 | { 707 | "urlParam": "cy", 708 | "name": "Welsh" 709 | }, 710 | { 711 | "urlParam": "wo", 712 | "name": "Wolof" 713 | }, 714 | { 715 | "urlParam": "fy", 716 | "name": "Western Frisian" 717 | }, 718 | { 719 | "urlParam": "xh", 720 | "name": "Xhosa" 721 | }, 722 | { 723 | "urlParam": "yi", 724 | "name": "Yiddish" 725 | }, 726 | { 727 | "urlParam": "yo", 728 | "name": "Yoruba" 729 | }, 730 | { 731 | "urlParam": "za", 732 | "name": "Zhuang, Chuang" 733 | }, 734 | { 735 | "urlParam": "zu", 736 | "name": "Zulu" 737 | } 738 | ] -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | 4 | def pytest_addoption(parser): 5 | parser.addoption( 6 | "--auth", 7 | action="store", 8 | default="", 9 | help="username:token to pass to test functions", 10 | ) 11 | 12 | 13 | @pytest.fixture(scope="class") 14 | def auth(request): 15 | request.cls.auth = request.config.getoption("--auth") 16 | return request.config.getoption("--auth") 17 | 18 | 19 | def pytest_configure(config): 20 | config.addinivalue_line( 21 | "markers", "auth: mark test as requiring authentication token" 22 | ) 23 | 24 | 25 | def pytest_collection_modifyitems(config, items): 26 | if config.getoption("--auth"): 27 | # --auth given in cli: do not skip auth-required tests 28 | return 29 | skip_auth = pytest.mark.skip(reason="need --auth option to run") 30 | for item in items: 31 | if "auth" in item.keywords: 32 | item.add_marker(skip_auth) 33 | -------------------------------------------------------------------------------- /tests/test_cli.py: -------------------------------------------------------------------------------- 1 | """tests.test_cli""" 2 | 3 | from datetime import datetime, timedelta 4 | from random import randint 5 | from sys import maxsize 6 | from time import time 7 | # import re 8 | import os 9 | import shutil 10 | 11 | from click.testing import CliRunner 12 | import pytest 13 | 14 | from starcli.__main__ import cli, CACHE_DIR, CACHED_RESULT_PATH 15 | 16 | 17 | @pytest.mark.usefixtures("auth") 18 | class TestCli: 19 | def test_cli_debug(self): 20 | """Test cli when --debug is passed""" 21 | result = self.cli_result(debug=True) 22 | self.assertions(result, debug=True) 23 | 24 | def test_cli(self): 25 | """Test cli when no commands given & debug+auth off""" 26 | result = self.cli_result(debug=False, auth="") 27 | self.assertions(result, debug=False) 28 | 29 | @pytest.mark.auth # This test needs --auth, otherwise skip 30 | def test_auth(self, auth): 31 | """Test basic authentication for valid auth credentials""" 32 | result = self.cli_result(auth=auth) 33 | self.assertions( 34 | result, 35 | # Testing rich logger output doesn't work 36 | # in_stderr=("DEBUG: auth: on",), 37 | # not_in_stderr=("The server did not accept the credentials.",), 38 | ) 39 | 40 | # def test_no_auth(self): 41 | # """Test without --auth""" 42 | # result = self.cli_result(auth="") 43 | # Testing rich logger output doesn't work 44 | # self.assertions(result, in_stderr=("DEBUG: auth: off",)) 45 | 46 | # def test_incorrect_auth(self): 47 | # """Test incorrect credentials provided to --auth""" 48 | # result = self.cli_result(auth="github:0000") 49 | # self.assertions( 50 | # result, 51 | # # Testing rich logger output doesn't work 52 | # # in_stderr=("The server did not accept the credentials.",), 53 | # ) 54 | 55 | def test_invalid_auth_format(self): 56 | """Test invalid credentials provided to --auth""" 57 | # XXX: --auth format not checked if a cache exists 58 | result = self.cli_result(auth="github:", clear_cache=True, nop=True) 59 | self.assertions(result, in_output=("Invalid authentication format",)) 60 | 61 | result = self.cli_result(auth=":0000", clear_cache=True, nop=True) 62 | self.assertions(result, in_output=("Invalid authentication format",)) 63 | 64 | result = self.cli_result(auth="abc", clear_cache=True, nop=True) 65 | self.assertions(result, in_output=("Invalid authentication format",)) 66 | 67 | def test_cli_lang(self): 68 | """Test cli when --lang or -l is passed""" 69 | param_decls = ["--lang", "-l"] 70 | 71 | for param in param_decls: 72 | result = self.cli_result(param, "python", clear_cache=False) 73 | self.assertions( 74 | result, 75 | # Testing rich logger output doesn't work 76 | # in_stderr=("language:python",), 77 | ) 78 | 79 | # Until upstream github trending dependency is fixed 80 | @pytest.mark.xfail() 81 | def test_cli_spoken_language(self): 82 | """Test cli when --spoken-language or -S is passed""" 83 | param_decls = ["--spoken-language", "-S"] 84 | # Currently, this option uses `search_github_trending`, which produces no 'DEBUG:' 85 | for param in param_decls: 86 | result = self.cli_result(param, "en") 87 | self.assertions(result, debug=False) 88 | 89 | def test_cli_created(self): 90 | """Test cli when --created or -c with valid option is passed""" 91 | param_decls = ["--created", "-c"] 92 | 93 | for param in param_decls: 94 | date_format = "%Y-%m-%d" 95 | day_range = 0 - randint(100, 400) 96 | created_date_value = ">=" + ( 97 | datetime.utcnow() + timedelta(days=day_range) 98 | ).strftime(date_format) 99 | result = self.cli_result(param, created_date_value) 100 | self.assertions(result, not_in_output=("Invalid date",)) 101 | 102 | def test_cli_created_invalid(self): 103 | """Test cli when --created or -c with invalid option is passed""" 104 | param_decls = ["--created", "-c"] 105 | 106 | for param in param_decls: 107 | date_format = "%d-%m-%Y" 108 | day_range = 0 - randint(100, 400) 109 | created_date_value = ( 110 | datetime.utcnow() + timedelta(days=day_range) 111 | ).strftime(date_format) 112 | result = self.cli_result(param, created_date_value) 113 | self.assertions( 114 | result, 115 | in_output=("Invalid date",), 116 | ) 117 | 118 | def test_cli_topic(self): 119 | """Test cli when --topic or -t is passed""" 120 | param_decls = ["--topic", "-t"] 121 | 122 | for param in param_decls: 123 | result = self.cli_result( 124 | param, "javascript", param, "nodejs", clear_cache=False 125 | ) # javascript + nodejs will likely come up together 126 | self.assertions(result) 127 | 128 | def test_cli_pushed(self): 129 | """Test cli when --pushed or -p is passed""" 130 | param_decls = ["--pushed", "-p"] 131 | 132 | for param in param_decls: 133 | date_format = "%Y-%m-%d" 134 | day_range = 0 - randint(100, 400) 135 | pushed_date_value = ( 136 | datetime.utcnow() + timedelta(days=day_range) 137 | ).strftime(date_format) 138 | result = self.cli_result(param, pushed_date_value) 139 | self.assertions(result, not_in_output=("Invalid date",)) 140 | 141 | def test_cli_pushed_invalid(self): 142 | """Test cli when invalid option to --pushed or -p is passed""" 143 | param_decls = ["--pushed", "-p"] 144 | 145 | for param in param_decls: 146 | date_format = "%d-%m-%Y" 147 | day_range = 0 - randint(100, 400) 148 | pushed_date_value = ( 149 | datetime.utcnow() + timedelta(days=day_range) 150 | ).strftime(date_format) 151 | result = self.cli_result(param, pushed_date_value, clear_cache=False) 152 | self.assertions( 153 | result, 154 | in_output=("Invalid date"), 155 | ) 156 | 157 | def test_cli_layout(self): 158 | """Test cli when --layout or -L is passed""" 159 | param_decls = ["--layout", "-L"] 160 | choices = ["list", "table", "grid"] 161 | 162 | for param in param_decls: 163 | for choice in choices: 164 | result = self.cli_result(param, choice, clear_cache=False) 165 | self.assertions(result) 166 | 167 | def test_cli_stars(self): 168 | """Test cli when --stars or -s is passed""" 169 | param_decls = ["--stars", "-s"] 170 | 171 | for param in param_decls: 172 | result = self.cli_result(param, 0) 173 | self.assertions(result) 174 | 175 | result = self.cli_result(param, 1) 176 | self.assertions(result) 177 | 178 | result = self.cli_result(param, maxsize) 179 | self.assertions(result) 180 | 181 | def test_cli_limit_results(self): 182 | """Test cli when --limit-results or -r is passed""" 183 | param_decls = ["--limit-results", "-r"] 184 | 185 | for param in param_decls: 186 | result = self.cli_result(param, 0, clear_cache=False) 187 | self.assertions(result) 188 | 189 | result = self.cli_result(param, 1, clear_cache=False) 190 | self.assertions(result) 191 | 192 | result = self.cli_result(param, maxsize, clear_cache=False) 193 | self.assertions(result) 194 | 195 | result = self.cli_result(param, -1, clear_cache=False) 196 | self.assertions(result) 197 | 198 | def test_cli_order(self): 199 | """Test cli when --order or -o is passed""" 200 | param_decls = ["--order", "-o"] 201 | choices = ["desc", "asc"] 202 | 203 | for param in param_decls: 204 | for choice in choices: 205 | result = self.cli_result(param, choice, clear_cache=False) 206 | self.assertions(result) 207 | 208 | def test_cli_long_stats(self): 209 | """Test cli when --long-stats is passed""" 210 | result = self.cli_result("--long-stats", clear_cache=False) 211 | self.assertions(result) 212 | 213 | @pytest.mark.xfail() 214 | def test_cli_date_range(self): 215 | """Test cli when --date-range or -d is passed""" 216 | param_decls = ["--date-range", "-d"] 217 | choices = ["today", "this-week", "this-month"] 218 | # Currently, this option uses `search_github_trending`, which produces no 'DEBUG:' 219 | for param in param_decls: 220 | for choice in choices: 221 | result = self.cli_result(param, choice) 222 | self.assertions(result, debug=False) 223 | 224 | def test_cli_user(self): 225 | """Test cli when --user or -U is passed""" 226 | param_decls = ["--user", "-u"] 227 | 228 | for param in param_decls: 229 | result = self.cli_result(param, "github", clear_cache=False) 230 | self.assertions(result) 231 | 232 | def test_cached_file_existence(self): 233 | """Test the caching of result""" 234 | 235 | self.cli_result( 236 | "--topic", "python", "--stars", ">100", clear_cache=True, nop=False 237 | ) 238 | 239 | assert os.path.exists(CACHE_DIR), f"Cache directory not created: {CACHE_DIR}" 240 | assert os.path.exists( 241 | CACHED_RESULT_PATH 242 | ), f"Failed to create cache file: {CACHED_RESULT_PATH}" 243 | 244 | def test_time_diff_for_cached_result(self): 245 | """Test the time difference between fetching new and cached result""" 246 | 247 | start = time() 248 | self.cli_result("--topic", "python", "--stars", ">1000", clear_cache=True) 249 | end = time() 250 | new_result_runtime = end - start 251 | 252 | start = time() 253 | self.cli_result("--topic", "python", "--stars", ">1000", clear_cache=False) 254 | end = time() 255 | cached_result_runtime = end - start 256 | 257 | assert ( 258 | new_result_runtime > cached_result_runtime 259 | ), f"Cached result took longer ({cached_result_runtime}) than newly fetching results ({new_result_runtime})." 260 | 261 | def cli_result( 262 | self, 263 | *args, 264 | debug=True, 265 | auth="", 266 | clear_cache=True, 267 | nop=False, 268 | ): 269 | """ 270 | CliRunner() helper function. Returns a `click.testing.Result` object. 271 | Passes `--debug` by default. Passes `--auth` + credentials, if given. 272 | 273 | Also clear the cache if needed. 274 | """ 275 | if clear_cache: 276 | try: 277 | shutil.rmtree(CACHE_DIR) 278 | except FileNotFoundError: 279 | pass 280 | 281 | runner = CliRunner() 282 | cli_params = list(args) 283 | 284 | if auth: 285 | cli_params.extend(["--auth", auth]) 286 | elif self.auth: 287 | cli_params.extend(["--auth", self.auth]) 288 | 289 | if debug: 290 | cli_params.append("--debug") 291 | 292 | if nop: 293 | cli_params.append("--nop") 294 | 295 | 296 | return runner.invoke(cli, cli_params) if cli_params else runner.invoke(cli) 297 | 298 | def assertions( 299 | self, 300 | result, 301 | exit_code=0, 302 | output=True, 303 | debug=True, 304 | in_output=(), 305 | not_in_output=(), 306 | in_stderr=(), 307 | not_in_stderr=(), 308 | ): 309 | """ 310 | Helper function for basic assert statements. 311 | """ 312 | if exit_code: 313 | assert result.exit_code == exit_code, f"`exit_code` should be '{exit_code}'" 314 | # if debug: # logs aren't captured in result.output so it doesn't work 315 | # assert "DEBUG" in result.output, f"'DEBUG' not in `result.output`" 316 | if output and not debug: 317 | assert result.output, "No cli output generated" 318 | elif not output: 319 | assert not result.output, "Cli output generated, but expected nothing" 320 | 321 | if in_output: 322 | for s in in_output: 323 | assert s in result.output, f"'{s}' not found in `result.output.`" 324 | if not_in_output: 325 | for s in not_in_output: 326 | assert ( 327 | not s in result.output 328 | ), f"{s} found in `result.output`, but shouldn't be." 329 | if in_stderr: 330 | for s in in_stderr: 331 | assert s in result.stderr, f"{s} not in `result.stderr`" 332 | if not_in_stderr: 333 | for s in not_in_stderr: 334 | assert not s in result.stderr, f"{s} shouldn't be in stderr" 335 | -------------------------------------------------------------------------------- /tests/test_search.py: -------------------------------------------------------------------------------- 1 | """tests.test_search""" 2 | 3 | from datetime import datetime, timedelta 4 | from random import randint 5 | 6 | import pytest 7 | 8 | from starcli.search import search, search_github_trending 9 | 10 | 11 | def test_search_language(): 12 | """Test searching by language""" 13 | for language in ["python", "Python", "JavaScript", "c"]: 14 | repos = search([language]) 15 | for repo in repos: 16 | assert repo["stargazers_count"] >= 0 17 | assert repo["forks"] >= 0 18 | assert repo["language"].lower() == language.lower() 19 | assert (repo["description"] is None) or repo["description"] 20 | assert repo["full_name"].count("/") == 1 21 | assert repo["full_name"] == f"{repo['owner']['login']}/{repo['name']}" 22 | assert repo["html_url"] == "https://github.com/" + repo["full_name"] 23 | 24 | 25 | def test_search_topics(): 26 | """Test searching by topics""" 27 | for topics in [ 28 | "deezer", 29 | "django", 30 | "cookiecutter", 31 | ["web", "flask"], 32 | "python3", 33 | "algorithm", 34 | "shell", 35 | ]: 36 | repos = search(languages=["python"], topics=topics) 37 | for repo in repos: 38 | assert repo["stargazers_count"] >= 0 39 | assert repo["forks"] >= 0 40 | assert repo["language"].lower() == "python" 41 | assert (repo["description"] is None) or repo["description"] 42 | assert repo["full_name"].count("/") >= 1 43 | assert repo["full_name"] == f"{repo['owner']['login']}/{repo['name']}" 44 | assert repo["html_url"] == "https://github.com/" + repo["full_name"] 45 | assert topics in repo["topics"] 46 | 47 | 48 | def test_search_created_date(): 49 | """Test searching with creation date""" 50 | date_format = "%Y-%m-%d" 51 | day_range = 0 - randint(100, 400) 52 | created_date_value = (datetime.utcnow() + timedelta(days=day_range)).strftime( 53 | date_format 54 | ) 55 | repos = search(languages=["python"], created=created_date_value) 56 | for repo in repos: 57 | assert repo["stargazers_count"] >= 0 58 | assert repo["forks"] >= 0 59 | assert (repo["description"] is None) or repo["description"] 60 | assert repo["full_name"].count("/") >= 1 61 | assert repo["full_name"] == f"{repo['owner']['login']}/{repo['name']}" 62 | assert repo["html_url"] == "https://github.com/" + repo["full_name"] 63 | 64 | assert datetime.strptime( 65 | repo["created_at"].split("T")[0], date_format 66 | ) >= datetime.strptime(created_date_value, date_format) and datetime.strptime( 67 | repo["created_at"].split("T")[0], date_format 68 | ) <= datetime.strptime( 69 | created_date_value, date_format 70 | ) + timedelta( 71 | days=1 72 | ) 73 | 74 | 75 | def test_search_pushed_date(): 76 | """Test searching with updated date""" 77 | date_format = "%Y-%m-%d" 78 | day_range = 0 - randint(100, 400) 79 | pushed_date_value = (datetime.utcnow() + timedelta(days=day_range)).strftime( 80 | date_format 81 | ) 82 | repos = search(languages=["python"], pushed=pushed_date_value) 83 | for repo in repos: 84 | assert repo["stargazers_count"] >= 0 85 | assert repo["forks"] >= 0 86 | assert (repo["description"] is None) or repo["description"] 87 | assert repo["full_name"].count("/") >= 1 88 | assert repo["full_name"] == f"{repo['owner']['login']}/{repo['name']}" 89 | assert repo["html_url"] == "https://github.com/" + repo["full_name"] 90 | 91 | # Need to account for min and max updated dates 92 | assert datetime.strptime( 93 | repo["pushed_at"].split("T")[0], date_format 94 | ) >= datetime.strptime(pushed_date_value, date_format) and datetime.strptime( 95 | repo["pushed_at"].split("T")[0], date_format 96 | ) <= datetime.strptime( 97 | pushed_date_value, date_format 98 | ) + timedelta( 99 | days=1 100 | ) 101 | 102 | 103 | @pytest.mark.xfail(raises=AssertionError) 104 | def test_search_stars(): 105 | """Test searching with number of stars""" 106 | repos = search(languages=["python"], stars="<10") 107 | for repo in repos: 108 | # FIXME: Possibly problem with GitHub API? 109 | assert repo["stargazers_count"] < 10 # Sometimes stars+1 110 | assert repo["forks"] >= 0 111 | assert (repo["description"] is None) or repo["description"] 112 | assert repo["full_name"].count("/") >= 1 113 | assert repo["full_name"] == f"{repo['owner']['login']}/{repo['name']}" 114 | assert repo["html_url"] == "https://github.com/" + repo["full_name"] 115 | 116 | 117 | def test_search_user(): 118 | """Test searching by user""" 119 | repos = search(languages=["ruby"], user="octocat") 120 | for repo in repos: 121 | assert repo["stargazers_count"] >= 0 122 | assert repo["forks"] >= 0 123 | assert (repo["description"] is None) or repo["description"] 124 | assert repo["full_name"].split("/")[0] == "octocat" 125 | assert repo["full_name"] == f"{repo['owner']['login']}/{repo['name']}" 126 | assert repo["html_url"] == "https://github.com/" + repo["full_name"] 127 | 128 | 129 | def test_no_results(): 130 | """Test if no search results found""" 131 | repos = search(["python"], "2020-01-01", "2019-01-01") 132 | assert repos == [] 133 | 134 | 135 | # commented until upstream github trending dependency is fixed 136 | @pytest.mark.xfail() 137 | def test_spoken_language(): 138 | """Test search by spoken languages""" 139 | repos = search_github_trending(["javascript"], "zh") # zh = chinese 140 | for repo in repos: 141 | assert repo["stargazers_count"] >= 0 142 | assert repo["forks"] >= 0 143 | assert repo["language"].lower() == "javascript" 144 | assert (repo["description"] == None) or repo["description"] 145 | assert repo["full_name"].count("/") >= 1 146 | assert repo["html_url"] == "https://github.com/" + repo["full_name"] 147 | 148 | @pytest.mark.xfail() 149 | def test_date_range(): 150 | """Test search by date range""" 151 | for date_range in ["daily", "monthly", "weekly"]: 152 | repos = search_github_trending(["python"], "en", date_range) 153 | for repo in repos: 154 | assert repo["stargazers_count"] >= 0 155 | assert repo["forks"] >= 0 156 | assert repo["language"].lower() == "python" 157 | assert (repo["description"] == None) or repo["description"] 158 | assert repo["full_name"].count("/") >= 1 159 | assert repo["html_url"] == "https://github.com/" + repo["full_name"] 160 | # TODO: verify date range 161 | 162 | 163 | def test_search_multiple_language(): 164 | """Test searching by multiple language""" 165 | languages = ["python", "c"] 166 | repos = search(languages) 167 | for repo in repos: 168 | assert repo["stargazers_count"] >= 0 169 | assert repo["forks"] >= 0 170 | assert repo["language"].lower() in languages 171 | assert (repo["description"] is None) or repo["description"] 172 | assert repo["full_name"].count("/") == 1 173 | assert repo["full_name"] == f"{repo['owner']['login']}/{repo['name']}" 174 | assert repo["html_url"] == "https://github.com/" + repo["full_name"] 175 | -------------------------------------------------------------------------------- /tests/test_shorten_count.py: -------------------------------------------------------------------------------- 1 | """tests.test_shorten_count""" 2 | from starcli.layouts import shorten_count 3 | 4 | 5 | def test_shorten_count(): 6 | """Test the shorten_count functionality""" 7 | assert shorten_count(1487) == "1.5k" 8 | assert shorten_count(6001) == "6k" 9 | assert shorten_count(15587) == "15.6k" 10 | assert shorten_count(12) == "12" 11 | --------------------------------------------------------------------------------