├── .gitignore ├── .haxornewsconfig ├── .travis.yml ├── CHANGELOG.md ├── CHANGELOG.rst ├── CHECKLIST.md ├── CONTRIBUTING.md ├── INSTALLATION.md ├── LICENSE.txt ├── README.md ├── appveyor.yml ├── codecov.yml ├── haxor_news ├── __init__.py ├── compat.py ├── completer.py ├── completions.py ├── config.py ├── hacker_news.py ├── hacker_news_cli.py ├── haxor.py ├── keys.py ├── lib │ ├── __init__.py │ ├── debug_timer.py │ ├── haxor │ │ ├── __init__.py │ │ ├── haxor.py │ │ └── settings.py │ ├── html2text │ │ ├── __init__.py │ │ └── html2text.py │ └── pretty_date_time.py ├── main.py ├── main_cli.py ├── onions.py ├── settings.py ├── style.py ├── toolbar.py ├── utils.py └── web_viewer.py ├── requirements-dev.txt ├── scripts ├── create_changelog.sh ├── create_readme_rst.sh ├── run_code_checks.sh ├── set_changelog_as_readme.sh ├── update_docs.sh └── upload_pypi.sh ├── setup.py ├── tests ├── __init__.py ├── compat.py ├── data │ ├── __init__.py │ ├── comment.py │ ├── item.py │ ├── markdown.py │ ├── regex.py │ ├── tip.py │ ├── title.py │ └── url.py ├── mock_hacker_news_api.py ├── run_tests.py ├── test_cli.py ├── test_completer.py ├── test_config.py ├── test_config_integration.py ├── test_hacker_news.py ├── test_hacker_news_cli.py ├── test_haxor.py ├── test_keys.py └── test_toolbar.py └── tox.ini /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | # C extensions 6 | *.so 7 | 8 | # Distribution / packaging 9 | .Python 10 | env/ 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib64/ 18 | parts/ 19 | sdist/ 20 | var/ 21 | *.egg-info/ 22 | .installed.cfg 23 | *.egg 24 | 25 | # PyInstaller 26 | # Usually these files are written by a python script from a template 27 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 28 | *.manifest 29 | *.spec 30 | 31 | # Installer logs 32 | pip-log.txt 33 | pip-delete-this-directory.txt 34 | 35 | # Unit test / coverage reports 36 | htmlcov/ 37 | .tox/ 38 | .coverage 39 | .coverage.* 40 | .cache 41 | nosetests.xml 42 | coverage.xml 43 | *,cover 44 | 45 | # Translations 46 | *.mo 47 | *.pot 48 | 49 | # Django stuff: 50 | *.log 51 | 52 | # Sphinx documentation 53 | docs/_build/ 54 | 55 | # PyBuilder 56 | target/ 57 | 58 | # Misc 59 | scratch/ -------------------------------------------------------------------------------- /.haxornewsconfig: -------------------------------------------------------------------------------- 1 | [haxor-news] 2 | hiring_id = 0 3 | freelance_id = 0 4 | show_tip = True 5 | clr_bold = cyan 6 | clr_code = cyan 7 | clr_general = None 8 | clr_header = yellow 9 | clr_link = green 10 | clr_list = cyan 11 | clr_num_comments = green 12 | clr_num_points = green 13 | clr_tag = cyan 14 | clr_time = yellow 15 | clr_title = None 16 | clr_tooltip = None 17 | clr_user = cyan 18 | clr_view_link = magenta 19 | clr_view_index = magenta 20 | item_ids = [0, 1, 2] 21 | item_cache = ['3', '4', '5'] 22 | 23 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | env: 3 | - TOXENV=py27 4 | - TOXENV=py36 5 | os: 6 | - linux 7 | install: 8 | - travis_retry pip install tox 9 | - pip install codecov 10 | script: 11 | - tox 12 | after_success: 13 | - codecov 14 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ![](http://i.imgur.com/C4mkc3L.gif) 2 | 3 | [![Build Status](https://travis-ci.org/donnemartin/haxor-news.svg?branch=master)](https://travis-ci.org/donnemartin/haxor-news) [![Dependency Status](https://gemnasium.com/donnemartin/haxor-news.svg)](https://gemnasium.com/donnemartin/haxor-news) 4 | 5 | [![PyPI version](https://badge.fury.io/py/haxor-news.svg)](http://badge.fury.io/py/haxor-news) [![PyPI](https://img.shields.io/pypi/pyversions/haxor-news.svg)](https://pypi.python.org/pypi/haxor-news/) [![License](http://img.shields.io/:license-apache-blue.svg)](http://www.apache.org/licenses/LICENSE-2.0.html) 6 | 7 | haxor-news 8 | ========== 9 | 10 | To view the latest `README`, `docs`, and `code` visit the GitHub repo: 11 | 12 | https://github.com/donnemartin/haxor-news 13 | 14 | To submit bugs or feature requests, visit the issue tracker: 15 | 16 | https://github.com/donnemartin/haxor-news/issues 17 | 18 | Changelog 19 | ========= 20 | 21 | 0.4.2 (2017-04-08) 22 | ------------------ 23 | 24 | ### Bug Fixes 25 | 26 | * [#94](https://github.com/donnemartin/haxor-news/pull/94) - Update to `prompt-toolkit` 1.0.0, which includes a number of performance improvements (especially noticeable on Windows) and bug fixes. 27 | 28 | 0.4.1 (2016-05-30) 29 | ------------------ 30 | 31 | ### Bug Fixes 32 | 33 | * [#62](https://github.com/donnemartin/haxor-news/pull/62) - Fix `prompt-toolkit` v1.0.0 hanging while autocompleting the hn view command. Revert back to `prompt-toolkit` v0.52. This bug only happens on Windows. 34 | 35 | 0.4.0 (2016-05-30) 36 | ------------------ 37 | 38 | ### Features 39 | 40 | * [#52](https://github.com/donnemartin/haxor-news/issues/52) - Add `exit` and `quit` commands, which can be used instead of `ctrl-d`. 41 | * [#53](https://github.com/donnemartin/haxor-news/issues/53) - Allow clicking of urls in some terminals to open in a browser. 42 | 43 | ### Bug Fixes 44 | 45 | * [#36](https://github.com/donnemartin/haxor-news/issues/36) - Fix crash caused by Unicode comments on Windows. 46 | * [#59](https://github.com/donnemartin/haxor-news/pull/59) - Update to `prompt-toolkit` 1.0.0, which includes a number of performance improvements (especially noticeable on Windows) and bug fixes. 47 | * Fix some comments and docstrings. 48 | 49 | ### Updates 50 | 51 | * [#48](https://github.com/donnemartin/haxor-news/issues/48), [#50](https://github.com/donnemartin/haxor-news/issues/50) - Update latest monthly hiring post ids. 52 | * [#56](https://github.com/donnemartin/haxor-news/issues/48) - Update packaging dependencies based on semantic versioning. 53 | * Fix `Config` docstrings. 54 | * Update `README`: 55 | * Update intro 56 | * Add Hacker News discussion of `haxor-news` 57 | * Update comments discussion and examples 58 | * Update TODO 59 | * Fix urls based on redirects 60 | * Remove buggy Codecov graph 61 | * Add note about OS X 10.11 pip installation issue 62 | * Add Gemnasium dependencies management. 63 | * Update links in `CONTRIBUTING`. 64 | 65 | 0.3.1 (2016-04-10) 66 | ------------------ 67 | 68 | - Initial release. 69 | -------------------------------------------------------------------------------- /CHANGELOG.rst: -------------------------------------------------------------------------------- 1 | .. figure:: http://i.imgur.com/C4mkc3L.gif 2 | :alt: 3 | 4 | |Build Status| |Dependency Status| 5 | 6 | |PyPI version| |PyPI| |License| 7 | 8 | haxor-news 9 | ========== 10 | 11 | To view the latest ``README``, ``docs``, and ``code`` visit the GitHub 12 | repo: 13 | 14 | https://github.com/donnemartin/haxor-news 15 | 16 | To submit bugs or feature requests, visit the issue tracker: 17 | 18 | https://github.com/donnemartin/haxor-news/issues 19 | 20 | Changelog 21 | ========= 22 | 23 | 0.4.2 (2017-04-08) 24 | ------------------ 25 | 26 | Bug Fixes 27 | ~~~~~~~~~ 28 | 29 | - `#94 `__ - Update 30 | to ``prompt-toolkit`` 1.0.0, which includes a number of performance 31 | improvements (especially noticeable on Windows) and bug fixes. 32 | 33 | 0.4.1 (2016-05-30) 34 | ------------------ 35 | 36 | Bug Fixes 37 | ~~~~~~~~~ 38 | 39 | - `#62 `__ - Fix 40 | ``prompt-toolkit`` v1.0.0 hanging while autocompleting the hn view 41 | command. Revert back to ``prompt-toolkit`` v0.52. This bug only 42 | happens on Windows. 43 | 44 | 0.4.0 (2016-05-30) 45 | ------------------ 46 | 47 | Features 48 | ~~~~~~~~ 49 | 50 | - `#52 `__ - Add 51 | ``exit`` and ``quit`` commands, which can be used instead of 52 | ``ctrl-d``. 53 | - `#53 `__ - Allow 54 | clicking of urls in some terminals to open in a browser. 55 | 56 | Bug Fixes 57 | ~~~~~~~~~ 58 | 59 | - `#36 `__ - Fix 60 | crash caused by Unicode comments on Windows. 61 | - `#59 `__ - Update 62 | to ``prompt-toolkit`` 1.0.0, which includes a number of performance 63 | improvements (especially noticeable on Windows) and bug fixes. 64 | - Fix some comments and docstrings. 65 | 66 | Updates 67 | ~~~~~~~ 68 | 69 | - `#48 `__, 70 | `#50 `__ - 71 | Update latest monthly hiring post ids. 72 | - `#56 `__ - 73 | Update packaging dependencies based on semantic versioning. 74 | - Fix ``Config`` docstrings. 75 | - Update ``README``: 76 | 77 | - Update intro 78 | - Add Hacker News discussion of ``haxor-news`` 79 | - Update comments discussion and examples 80 | - Update TODO 81 | - Fix urls based on redirects 82 | - Remove buggy Codecov graph 83 | - Add note about OS X 10.11 pip installation issue 84 | 85 | - Add Gemnasium dependencies management. 86 | - Update links in ``CONTRIBUTING``. 87 | 88 | 0.3.1 (2016-04-10) 89 | ------------------ 90 | 91 | - Initial release. 92 | 93 | .. |Build Status| image:: https://travis-ci.org/donnemartin/haxor-news.svg?branch=master 94 | :target: https://travis-ci.org/donnemartin/haxor-news 95 | .. |Dependency Status| image:: https://gemnasium.com/donnemartin/haxor-news.svg 96 | :target: https://gemnasium.com/donnemartin/haxor-news 97 | .. |PyPI version| image:: https://badge.fury.io/py/haxor-news.svg 98 | :target: http://badge.fury.io/py/haxor-news 99 | .. |PyPI| image:: https://img.shields.io/pypi/pyversions/haxor-news.svg 100 | :target: https://pypi.python.org/pypi/haxor-news/ 101 | .. |License| image:: http://img.shields.io/:license-apache-blue.svg 102 | :target: http://www.apache.org/licenses/LICENSE-2.0.html 103 | -------------------------------------------------------------------------------- /CHECKLIST.md: -------------------------------------------------------------------------------- 1 | Release Checklist 2 | ================= 3 | 4 | A. Install in a new venv and run unit tests 5 | 6 | Note, you can't seem to script the virtualenv calls, see: 7 | https://bitbucket.org/dhellmann/virtualenvwrapper/issues/219/cant-deactivate-active-virtualenv-from 8 | 9 | $ deactivate 10 | $ rmvirtualenv haxor-news 11 | $ mkvirtualenv haxor-news 12 | $ pip install -e . 13 | $ pip install -r requirements-dev.txt 14 | $ rm -rf .tox && tox 15 | 16 | B. Run code checks 17 | 18 | $ scripts/run_code_checks.sh 19 | 20 | C. Run manual [smoke tests](#smoke-tests) on Mac, Ubuntu, Windows 21 | 22 | D. Update and review `README.rst` and `Sphinx` docs, then check haxor-news/docs/build/html/index.html 23 | 24 | $ scripts/update_docs.sh 25 | 26 | E. Push changes 27 | 28 | F. Review Travis, Codecov, and Gemnasium 29 | 30 | G. Start a new release branch 31 | 32 | $ git flow release start x.y.z 33 | 34 | H. Increment the version number in `haxor-news/__init__.py` 35 | 36 | I. Update and review `CHANGELOG` 37 | 38 | $ scripts/create_changelog.sh 39 | 40 | J. Commit the changes 41 | 42 | K. Finish the release branch 43 | 44 | $ git flow release finish 'x.y.z' 45 | 46 | L. Input a tag 47 | 48 | $ vx.y.z 49 | 50 | M. Push tagged release to develop and master 51 | 52 | N. Set CHANGELOG.rst as `README.rst` 53 | 54 | $ scripts/set_changelog_as_readme.sh 55 | 56 | O. Register package with PyPi 57 | 58 | $ python setup.py register -r pypi 59 | 60 | P. Upload to PyPi 61 | 62 | $ python setup.py sdist upload -r pypi 63 | 64 | Q. Upload Sphinx docs to PyPi 65 | 66 | $ python setup.py upload_sphinx 67 | 68 | R. Review newly released package from PyPi 69 | 70 | S. Release on GitHub: https://github.com/donnemartin/haxor-news/tags 71 | 72 | 1. Click "Add release notes" for latest release 73 | 2. Copy release notes from `CHANGELOG.md` 74 | 3. Click "Publish release" 75 | 76 | T. Install in a new venv and run manual [smoke tests](#smoke-tests) on Mac, Ubuntu, Windows 77 | 78 | ## Smoke Tests 79 | 80 | Run the following on Python 2.7 and Python 3.4: 81 | 82 | * Craete a new `virtualenv` 83 | * Pip install `haxor-news` into new `virtualenv` 84 | * Run `haxor-news` 85 | * Run targeted tests based on recent code changes 86 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | Contributing 2 | ============ 3 | 4 | Contributions are welcome! 5 | 6 | **Please carefully read this page to make the code review process go as smoothly as possible and to maximize the likelihood of your contribution being merged.** 7 | 8 | ## Bug Reports 9 | 10 | For bug reports or requests [submit an issue](https://github.com/donnemartin/haxor-news/issues). 11 | 12 | ## Pull Requests 13 | 14 | The preferred way to contribute is to fork the 15 | [main repository](https://github.com/donnemartin/haxor-news) on GitHub. 16 | 17 | 1. Fork the [main repository](https://github.com/donnemartin/haxor-news). Click on the 'Fork' button near the top of the page. This creates a copy of the code under your account on the GitHub server. 18 | 19 | 2. Clone this copy to your local disk: 20 | 21 | $ git clone git@github.com:YourLogin/haxor-news.git 22 | $ cd haxor-news 23 | 24 | 3. Create a branch to hold your changes and start making changes. Don't work in the `master` branch! 25 | 26 | $ git checkout -b my-feature 27 | 28 | 4. Work on this copy on your computer using Git to do the version control. When you're done editing, run the following to record your changes in Git: 29 | 30 | $ git add modified_files 31 | $ git commit 32 | 33 | 5. Push your changes to GitHub with: 34 | 35 | $ git push -u origin my-feature 36 | 37 | 6. Finally, go to the web page of your fork of the `haxor-news` repo and click 'Pull Request' to send your changes for review. 38 | 39 | ### GitHub Pull Requests Docs 40 | 41 | If you are not familiar with pull requests, review the [pull request docs](https://help.github.com/articles/using-pull-requests/). 42 | 43 | ### Code Quality 44 | 45 | Ensure your pull request satisfies all of the following, where applicable: 46 | 47 | * Is covered by [unit tests](https://github.com/donnemartin/haxor-news#unit-tests-and-code-coverage) 48 | * Passes [continuous integration](https://github.com/donnemartin/haxor-news#continuous-integration) 49 | * Is covered by [documentation](https://github.com/donnemartin/haxor-news#documentation) 50 | 51 | Review the following [style guide](https://google.github.io/styleguide/pyguide.html). 52 | 53 | Run code checks and fix any issues: 54 | 55 | $ scripts/run_code_checks.sh 56 | 57 | ### Installation 58 | 59 | Refer to the [Installation](https://github.com/donnemartin/haxor-news#installation) and [Developer Installation](https://github.com/donnemartin/haxor-news#developer-installation) sections. 60 | -------------------------------------------------------------------------------- /INSTALLATION.md: -------------------------------------------------------------------------------- 1 | Installation 2 | ============ 3 | 4 | ### Pip Installation 5 | 6 | [![PyPI version](https://badge.fury.io/py/haxor-news.svg)](http://badge.fury.io/py/haxor-news) [![PyPI](https://img.shields.io/pypi/pyversions/haxor-news.svg)](https://pypi.python.org/pypi/haxor-news/) 7 | 8 | `haxor-news` is hosted on [PyPI](https://pypi.python.org/pypi/haxor-news). The following command will install `haxor-news`: 9 | 10 | $ pip install haxor-news 11 | 12 | You can also install the latest `haxor-news` from GitHub source which can contain changes not yet pushed to PyPI: 13 | 14 | $ pip install git+https://github.com/donnemartin/haxor-news.git 15 | 16 | If you are not installing in a virtualenv, run with `sudo`: 17 | 18 | $ sudo pip install haxor-news 19 | 20 | Once installed, run the optional `haxor-news` auto-completer with interactive help: 21 | 22 | $ haxor-news 23 | 24 | Run commands: 25 | 26 | $ hn [params] [options] 27 | 28 | ## Virtual Environment Installation 29 | 30 | It is recommended that you install Python packages in a [virtualenv](http://docs.python-guide.org/en/latest/dev/virtualenvs/) to avoid potential issues with dependencies or permissions. 31 | 32 | If you are a Windows user or if you would like more details on `virtualenv`, check out this [guide](http://docs.python-guide.org/en/latest/dev/virtualenvs/). 33 | 34 | Install `virtualenv` and `virtualenvwrapper`: 35 | 36 | pip install virtualenv 37 | pip install virtualenvwrapper 38 | export WORKON_HOME=~/.virtualenvs 39 | source /usr/local/bin/virtualenvwrapper.sh 40 | 41 | Create a `haxor-news` `virtualenv` and install `haxor-news`: 42 | 43 | mkvirtualenv haxor-news 44 | pip install haxor-news 45 | 46 | If you want to activate the `haxor-news` `virtualenv` again later, run: 47 | 48 | workon haxor-news 49 | 50 | ## Mac OS X 10.11 El Capitan Users 51 | 52 | There is a known issue with Apple and its included python package dependencies (more info at https://github.com/pypa/pip/issues/3165). We are investigating ways to fix this issue but in the meantime, to install haxor-news, you can run: 53 | 54 | $ sudo pip install haxor-news --upgrade --ignore-installed six 55 | 56 | ## Nix/NixOS installation 57 | 58 | Nix is a package manager default to the NixOS distribution, but it can also be used on any Linux distribution. In order to install `haxor-news` with it run: 59 | 60 | $ nix-env -i haxor-news 61 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | I am providing code and resources in this repository to you under an open source 2 | license. Because this is my personal repository, the license you receive to my 3 | code and resources is from me and not my employer (Facebook). 4 | 5 | Copyright 2015 Donne Martin. All Rights Reserved. 6 | 7 | Licensed under the Apache License, Version 2.0 (the "License"). You 8 | may not use this file except in compliance with the License. A copy of 9 | the License is located at 10 | 11 | http://www.apache.org/licenses/LICENSE-2.0 12 | 13 | or in the "license" file accompanying this file. This file is 14 | distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF 15 | ANY KIND, either express or implied. See the License for the specific 16 | language governing permissions and limitations under the License. 17 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Imgur](http://i.imgur.com/C4mkc3L.gif) 2 | 3 | [![Build Status](https://travis-ci.org/donnemartin/haxor-news.svg?branch=master)](https://travis-ci.org/donnemartin/haxor-news) 4 | 5 | [![PyPI version](https://badge.fury.io/py/haxor-news.svg)](http://badge.fury.io/py/haxor-news) [![PyPI](https://img.shields.io/pypi/pyversions/haxor-news.svg)](https://pypi.python.org/pypi/haxor-news/) [![License](http://img.shields.io/:license-apache-blue.svg)](http://www.apache.org/licenses/LICENSE-2.0.html) 6 | 7 | haxor-news 8 | ================= 9 | 10 | > *Coworker who sees me looking at something in a browser: "Glad you're not busy; I need you to do this, this, this..."* 11 | > 12 | > *Coworker who sees me staring intently at a command prompt: Backs away, slowly...* 13 | > 14 | > *-[Source](https://www.reddit.com/user/foxingworth)* 15 | 16 | Check out the `haxor-news` discussion in this [Hacker News post](https://news.ycombinator.com/item?id=11518596). 17 | 18 | `haxor-news` brings Hacker News to the terminal, allowing you to **view**/**filter** the following without leaving your command line: 19 | 20 | * Posts 21 | * Post Comments 22 | * Post Linked Web Content 23 | * Monthly Hiring and Freelancers Posts 24 | * User Info 25 | * Onions 26 | 27 | `haxor-news` helps you **filter the large number of comments that popular posts generate**. 28 | 29 | * Want to expand only previously **unseen comments**? 30 | * `-cu/--comments_unseen` 31 | * How about **recent comments** posted in the past 60 minutes? 32 | * `-cr/--comments_recent` 33 | * Filter comments matching a **regex query**? 34 | * `-cq/--comments_query [query]` 35 | 36 | ![Imgur](http://i.imgur.com/4psj3nE.png) 37 | 38 | Job hunting or just curious what's out there? **Filter the monthly who's hiring and freelancers post**: 39 | 40 | $ hn hiring "(?i)(Node|JavaScript).*(remote)" > remote_web_jobs.txt 41 | 42 | Combine `haxor-news` with pipes, redirects, and other command line utilities. Output to pagers, write to files, automate with cron, etc. 43 | 44 | `haxor-news` comes with a handy **optional auto-completer with interactive help**: 45 | 46 | ![Imgur](http://i.imgur.com/seKUiur.png) 47 | 48 | ## Index 49 | 50 | ### General 51 | 52 | * [Syntax](#syntax) 53 | * [Auto-Completer and Interactive Help](#auto-completer-and-interactive-help) 54 | * [Customizable Highlighting](#customizable-highlighting) 55 | * [Commands](#commands) 56 | 57 | ### Features 58 | 59 | * [View Posts](#view-posts) 60 | * [View a Post's Linked Web Content](#view-a-posts-linked-web-content) 61 | * [View and Filter a Post's Comments](#view-and-filter-a-posts-comments) 62 | * [View All Comments](#view-all-comments) 63 | * [Filter on Unseen Comments](#filter-on-unseen-comments) 64 | * [Filter on Recent Comments](#filter-on-recent-comments) 65 | * [Filter with Regex](#filter-with-regex) 66 | * [Hide Non-Matching Comments](#hide-non-matching-comments) 67 | * [View and Filter the Monthly Hiring Post](#filter-the-monthly-hiring-post) 68 | * [View and Filter the Monthly Freelancer Post](#filter-the-monthly-hiring-post) 69 | * [Combine With Pipes and Redirects](#combine-with-pipes-and-redirects) 70 | * [View User Info](#view-user-info) 71 | * [View Onions](#view-onions) 72 | * [View Results in a Browser](#view-in-a-browser) 73 | * [Windows Support](#windows-support) 74 | 75 | ### Installation and Tests 76 | 77 | * [Installation](#installation) 78 | * [Pip Installation](#pip-installation) 79 | * [Virtual Environment Installation](#virtual-environment-installation) 80 | * [Supported Python Versions](#supported-python-versions) 81 | * [Supported Platforms](#supported-platforms) 82 | * [Developer Installation](#developer-installation) 83 | * [Continuous Integration](#continuous-integration) 84 | * [Unit Tests and Code Coverage](#unit-tests-and-code-coverage) 85 | * [Documentation](#documentation) 86 | 87 | ### Misc 88 | 89 | * [Contributing](#contributing) 90 | * [Credits](#credits) 91 | * [Contact Info](#contact-info) 92 | * [License](#license) 93 | 94 | ## Syntax 95 | 96 | Usage: 97 | 98 | $ hn [params] [options] 99 | 100 | ## Auto-Completer and Interactive Help 101 | 102 | Optionally, you can enable fish-style completions and an auto-completion menu with interactive help: 103 | 104 | $ haxor-news 105 | 106 | If available, the auto-completer also automatically displays comments through a pager. 107 | 108 | Within the auto-completer, the same syntax applies: 109 | 110 | haxor> hn [params] [options] 111 | 112 | ![Imgur](http://i.imgur.com/L2rzgb3.png) 113 | 114 | ![Imgur](http://i.imgur.com/FL2pyC0.png) 115 | 116 | ## Customizable Highlighting 117 | 118 | You can control the ansi colors used for highlighting by updating your `~/.haxornewsconfig` file. 119 | 120 | Color options include: 121 | 122 | ``` 123 | 'black', 'red', 'green', 'yellow', 124 | 'blue', 'magenta', 'cyan', 'white' 125 | ``` 126 | 127 | For no color, set the value(s) to `None`. 128 | 129 | ![Imgur](http://i.imgur.com/lzoRxfW.png) 130 | 131 | ## Commands 132 | 133 | ![Imgur](http://i.imgur.com/oqvUbj8.png) 134 | 135 | ## View Posts 136 | 137 | View the Top, Best, Show, Ask, Jobs, New, and Onion posts. 138 | 139 | Usage: 140 | 141 | $ hn [command] [limit] # post limit default: 10 142 | 143 | Examples: 144 | 145 | $ hn top 146 | $ hn show 20 147 | 148 | ![Imgur](http://i.imgur.com/tjGPszv.png) 149 | 150 | ## View a Post's Linked Web Content 151 | 152 | After viewing a list of posts, you can view a post's linked web content by referencing the post `#`. 153 | 154 | The HTML contents of the post's link are **formatted for easy-viewing within your terminal**. If available, the formatted output is sent to a pager. 155 | 156 | See the [View in a Browser](#view-in-a-browser) section to view the contents in a browser instead. 157 | 158 | Usage: 159 | 160 | $ hn view [#] 161 | 162 | Example: 163 | 164 | $ hn view 1 165 | $ hn view 8 166 | 167 | ![Imgur](http://i.imgur.com/FoTCPAp.png) 168 | 169 | ## View and Filter a Post's Comments 170 | 171 | ### View All Comments 172 | 173 | After viewing a list of posts, you can view a post's comments by referencing the post `#`. 174 | 175 | Examples: 176 | 177 | $ hn view 8 -c 178 | $ hn view 8 --comments > comments.txt 179 | 180 | #### Paged Comments 181 | 182 | If running with the auto-completer, comments are automatically paginated. To get the same pagination without the auto-completer, append `| less -r`: 183 | 184 | $ hn view 8 -c | less -r 185 | 186 | ![Imgur](http://i.imgur.com/t32ITvN.png) 187 | 188 | ### Filter on Unseen Comments 189 | 190 | Filter comments to expand only those you have not yet seen. Unseen comments are denoted with a `[!]` and are fully expanded. 191 | 192 | Seen comments will be truncated with [...] and will be shown to help provide context to unseen comments. 193 | 194 | Examples: 195 | 196 | $ hn view 8 -cu 197 | $ hn view 8 --comments_unseen | less -r 198 | 199 | ![Imgur](http://i.imgur.com/tCVpSIs.png) 200 | 201 | ### Filter on Recent Comments 202 | 203 | Filter comments to expand only those **posted within the past 60 minutes**. 204 | 205 | Older comments will be truncated with [...] and will be shown to help provide context to recent comments. 206 | 207 | Examples: 208 | 209 | $ hn view 8 -cr | less -r 210 | $ hn view 8 --comments_recent 211 | 212 | ![Imgur](http://i.imgur.com/diOjxIr.png) 213 | 214 | ### Filter with Regex 215 | 216 | Filter comments based on a given regular expression query. 217 | 218 | Examples: 219 | 220 | $ hn view 2 -cq "(?i)programmer" | less -r 221 | $ hn view 2 --comments_regex_query "(?i)programmer" > programmer.txt 222 | 223 | *Case insensitive regex: `(?i)`* 224 | 225 | ![Imgur](http://i.imgur.com/SlKtIpS.png) 226 | 227 | ### Hide Non-Matching Comments 228 | 229 | When filtering comments for unseen, recent, or with regex, non-matching comments are collapsed to provide context. To instead hide non-matching comments, pass the `-ch\--comments_hide` flag. Hidden comments will be displayed as `.`. 230 | 231 | Example: 232 | 233 | $ hn view 8 -cu -ch | less -r 234 | 235 | ![Imgur](http://i.imgur.com/qPopK7X.png) 236 | 237 | ## Filter the Monthly Hiring Post 238 | 239 | Hacker News hosts a monthly hiring post where employers post the latest job openings. 240 | 241 | Usage: 242 | 243 | $ hn hiring [regex filter] 244 | 245 | Examples: 246 | 247 | $ hn hiring "" 248 | $ hn hiring "(?i)JavaScript|Node" 249 | $ hn hiring "(?i)(Node|JavaScript).*(remote)" > remote_jobs.txt 250 | 251 | *Case insensitive regex: `(?i)`* 252 | 253 | ![Imgur](http://i.imgur.com/Lwz8iwG.png) 254 | 255 | To search a different monthly hiring post other than the latest, use the hiring post id. 256 | 257 | Usage: 258 | 259 | $ hn hiring [regex filter] [post id] 260 | 261 | ## Filter the Freelancers Post 262 | 263 | Hacker News hosts a monthly freelancers post where employers and freelancers post availabilities. 264 | 265 | Usage: 266 | 267 | $ hn freelance [regex filter] 268 | 269 | Examples: 270 | 271 | $ hn freelance "" 272 | $ hn freelance "(?i)JavaScript|Node" 273 | $ hn freelance "(?i)(Node|JavaScript).*(remote)" > remote_jobs.txt 274 | 275 | *Case insensitive regex: `(?i)`* 276 | 277 | ![Imgur](http://i.imgur.com/TnBDFGk.png) 278 | 279 | To search a different monthly hiring post other than the latest, use the hiring post id. 280 | 281 | Usage: 282 | 283 | $ hn freelance [regex filter] [post id] 284 | 285 | ## Combine With Pipes and Redirects 286 | 287 | Output to pagers, write to files, automate with cron, etc. 288 | 289 | Examples: 290 | 291 | $ hn view 1 -c | less 292 | $ hn freelance "(?i)(Node|JavaScript).*(remote)" > remote_jobs.txt 293 | 294 | ![Imgur](http://i.imgur.com/x2aj7SM.png) 295 | 296 | ## View User Info 297 | 298 | Usage: 299 | 300 | $ hn user [user id] 301 | 302 | ![Imgur](http://i.imgur.com/oTALQQI.png) 303 | 304 | ## View Onions 305 | 306 | Usage: 307 | 308 | $ hn onion [limit] # post limit default: all 309 | 310 | ![Imgur](http://i.imgur.com/MubWRNG.png) 311 | 312 | ## View in a Browser 313 | 314 | View the linked web content or comments in your default browser instead of your terminal. 315 | 316 | Usage: 317 | 318 | $ hn [params] [options] -b 319 | $ hn [params] [options] --browser 320 | 321 | ## Windows Support 322 | 323 | `haxor-news` has been tested on Windows 10. 324 | 325 | ### Pager Support 326 | 327 | Pager support on Windows is more limited as discussed in the following [ticket](https://github.com/donnemartin/haxor-news/issues/16). Users can direct output to a pager with the `| more` command: 328 | 329 | $ hn view 1 -c | more 330 | 331 | ### Config File 332 | 333 | On Windows, the `.haxornewsconfig` file can be found in `%userprofile%`. For example: 334 | 335 | C:\Users\dmartin\.haxornewsconfig 336 | 337 | ### `cmder` and `conemu` 338 | 339 | Although you can use the standard Windows command prompt, you'll probably have a better experience with either [cmder](https://github.com/cmderdev/cmder) or [conemu](https://github.com/Maximus5/ConEmu). 340 | 341 | ## Installation 342 | 343 | ### Pip Installation 344 | 345 | [![PyPI version](https://badge.fury.io/py/haxor-news.svg)](http://badge.fury.io/py/haxor-news) [![PyPI](https://img.shields.io/pypi/pyversions/haxor-news.svg)](https://pypi.python.org/pypi/haxor-news/) 346 | 347 | `haxor-news` is hosted on [PyPI](https://pypi.python.org/pypi/haxor-news). The following command will install `haxor-news`: 348 | 349 | $ pip install haxor-news 350 | 351 | You can also install the latest `haxor-news` from GitHub source which can contain changes not yet pushed to PyPI: 352 | 353 | $ pip install git+https://github.com/donnemartin/haxor-news.git 354 | 355 | If you are not installing in a virtualenv, run with `sudo`: 356 | 357 | $ sudo pip install haxor-news 358 | 359 | Once installed, run the optional `haxor-news` auto-completer with interactive help: 360 | 361 | $ haxor-news 362 | 363 | Run commands: 364 | 365 | $ hn [params] [options] 366 | 367 | ### Virtual Environment Installation 368 | 369 | It is recommended that you install Python packages in a [virtualenv](http://docs.python-guide.org/en/latest/dev/virtualenvs/) to avoid potential issues with dependencies or permissions. 370 | 371 | To view `haxor-news` `virtualenv` installation instructions, click [here](https://github.com/donnemartin/haxor-news/blob/master/INSTALLATION.md). 372 | 373 | ### Mac OS X 10.11 El Capitan Users 374 | 375 | There is a known issue with Apple and its included python package dependencies (more info at https://github.com/pypa/pip/issues/3165). We are investigating ways to fix this issue but in the meantime, to install haxor-news, you can run: 376 | 377 | $ sudo pip install haxor-news --upgrade --ignore-installed six 378 | 379 | ### Supported Python Versions 380 | 381 | * Python 2.6 382 | * Python 2.7 383 | * Python 3.3 384 | * Python 3.4 385 | * Python 3.5 386 | * Python 3.6 387 | * Python 3.7 388 | 389 | ### Supported Platforms 390 | 391 | * Mac OS X 392 | * Tested on OS X 10.10 393 | * Linux, Unix 394 | * Tested on Ubuntu 14.04 LTS 395 | * Windows 396 | * Tested on Windows 10 397 | 398 | ## Developer Installation 399 | 400 | If you're interested in contributing to `haxor-news`, run the following commands: 401 | 402 | $ git clone https://github.com/donnemartin/haxor-news.git 403 | $ pip install -e . 404 | $ pip install -r requirements-dev.txt 405 | $ haxor-news 406 | $ hn [params] [options] 407 | 408 | ### Continuous Integration 409 | 410 | [![Build Status](https://travis-ci.org/donnemartin/haxor-news.svg?branch=master)](https://travis-ci.org/donnemartin/haxor-news) 411 | 412 | Continuous integration details are available on [Travis CI](https://travis-ci.org/donnemartin/haxor-news). 413 | 414 | ### Unit Tests and Code Coverage 415 | 416 | Run unit tests in your active Python environment: 417 | 418 | $ python tests/run_tests.py 419 | 420 | Run unit tests with [tox](https://pypi.python.org/pypi/tox) on multiple Python environments: 421 | 422 | $ tox 423 | 424 | ### Documentation 425 | 426 | Source code documentation will soon be available on [Readthedocs.org](https://readthedocs.org/). Check out the [source docstrings](https://github.com/donnemartin/haxor-news/blob/master/haxor_news/hacker_news_cli.py). 427 | 428 | Run the following to build the docs: 429 | 430 | $ scripts/update_docs.sh 431 | 432 | ## Contributing 433 | 434 | Contributions are welcome! 435 | 436 | Review the [Contributing Guidelines](https://github.com/donnemartin/haxor-news/blob/master/CONTRIBUTING.md) for details on how to: 437 | 438 | * Submit issues 439 | * Submit pull requests 440 | 441 | ## Credits 442 | 443 | * [click](https://github.com/pallets/click) by [mitsuhiko](https://github.com/mitsuhiko) 444 | * [haxor](https://github.com/avinassh/haxor) by [avinassh](https://github.com/avinassh) 445 | * [html2text](https://github.com/aaronsw/html2text) by [aaronsw](https://github.com/aaronsw) 446 | * [python-prompt-toolkit](https://github.com/jonathanslenders/python-prompt-toolkit) by [jonathanslenders](https://github.com/jonathanslenders) 447 | * [requests](https://github.com/kennethreitz/requests) by [kennethreitz](https://github.com/kennethreitz) 448 | 449 | ## Contact Info 450 | 451 | Feel free to contact me to discuss any issues, questions, or comments. 452 | 453 | My contact info can be found on my [GitHub page](https://github.com/donnemartin). 454 | 455 | ## License 456 | 457 | *I am providing code and resources in this repository to you under an open source license. Because this is my personal repository, the license you receive to my code and resources is from me and not my employer (Facebook).* 458 | 459 | [![License](http://img.shields.io/:license-apache-blue.svg)](http://www.apache.org/licenses/LICENSE-2.0.html) 460 | 461 | Copyright 2015 Donne Martin 462 | 463 | Licensed under the Apache License, Version 2.0 (the "License"); 464 | you may not use this file except in compliance with the License. 465 | You may obtain a copy of the License at 466 | 467 | http://www.apache.org/licenses/LICENSE-2.0 468 | 469 | Unless required by applicable law or agreed to in writing, software 470 | distributed under the License is distributed on an "AS IS" BASIS, 471 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 472 | See the License for the specific language governing permissions and 473 | limitations under the License. 474 | -------------------------------------------------------------------------------- /appveyor.yml: -------------------------------------------------------------------------------- 1 | # What Python version is installed where: 2 | # http://www.appveyor.com/docs/installed-software#python 3 | 4 | environment: 5 | matrix: 6 | - PYTHON: "C:\\Python27" 7 | TOX_ENV: "py27" 8 | 9 | - PYTHON: "C:\\Python33" 10 | TOX_ENV: "py33" 11 | 12 | - PYTHON: "C:\\Python34" 13 | TOX_ENV: "py34" 14 | 15 | - PYTHON: "C:\\Python35" 16 | TOX_ENV: "py35" 17 | 18 | 19 | init: 20 | - "%PYTHON%/python -V" 21 | - "%PYTHON%/python -c \"import struct;print( 8 * struct.calcsize(\'P\'))\"" 22 | 23 | install: 24 | - "%PYTHON%/Scripts/easy_install -U pip" 25 | - "%PYTHON%/Scripts/pip install tox" 26 | - "%PYTHON%/Scripts/pip install wheel" 27 | 28 | build: false # Not a C# project, build stuff at the test step instead. 29 | 30 | test_script: 31 | - "%PYTHON%/Scripts/tox -e %TOX_ENV%" 32 | 33 | after_test: 34 | - "%PYTHON%/python setup.py bdist_wheel" 35 | - ps: "ls dist" 36 | 37 | artifacts: 38 | - path: dist\* 39 | 40 | #on_success: 41 | # - TODO: upload the content of dist/*.whl to a public wheelhouse -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | comment: 2 | layout: header, changes, diff 3 | 4 | coverage: 5 | ignore: 6 | - haxor_news/lib/* 7 | - haxor_news/compat.py 8 | -------------------------------------------------------------------------------- /haxor_news/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # Copyright 2015 Donne Martin. All Rights Reserved. 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"). You 6 | # may not use this file except in compliance with the License. A copy of 7 | # the License is located at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # or in the "license" file accompanying this file. This file is 12 | # distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF 13 | # ANY KIND, either express or implied. See the License for the specific 14 | # language governing permissions and limitations under the License. 15 | 16 | __version__ = '0.4.2' 17 | -------------------------------------------------------------------------------- /haxor_news/compat.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # Copyright 2015 Donne Martin. All Rights Reserved. 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"). You 6 | # may not use this file except in compliance with the License. A copy of 7 | # the License is located at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # or in the "license" file accompanying this file. This file is 12 | # distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF 13 | # ANY KIND, either express or implied. See the License for the specific 14 | # language governing permissions and limitations under the License. 15 | 16 | import sys 17 | import urllib 18 | try: 19 | # Python 3 20 | import configparser 21 | from urllib.parse import urlparse 22 | from urllib.request import urlretrieve 23 | from urllib.error import URLError 24 | except ImportError: 25 | # Python 2 26 | import ConfigParser as configparser 27 | from urlparse import urlparse 28 | from urllib import urlretrieve 29 | from urllib2 import URLError 30 | if sys.version_info < (3, 3): 31 | import HTMLParser 32 | else: 33 | import html as HTMLParser 34 | -------------------------------------------------------------------------------- /haxor_news/completer.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # Copyright 2015 Donne Martin. All Rights Reserved. 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"). You 6 | # may not use this file except in compliance with the License. A copy of 7 | # the License is located at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # or in the "license" file accompanying this file. This file is 12 | # distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF 13 | # ANY KIND, either express or implied. See the License for the specific 14 | # language governing permissions and limitations under the License. 15 | 16 | from __future__ import unicode_literals 17 | from __future__ import print_function 18 | 19 | from prompt_toolkit.completion import Completer 20 | 21 | from .completions import SUBCOMMANDS, ARGS_OPTS_LOOKUP 22 | 23 | 24 | class Completer(Completer): 25 | """Completer for haxor-news. 26 | 27 | :type text_utils: :class:`utils.TextUtils` 28 | :param text_utils: An instance of `utils.TextUtils`. 29 | 30 | :type fuzzy_match: bool 31 | :param fuzzy_match: Determines whether to use fuzzy matching. 32 | """ 33 | 34 | def __init__(self, fuzzy_match, text_utils): 35 | self.fuzzy_match = fuzzy_match 36 | self.text_utils = text_utils 37 | 38 | def completing_command(self, words, word_before_cursor): 39 | """Determine if we are currently completing the hn command. 40 | 41 | :type words: list 42 | :param words: The input text broken into word tokens. 43 | 44 | :type word_before_cursor: str 45 | :param word_before_cursor: The current word before the cursor, 46 | which might be one or more blank spaces. 47 | 48 | :rtype: bool 49 | :return: Specifies whether we are currently completing the hn command. 50 | """ 51 | if len(words) == 1 and word_before_cursor != '': 52 | return True 53 | else: 54 | return False 55 | 56 | def completing_subcommand(self, words, word_before_cursor): 57 | """Determine if we are currently completing a subcommand. 58 | 59 | :type words: list 60 | :param words: The input text broken into word tokens. 61 | 62 | :type word_before_cursor: str 63 | :param word_before_cursor: The current word before the cursor, 64 | which might be one or more blank spaces. 65 | 66 | :rtype: bool 67 | :return: Specifies whether we are currently completing a subcommand. 68 | """ 69 | if (len(words) == 1 and word_before_cursor == '') \ 70 | or (len(words) == 2 and word_before_cursor != ''): 71 | return True 72 | else: 73 | return False 74 | 75 | def completing_arg(self, words, word_before_cursor): 76 | """Determine if we are currently completing an arg. 77 | 78 | :type words: list 79 | :param words: The input text broken into word tokens. 80 | 81 | :type word_before_cursor: str 82 | :param word_before_cursor: The current word before the cursor, 83 | which might be one or more blank spaces. 84 | 85 | :rtype: bool 86 | :return: Specifies whether we are currently completing an arg. 87 | """ 88 | if (len(words) == 2 and word_before_cursor == '') \ 89 | or (len(words) == 3 and word_before_cursor != ''): 90 | return True 91 | else: 92 | return False 93 | 94 | def completing_subcommand_option(self, words, word_before_cursor): 95 | """Determine if we are currently completing an option. 96 | 97 | :type words: list 98 | :param words: The input text broken into word tokens. 99 | 100 | :type word_before_cursor: str 101 | :param word_before_cursor: The current word before the cursor, 102 | which might be one or more blank spaces. 103 | 104 | :rtype: list 105 | :return: A list of options. 106 | """ 107 | options = [] 108 | for subcommand, args_opts in ARGS_OPTS_LOOKUP.items(): 109 | if subcommand in words and \ 110 | (words[-2] == subcommand or 111 | self.completing_subcommand_option_util(subcommand, words)): 112 | options.extend(ARGS_OPTS_LOOKUP[subcommand]['opts']) 113 | return options 114 | 115 | def completing_subcommand_option_util(self, option, words): 116 | """Determine if we are currently completing an option. 117 | 118 | Called by completing_subcommand_option as a utility method. 119 | 120 | :type option: str 121 | :param option: The subcommand in the elements of ARGS_OPTS_LOOKUP. 122 | 123 | :type words: list 124 | :param words: The input text broken into word tokens. 125 | 126 | :rtype: bool 127 | :return: Specifies whether we are currently completing an option. 128 | """ 129 | # Example: Return True for: hn view 0 --comm 130 | if len(words) > 3: 131 | if option in words: 132 | return True 133 | return False 134 | 135 | def arg_completions(self, words, word_before_cursor): 136 | """Generates arguments completions based on the input. 137 | 138 | :type words: list 139 | :param words: The input text broken into word tokens. 140 | 141 | :type word_before_cursor: str 142 | :param word_before_cursor: The current word before the cursor, 143 | which might be one or more blank spaces. 144 | 145 | :rtype: list 146 | :return: A list of completions. 147 | """ 148 | if 'hn' not in words: 149 | return [] 150 | for subcommand, args_opts in ARGS_OPTS_LOOKUP.items(): 151 | if subcommand in words: 152 | return [ARGS_OPTS_LOOKUP[subcommand]['args']] 153 | return ['10'] 154 | 155 | def get_completions(self, document, _): 156 | """Get completions for the current scope. 157 | 158 | :type document: :class:`prompt_toolkit.Document` 159 | :param document: An instance of `prompt_toolkit.Document`. 160 | 161 | :type _: :class:`prompt_toolkit.completion.Completion` 162 | :param _: (Unused). 163 | 164 | :rtype: generator 165 | :return: Yields an instance of `prompt_toolkit.completion.Completion`. 166 | """ 167 | word_before_cursor = document.get_word_before_cursor(WORD=True) 168 | words = self.text_utils.get_tokens(document.text) 169 | commands = [] 170 | if len(words) == 0: 171 | return commands 172 | if self.completing_command(words, word_before_cursor): 173 | commands = ['hn'] 174 | else: 175 | if 'hn' not in words: 176 | return commands 177 | if self.completing_subcommand(words, word_before_cursor): 178 | commands = list(SUBCOMMANDS.keys()) 179 | else: 180 | if self.completing_arg(words, word_before_cursor): 181 | commands = self.arg_completions(words, word_before_cursor) 182 | else: 183 | commands = self.completing_subcommand_option( 184 | words, 185 | word_before_cursor) 186 | completions = self.text_utils.find_matches( 187 | word_before_cursor, commands, fuzzy=self.fuzzy_match) 188 | return completions 189 | -------------------------------------------------------------------------------- /haxor_news/completions.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # Copyright 2015 Donne Martin. All Rights Reserved. 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"). You 6 | # may not use this file except in compliance with the License. A copy of 7 | # the License is located at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # or in the "license" file accompanying this file. This file is 12 | # distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF 13 | # ANY KIND, either express or implied. See the License for the specific 14 | # language governing permissions and limitations under the License. 15 | 16 | from .settings import freelancer_post_id, who_is_hiring_post_id 17 | 18 | 19 | FREELANCER_POST_ID = str(freelancer_post_id) 20 | WHO_IS_HIRING_POST_ID = str(who_is_hiring_post_id) 21 | SUBCOMMANDS = { 22 | 'ask': 'Ask HN posts', 23 | 'best': 'Best of HN weekly posts', 24 | 'freelance': "Monthly freelancers post", 25 | 'hiring': "Monthly hiring post", 26 | 'jobs': 'Jobs posts', 27 | 'new': 'Newest posts', 28 | 'onion': 'Onion posts', 29 | 'show': 'Show HN posts', 30 | 'top': 'Top posts', 31 | 'user': 'User info', 32 | 'view': 'View specified post', 33 | } 34 | ARGS_OPTS_LOOKUP = { 35 | 'freelance': { 36 | 'args': '"(?i)(Python|Django)"', 37 | 'opts': [ 38 | '--id_post ' + FREELANCER_POST_ID, 39 | '-i ' + FREELANCER_POST_ID, 40 | ], 41 | }, 42 | 'hiring': { 43 | 'args': '"(?i)(Python|Django)"', 44 | 'opts': [ 45 | '--id_post ' + WHO_IS_HIRING_POST_ID, 46 | '-i ' + WHO_IS_HIRING_POST_ID, 47 | ], 48 | }, 49 | 'user': { 50 | 'args': '"user"', 51 | 'opts': [ 52 | '--limit 10', 53 | '-l 10', 54 | ], 55 | }, 56 | 'view': { 57 | 'args': '1', 58 | 'opts': [ 59 | '--comments_regex_query ""', 60 | '-cq ""', 61 | '--comments', 62 | '-c', 63 | '--comments_recent', 64 | '-cr', 65 | '--comments_unseen', 66 | '-cu', 67 | '--comments_hide_non_matching', 68 | '-ch', 69 | '--clear_cache', 70 | '-cc', 71 | '--browser', 72 | '-b', 73 | ], 74 | }, 75 | } 76 | META_LOOKUP = { 77 | '10': 'limit: int (opt) limits the posts displayed', 78 | '"(?i)(Python|Django)"': ('regex_query: string (opt) applies a regular ' 79 | 'expression comment filter'), 80 | '1': 'index: int (req) views the post index', 81 | '"user"': 'user:string (req) shows info on the specified user', 82 | '--comments_regex_query ""': ('Filter comments with a regular expression' 83 | ' query (string)'), 84 | '-cq ""': ('Filter comments with a regular expression' 85 | ' query (string)'), 86 | '--comments': 'View comments instead of the url contents (flag)', 87 | '-c': 'View comments instead of the url contents (flag)', 88 | '--comments_recent': 'View only comments in the past hour (flag)', 89 | '-cr': 'View only comments in the past hour (flag)', 90 | '--comments_unseen': 'View only previously unseen comments (flag)', 91 | '-cu': 'View only previously unseen comments (flag)', 92 | '--comments_hide_non_matching': ('Hide instead of collapse ' 93 | 'non-matching comments (flag)'), 94 | '-ch': 'Hide instead of collapse non-matching comments (flag)', 95 | '--clear_cache': 'Clear the comment cache before executing.', 96 | '-cc': 'Clear the comment cache before executing.', 97 | '--browser': 'View in a browser instead of the terminal (flag)', 98 | '-b': 'View in a browser instead of the terminal (flag)', 99 | '--id_post ' + WHO_IS_HIRING_POST_ID: ('View matching comments from ' 100 | 'the (optional) post id instead' 101 | ' of the latest post (int)'), 102 | '-i ' + WHO_IS_HIRING_POST_ID: ('View matching comments from ' 103 | 'the (optional) post id instead' 104 | ' of the latest post (int)'), 105 | '--limit 10': 'Limits the number of user submissions displayed (int)', 106 | '-l 10': 'Limits the number of user submissions displayed (int)', 107 | } 108 | META_LOOKUP.update(SUBCOMMANDS) 109 | -------------------------------------------------------------------------------- /haxor_news/config.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # Copyright 2015 Donne Martin. All Rights Reserved. 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"). You 6 | # may not use this file except in compliance with the License. A copy of 7 | # the License is located at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # or in the "license" file accompanying this file. This file is 12 | # distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF 13 | # ANY KIND, either express or implied. See the License for the specific 14 | # language governing permissions and limitations under the License. 15 | 16 | from __future__ import print_function 17 | from __future__ import division 18 | 19 | import os 20 | 21 | import click 22 | from .compat import configparser 23 | from .compat import URLError 24 | from .compat import urlretrieve 25 | from .settings import freelancer_post_id, who_is_hiring_post_id 26 | 27 | 28 | class Config(object): 29 | """Hacker News config. 30 | 31 | :type clr_x: str 32 | :param clr_x: Various ansi color config colors to use for highlights. 33 | 34 | :type CONFIG: str 35 | :param CONFIG: The config file name. 36 | 37 | :type CONFIG_SECTION: str 38 | :param CONFIG_SECTION: The main config file section label. 39 | 40 | :type CONFIG_CLR_X: str 41 | :param CONFIG_CLR_X: Various ansi color config labels to use for highlights. 42 | 43 | :type CONFIG_IDS: str 44 | :param CONFIG_IDS: The last list of seen post ids config label. 45 | 46 | :type CONFIG_CACHE: str 47 | :param CONFIG_CACHE: The list of seen comments config label. 48 | 49 | :type CONFIG_HIRING_ID: str 50 | :param CONFIG_HIRING_ID: The monthly freelancer post id config label. 51 | 52 | :type CONFIG_FREELANCE_ID: str 53 | :param CONFIG_FREELANCE_ID: The monthly who's hiring post id config label. 54 | 55 | :type CONFIG_SHOW_TIP: bool 56 | :param CONFIG_SHOW_TIP: determines whether to show the tip. 57 | 58 | :type freelance_id: int 59 | :param freelance_id: The monthly freelancer hiring post id. 60 | 61 | :type hiring_id: int 62 | :param hiring_id: The monthly who's hiring post id. 63 | 64 | :type item_cache: list 65 | :param item_cache: A list of seen comment ids. 66 | TODO: Look into an OrderedSet for improved lookup performance 67 | http://code.activestate.com/recipes/576694/ 68 | 69 | :type item_ids: list 70 | :param item_ids: The last set of ids the user has seen, 71 | which allows the user to quickly access an item with the 72 | gh view [#] [-u/--url] command. 73 | 74 | :type MAX_ITEM_CACHE_SIZE: int 75 | :param MAX_ITEM_CACHE_SIZE: The maximum size of seen comment ids cache. 76 | """ 77 | 78 | CONFIG = '.haxornewsconfig' 79 | CONFIG_CLR_BOLD = 'clr_bold' 80 | CONFIG_CLR_CODE = 'clr_code' 81 | CONFIG_CLR_GENERAL = 'clr_general' 82 | CONFIG_CLR_HEADER = 'clr_header' 83 | CONFIG_CLR_LINK = 'clr_link' 84 | CONFIG_CLR_LIST = 'clr_list' 85 | CONFIG_CLR_NUM_COMMENTS = 'clr_num_comments' 86 | CONFIG_CLR_NUM_POINTS = 'clr_num_points' 87 | CONFIG_CLR_TAG = 'clr_tag' 88 | CONFIG_CLR_TIME = 'clr_time' 89 | CONFIG_CLR_TITLE = 'clr_title' 90 | CONFIG_CLR_TOOLTIP = 'clr_tooltip' 91 | CONFIG_CLR_USER = 'clr_user' 92 | CONFIG_CLR_VIEW_LINK = 'clr_view_link' 93 | CONFIG_CLR_VIEW_INDEX = 'clr_view_index' 94 | CONFIG_SECTION = 'haxor-news' 95 | CONFIG_IDS = 'item_ids' 96 | CONFIG_CACHE = 'item_cache' 97 | CONFIG_HIRING_ID = 'hiring_id' 98 | CONFIG_FREELANCE_ID = 'freelance_id' 99 | CONFIG_SHOW_TIP = 'show_tip' 100 | MAX_ITEM_CACHE_SIZE = 20000 101 | 102 | def __init__(self): 103 | self.item_ids = [] 104 | self.item_cache = [] 105 | self.hiring_id = 0 106 | self.freelance_id = 0 107 | self.show_tip = True 108 | self._init_colors() 109 | self.load_config([ 110 | self.load_config_item_ids, 111 | self.load_config_item_cache, 112 | self.load_config_colors, 113 | self.load_config_show_tip, 114 | ]) 115 | 116 | def _init_colors(self): 117 | """Initialize colors to their defaults.""" 118 | self.clr_bold = 'cyan' 119 | self.clr_code = 'cyan' 120 | self.clr_general = None 121 | self.clr_header = 'yellow' 122 | self.clr_link = 'green' 123 | self.clr_list = 'cyan' 124 | self.clr_num_comments = 'green' 125 | self.clr_num_points = 'green' 126 | self.clr_tag = 'cyan' 127 | self.clr_time = 'yellow' 128 | self.clr_title = None 129 | self.clr_tooltip = None 130 | self.clr_user = 'cyan' 131 | self.clr_view_link = 'magenta' 132 | self.clr_view_index = 'magenta' 133 | 134 | def clear_item_cache(self): 135 | """Clear the item cache.""" 136 | self.item_cache = [] 137 | self.save_cache() 138 | 139 | def get_config_path(self, config_file_name): 140 | """Get the config file path. 141 | 142 | :type config_file_name: str 143 | :param config_file_name: The config file name. 144 | 145 | :rtype: str 146 | :return: The config file path. 147 | """ 148 | home = os.path.abspath(os.environ.get('HOME', '')) 149 | config_file_path = os.path.join(home, config_file_name) 150 | return config_file_path 151 | 152 | def load_config(self, config_funcs): 153 | """Load the specified config from ~/.haxornewsconfig. 154 | 155 | :type config_funcs: list 156 | :param config_funcs: The config functions to run. 157 | """ 158 | config_file_path = self.get_config_path(self.CONFIG) 159 | parser = configparser.RawConfigParser() 160 | try: 161 | with open(config_file_path) as config_file: 162 | try: 163 | parser.read_file(config_file) 164 | except AttributeError: 165 | parser.readfp(config_file) 166 | for config_func in config_funcs: 167 | config_func(parser) 168 | except IOError: 169 | # There might not be a cache yet, just silently return. 170 | return None 171 | 172 | def load_config_colors(self, parser): 173 | """Load the color config from ~/.haxornewsconfig. 174 | 175 | :type parser: :class:`ConfigParser.RawConfigParser` 176 | :param parser: An instance of `ConfigParser.RawConfigParser`. 177 | """ 178 | self.load_colors(parser) 179 | 180 | def load_config_hiring_and_freelance_ids(self, parser): 181 | """Load the hiring and freelance ids from ~/.haxornewsconfig. 182 | 183 | :type parser: :class:`ConfigParser.RawConfigParser` 184 | :param parser: An instance of `ConfigParser.RawConfigParser`. 185 | """ 186 | self.hiring_id = parser.getint(self.CONFIG_SECTION, 187 | self.CONFIG_HIRING_ID) 188 | self.freelance_id = parser.getint(self.CONFIG_SECTION, 189 | self.CONFIG_FREELANCE_ID) 190 | 191 | def load_config_item_cache(self, parser): 192 | """Load the item cache from ~/.haxornewsconfig. 193 | 194 | :type parser: :class:`ConfigParser.RawConfigParser` 195 | :param parser: An instance of `ConfigParser.RawConfigParser`. 196 | """ 197 | self.item_cache = self.load_section_list(parser, 198 | self.CONFIG_CACHE) 199 | 200 | def load_config_item_ids(self, parser): 201 | """Load the item ids from ~/.haxornewsconfig. 202 | 203 | :type parser: :class:`ConfigParser.RawConfigParser` 204 | :param parser: An instance of `ConfigParser.RawConfigParser`. 205 | """ 206 | self.item_ids = self.load_section_list(parser, 207 | self.CONFIG_IDS) 208 | 209 | def load_config_show_tip(self, parser): 210 | """Load the show tip config from ~/.haxornewsconfig. 211 | 212 | :type parser: :class:`ConfigParser.RawConfigParser` 213 | :param parser: An instance of `ConfigParser.RawConfigParser`. 214 | """ 215 | self.show_tip = parser.getboolean(self.CONFIG_SECTION, 216 | self.CONFIG_SHOW_TIP) 217 | 218 | def load_color(self, parser, color_config, default): 219 | """Load the specified color from ~/.haxornewsconfig. 220 | 221 | :type parser: :class:`ConfigParser.RawConfigParser` 222 | :param parser: An instance of `ConfigParser.RawConfigParser`. 223 | 224 | :type color_config: str 225 | :param color_config: The color config label to load. 226 | 227 | :type default: str 228 | :param default: The default color if no color config exists. 229 | """ 230 | try: 231 | color = parser.get(self.CONFIG_SECTION, color_config) 232 | if color == 'none': 233 | color = None 234 | # Check if the user input a valid color. 235 | # If invalid, this will throw a TypeError 236 | click.style('', fg=color) 237 | except (TypeError, configparser.NoOptionError): 238 | return default 239 | return color 240 | 241 | def load_colors(self, parser): 242 | """Load all colors from ~/.haxornewsconfig. 243 | 244 | :type parser: :class:`ConfigParser.RawConfigParser` 245 | :param parser: An instance of `ConfigParser.RawConfigParser`. 246 | """ 247 | self.clr_bold = self.load_color( 248 | parser=parser, 249 | color_config=self.CONFIG_CLR_BOLD, 250 | default=self.clr_bold) 251 | self.clr_code = self.load_color( 252 | parser=parser, 253 | color_config=self.CONFIG_CLR_CODE, 254 | default=self.clr_code) 255 | self.clr_general = self.load_color( 256 | parser=parser, 257 | color_config=self.CONFIG_CLR_GENERAL, 258 | default=self.clr_general) 259 | self.clr_header = self.load_color( 260 | parser=parser, 261 | color_config=self.CONFIG_CLR_HEADER, 262 | default=self.clr_header) 263 | self.clr_link = self.load_color( 264 | parser=parser, 265 | color_config=self.CONFIG_CLR_LINK, 266 | default=self.clr_link) 267 | self.clr_list = self.load_color( 268 | parser=parser, 269 | color_config=self.CONFIG_CLR_LIST, 270 | default=self.clr_list) 271 | self.clr_num_comments = self.load_color( 272 | parser=parser, 273 | color_config=self.CONFIG_CLR_NUM_COMMENTS, 274 | default=self.clr_num_comments) 275 | self.clr_num_points = self.load_color( 276 | parser=parser, 277 | color_config=self.CONFIG_CLR_NUM_POINTS, 278 | default=self.clr_num_points) 279 | self.clr_tag = self.load_color( 280 | parser=parser, 281 | color_config=self.CONFIG_CLR_TAG, 282 | default=self.clr_tag) 283 | self.clr_time = self.load_color( 284 | parser=parser, 285 | color_config=self.CONFIG_CLR_TIME, 286 | default=self.clr_time) 287 | self.clr_title = self.load_color( 288 | parser=parser, 289 | color_config=self.CONFIG_CLR_TITLE, 290 | default=self.clr_title) 291 | self.clr_tooltip = self.load_color( 292 | parser=parser, 293 | color_config=self.CONFIG_CLR_TOOLTIP, 294 | default=self.clr_tooltip) 295 | self.clr_user = self.load_color( 296 | parser=parser, 297 | color_config=self.CONFIG_CLR_USER, 298 | default=self.clr_user) 299 | self.clr_view_link = self.load_color( 300 | parser=parser, 301 | color_config=self.CONFIG_CLR_VIEW_LINK, 302 | default=self.clr_view_link) 303 | self.clr_view_index = self.load_color( 304 | parser=parser, 305 | color_config=self.CONFIG_CLR_VIEW_INDEX, 306 | default=self.clr_view_index) 307 | 308 | def load_hiring_and_freelance_ids(self, url=None): 309 | """Load the latest who's hiring and freelancer post ids. 310 | 311 | The latest ids are updated monthly on the repo and are then cached. 312 | If fetching the latest ids from the repo fails, the cache is checked. 313 | If fetching the cache fails, the default ids set during installation 314 | are used. 315 | 316 | :type url: str 317 | :param url: The url to load the latest post ids. 318 | """ 319 | try: 320 | if url is None: 321 | url = 'https://raw.githubusercontent.com/donnemartin/haxor-news/master/haxor_news/settings.py' # NOQA 322 | file_name = 'downloaded_settings.py' 323 | urlretrieve(url, file_name) 324 | with open(file_name, 'r') as f: 325 | for line in f: 326 | if line.startswith('who_is_hiring_post_id'): 327 | self.hiring_id = line.split(' = ')[1].strip('\n') 328 | if line.startswith('freelancer_post_id'): 329 | self.freelance_id = line.split(' = ')[1].strip('\n') 330 | if self.hiring_id == 0 or self.freelance_id == 0: 331 | self.load_hiring_and_freelance_ids_from_cache_or_defaults() 332 | except (URLError, IOError): 333 | self.load_hiring_and_freelance_ids_from_cache_or_defaults() 334 | 335 | def load_hiring_and_freelance_ids_from_cache_or_defaults(self): 336 | """Load the hiring and freelancer post ids from cache or defaults. 337 | 338 | If fetching the cache fails, the default ids set during installation 339 | are used. 340 | """ 341 | self.load_config([self.load_config_hiring_and_freelance_ids]) 342 | if self.hiring_id == 0 or self.freelance_id == 0: 343 | self.hiring_id = who_is_hiring_post_id 344 | self.freelance_id = freelancer_post_id 345 | 346 | def load_section_list(self, parser, section): 347 | """Load the given section containing a list from ~/.haxornewsconfig. 348 | 349 | :type parser: :class:`ConfigParser.RawConfigParser` 350 | :param parser: An instance of `ConfigParser.RawConfigParser`. 351 | 352 | :type section: str 353 | :param section: The section to load. 354 | 355 | :rtype: list 356 | :return: Collection of items stored in config. 357 | 358 | :raises: `Exception` if an error occurred reading from the parser. 359 | """ 360 | items_ids = parser.get(self.CONFIG_SECTION, section) 361 | items_ids = items_ids.strip() 362 | excludes = ['[', ']', "'"] 363 | for exclude in excludes: 364 | items_ids = items_ids.replace(exclude, '') 365 | return items_ids.split(', ') 366 | 367 | def save_cache(self): 368 | """Save the current set of item ids and cache to ~/.haxornewsconfig.""" 369 | if self.item_cache is not None and \ 370 | len(self.item_cache) > self.MAX_ITEM_CACHE_SIZE: 371 | self.item_cache = self.item_cache[-self.MAX_ITEM_CACHE_SIZE//2:] 372 | config_file_path = self.get_config_path(self.CONFIG) 373 | parser = configparser.RawConfigParser() 374 | parser.add_section(self.CONFIG_SECTION) 375 | parser.set(self.CONFIG_SECTION, 376 | self.CONFIG_HIRING_ID, 377 | self.hiring_id) 378 | parser.set(self.CONFIG_SECTION, 379 | self.CONFIG_FREELANCE_ID, 380 | self.freelance_id) 381 | parser.set(self.CONFIG_SECTION, 382 | self.CONFIG_SHOW_TIP, 383 | self.show_tip) 384 | parser.set(self.CONFIG_SECTION, 385 | self.CONFIG_CLR_BOLD, 386 | self.clr_bold) 387 | parser.set(self.CONFIG_SECTION, 388 | self.CONFIG_CLR_CODE, 389 | self.clr_code) 390 | parser.set(self.CONFIG_SECTION, 391 | self.CONFIG_CLR_GENERAL, 392 | self.clr_general) 393 | parser.set(self.CONFIG_SECTION, 394 | self.CONFIG_CLR_HEADER, 395 | self.clr_header) 396 | parser.set(self.CONFIG_SECTION, 397 | self.CONFIG_CLR_LINK, 398 | self.clr_link) 399 | parser.set(self.CONFIG_SECTION, 400 | self.CONFIG_CLR_LIST, 401 | self.clr_list) 402 | parser.set(self.CONFIG_SECTION, 403 | self.CONFIG_CLR_NUM_COMMENTS, 404 | self.clr_num_comments) 405 | parser.set(self.CONFIG_SECTION, 406 | self.CONFIG_CLR_NUM_POINTS, 407 | self.clr_num_points) 408 | parser.set(self.CONFIG_SECTION, 409 | self.CONFIG_CLR_TAG, 410 | self.clr_tag) 411 | parser.set(self.CONFIG_SECTION, 412 | self.CONFIG_CLR_TIME, 413 | self.clr_time) 414 | parser.set(self.CONFIG_SECTION, 415 | self.CONFIG_CLR_TITLE, 416 | self.clr_title) 417 | parser.set(self.CONFIG_SECTION, 418 | self.CONFIG_CLR_TOOLTIP, 419 | self.clr_tooltip) 420 | parser.set(self.CONFIG_SECTION, 421 | self.CONFIG_CLR_USER, 422 | self.clr_user) 423 | parser.set(self.CONFIG_SECTION, 424 | self.CONFIG_CLR_VIEW_LINK, 425 | self.clr_view_link) 426 | parser.set(self.CONFIG_SECTION, 427 | self.CONFIG_CLR_VIEW_INDEX, 428 | self.clr_view_index) 429 | parser.set(self.CONFIG_SECTION, 430 | self.CONFIG_IDS, 431 | self.item_ids) 432 | parser.set(self.CONFIG_SECTION, 433 | self.CONFIG_CACHE, 434 | self.item_cache) 435 | with open(config_file_path, 'w+') as config_file: 436 | parser.write(config_file) 437 | -------------------------------------------------------------------------------- /haxor_news/hacker_news.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # Copyright 2015 Donne Martin. All Rights Reserved. 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"). You 6 | # may not use this file except in compliance with the License. A copy of 7 | # the License is located at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # or in the "license" file accompanying this file. This file is 12 | # distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF 13 | # ANY KIND, either express or implied. See the License for the specific 14 | # language governing permissions and limitations under the License. 15 | 16 | from __future__ import print_function 17 | from __future__ import division 18 | 19 | import platform 20 | import re 21 | import sys 22 | import webbrowser 23 | 24 | import click 25 | from .compat import HTMLParser 26 | from .compat import urlparse 27 | 28 | from .config import Config 29 | from .lib.haxor.haxor import HackerNewsApi, HTTPError, InvalidItemID, \ 30 | InvalidUserID 31 | from .lib.pretty_date_time import pretty_date_time 32 | from .onions import onions 33 | from .web_viewer import WebViewer 34 | 35 | 36 | class HackerNews(object): 37 | """Encapsulate Hacker News. 38 | 39 | :type COMMENT_INDENT: str (const) 40 | :param COMMENT_INDENT: The comment indent. 41 | 42 | :type COMMENT_UNSEEN: str (const) 43 | :param COMMENT_UNSEEN: The adornment for unseen 44 | comments. 45 | 46 | :type config: :class:`config.Config` 47 | :param config: An instance of `config.Config`. 48 | 49 | :type html: :class:`HTMLParser` 50 | :param html: An instance of `HTMLParser`. 51 | 52 | :type MAX_LIST_INDEX: int (const) 53 | :param MAX_LIST_INDEX: The maximum 1-based index value 54 | hn view will use to match item_ids. Any value larger than 55 | MAX_LIST_INDEX will result in hn view treating that index as an 56 | actual post id. 57 | 58 | :type MAX_SNIPPET_LENGTH: int (const) 59 | :param MAX_SNIPPET_LENGTH: The max length of a comment snippet shown 60 | when filtering comments. 61 | 62 | :type hacker_news_api: :class:`haxor.HackerNewsApi` 63 | :param hacker_news_api: An instance of `haxor.HackerNewsApi`. 64 | 65 | :type QUERY_UNSEEN: str (const) 66 | :param foo: the query to show unseen comments. 67 | 68 | :type web_viewer: :class:`web_viewer.WebViewer` 69 | :param web_viewer: An instance of `web_viewer.WebViewer`. 70 | """ 71 | 72 | COMMENT_INDENT = ' ' 73 | COMMENT_UNSEEN = ' [!]' 74 | MAX_LIST_INDEX = 1000 75 | MAX_SNIPPET_LENGTH = 60 76 | QUERY_UNSEEN = '\[!\]' 77 | 78 | def __init__(self): 79 | self.hacker_news_api = HackerNewsApi() 80 | try: 81 | self.html = HTMLParser.HTMLParser() 82 | except: 83 | self.html = HTMLParser 84 | self.config = Config() 85 | self.web_viewer = WebViewer() 86 | 87 | def ask(self, limit): 88 | """Display Ask HN posts. 89 | 90 | :type limit: int 91 | :param limit: the number of items to show, optional, defaults to 10. 92 | """ 93 | self.print_items( 94 | message=self.headlines_message('Ask HN'), 95 | item_ids=self.hacker_news_api.ask_stories(limit)) 96 | 97 | def best(self, limit): 98 | """Display best posts. 99 | 100 | :type limit: int 101 | :param limit: the number of items to show, optional, defaults to 10. 102 | """ 103 | self.print_items( 104 | message=self.headlines_message('Best'), 105 | item_ids=self.hacker_news_api.best_stories(limit)) 106 | 107 | def headlines_message(self, message): 108 | """Create the "Fetching [message] Headlines..." string. 109 | 110 | :type message: str 111 | :param message: The headline message. 112 | 113 | :rtype: str 114 | :return: "Fetching [message] Headlines...". 115 | """ 116 | return 'Fetching {0} Headlines...'.format(message) 117 | 118 | def hiring_and_freelance(self, regex_query, post_id): 119 | """Display comments matching the monthly who is hiring post. 120 | 121 | Searches the monthly Hacker News who is hiring post for comments 122 | matching the given regex_query. Defaults to searching the latest 123 | post based on your installed version of haxor-news. 124 | 125 | :type regex_query: str 126 | :param regex_query: The regex query to match. 127 | 128 | :type post_id: int 129 | :param post_id: the who is hiring post id. 130 | Optional, defaults to the latest post based on your installed 131 | version of haxor-news. 132 | """ 133 | try: 134 | item = self.hacker_news_api.get_item(post_id) 135 | self.print_comments(item, 136 | regex_query, 137 | comments_hide_non_matching=True) 138 | self.config.save_cache() 139 | except InvalidItemID: 140 | self.print_item_not_found(post_id) 141 | except IOError: 142 | sys.stderr.close() 143 | 144 | def jobs(self, limit): 145 | """Display job posts. 146 | 147 | :type limit: int 148 | :param limit: the number of items to show, optional, defaults to 10. 149 | """ 150 | self.print_items( 151 | message=self.headlines_message('Jobs'), 152 | item_ids=self.hacker_news_api.job_stories(limit)) 153 | 154 | def new(self, limit): 155 | """Display the latest posts. 156 | 157 | :type limit: int 158 | :param limit: the number of items to show, optional, defaults to 10. 159 | """ 160 | self.print_items( 161 | message=self.headlines_message('Latest'), 162 | item_ids=self.hacker_news_api.new_stories(limit)) 163 | 164 | def onion(self, limit): 165 | """Display onions. 166 | 167 | :type limit: int 168 | :param limit: the number of items to show, optional, defaults to 10. 169 | """ 170 | click.secho('\n{h}\n'.format(h=self.headlines_message('Top Onion')), 171 | fg=self.config.clr_title) 172 | index = 1 173 | for onion in onions[0:limit]: 174 | formatted_index_title = self.format_index_title(index, onion) 175 | click.echo(formatted_index_title) 176 | index += 1 177 | click.echo('') 178 | 179 | def print_comment(self, item, regex_query='', 180 | comments_hide_non_matching=False, depth=0): 181 | """Print the comments for the given item. 182 | 183 | :type item: :class:`haxor.Item` 184 | :param item: An instance of `haxor.Item`. 185 | 186 | :type regex_query: str 187 | :param regex_query: the regex query to match. 188 | 189 | :type comments_hide_non_matching: bool 190 | :param comments_hide_non_matching: determines whether to 191 | hide comments that don't match (False) or truncate them (True). 192 | 193 | :type depth: int 194 | :param depth: The current recursion depth, used to indent the comment. 195 | """ 196 | if item.text is None: 197 | return 198 | header_color = 'yellow' 199 | header_color_highlight = 'magenta' 200 | header_adornment = '' 201 | if self.config.item_cache is not None and \ 202 | str(item.item_id) not in self.config.item_cache: 203 | header_adornment = self.COMMENT_UNSEEN 204 | self.config.item_cache.append(item.item_id) 205 | show_comment = True 206 | if regex_query is not None: 207 | if self.match_comment_unseen(regex_query, header_adornment) or \ 208 | self.match_regex(item, regex_query): 209 | header_color = header_color_highlight 210 | else: 211 | show_comment = False 212 | formatted_heading, formatted_comment = self.format_comment( 213 | item, depth, header_color, header_adornment) 214 | if show_comment: 215 | click.echo(formatted_heading, color=True) 216 | click.echo(formatted_comment, color=True) 217 | elif comments_hide_non_matching: 218 | click.secho('.', nl=False) 219 | else: 220 | click.echo(formatted_heading, color=True) 221 | num_chars = len(formatted_comment) 222 | if num_chars > self.MAX_SNIPPET_LENGTH: 223 | num_chars = self.MAX_SNIPPET_LENGTH 224 | click.echo(formatted_comment[0:num_chars] + ' [...]', color=True) 225 | 226 | def print_comments(self, item, regex_query='', 227 | comments_hide_non_matching=False, depth=0): 228 | """Recursively print comments and subcomments for the given item. 229 | 230 | :type item: :class:`haxor.Item` 231 | :param item: An instance of `haxor.Item`. 232 | 233 | :type regex_query: str 234 | :param regex_query: the regex query to match. 235 | 236 | :type comments_hide_non_matching: bool 237 | :param comments_hide_non_matching: determines whether to 238 | hide comments that don't match (False) or truncate them (True). 239 | 240 | :type depth: int 241 | :param depth: The current recursion depth, used to indent the comment. 242 | """ 243 | self.print_comment(item, regex_query, comments_hide_non_matching, depth) 244 | comment_ids = item.kids 245 | if not comment_ids: 246 | return 247 | for comment_id in comment_ids: 248 | try: 249 | comment = self.hacker_news_api.get_item(comment_id) 250 | depth += 1 251 | self.print_comments( 252 | comment, 253 | regex_query=regex_query, 254 | comments_hide_non_matching=comments_hide_non_matching, 255 | depth=depth) 256 | depth -= 1 257 | except (InvalidItemID, HTTPError): 258 | click.echo('') 259 | self.print_item_not_found(comment_id) 260 | 261 | def format_comment(self, item, depth, header_color, header_adornment): 262 | """Format a given item's comment. 263 | 264 | :type item: :class:`haxor.Item` 265 | :param item: An instance of `haxor.Item`. 266 | 267 | :type depth: int 268 | :param depth: The current recursion depth, used to indent the comment. 269 | 270 | :type header_color: str 271 | :param header_color: The header color. 272 | 273 | :type header_adornment: str 274 | :param header_adornment: The header adornment. 275 | 276 | :rtype: tuple 277 | :return: * A string representing the formatted comment header. 278 | * A string representing the formatted comment. 279 | """ 280 | indent = self.COMMENT_INDENT * depth 281 | formatted_heading = click.style( 282 | '\n{i}{b} - {d}{h}'.format( 283 | i=indent, 284 | b=item.by, 285 | d=str(pretty_date_time(item.submission_time)), 286 | h=header_adornment), 287 | fg=header_color) 288 | unescaped_text = self.html.unescape(item.text) 289 | regex_paragraph = re.compile(r'

') 290 | unescaped_text = regex_paragraph.sub(click.style( 291 | '\n\n' + indent), unescaped_text) 292 | regex_url = re.compile(r'()') 293 | unescaped_text = regex_url.sub(click.style( 294 | r'\2', fg=self.config.clr_link), unescaped_text) 295 | regex_tag = re.compile(r'(<(.*)>.*?<\/\2>)') 296 | unescaped_text = regex_tag.sub(click.style( 297 | r'\1', fg=self.config.clr_tag), unescaped_text) 298 | formatted_comment = click.wrap_text(text=unescaped_text, 299 | initial_indent=indent, 300 | subsequent_indent=indent) 301 | return formatted_heading, formatted_comment 302 | 303 | def format_index_title(self, index, title): 304 | """Format and item's index and title. 305 | 306 | :type index: int 307 | :param index: The index for the given item, used with the 308 | hn view [index] commend. 309 | 310 | :type title: str 311 | :param title: The item's title. 312 | 313 | :rtype: str 314 | :return: The formatted index and title. 315 | """ 316 | INDEX_PAD = 5 317 | formatted_index = ' ' + (str(index) + '.').ljust(INDEX_PAD) 318 | formatted_index_title = click.style(formatted_index, 319 | fg=self.config.clr_view_index) 320 | formatted_index_title += click.style(title + ' ', 321 | fg=self.config.clr_title) 322 | return formatted_index_title 323 | 324 | def format_item(self, item, index): 325 | """Format an item. 326 | 327 | :type item: :class:`haxor.Item` 328 | :param item: An instance of `haxor.Item`. 329 | 330 | :type index: int 331 | :param index: The index for the given item, used with the 332 | hn view [index] commend. 333 | 334 | :rtype: str 335 | :return: The formatted item. 336 | """ 337 | formatted_item = self.format_index_title(index, item.title) 338 | if item.url is not None: 339 | netloc = urlparse(item.url).netloc 340 | netloc = re.sub('www.', '', netloc) 341 | formatted_item += click.style('(' + netloc + ')', 342 | fg=self.config.clr_view_link) 343 | formatted_item += '\n ' 344 | formatted_item += click.style(str(item.score) + ' points ', 345 | fg=self.config.clr_num_points) 346 | formatted_item += click.style('by ' + item.by + ' ', 347 | fg=self.config.clr_user) 348 | submission_time = str(pretty_date_time(item.submission_time)) 349 | formatted_item += click.style(submission_time + ' ', 350 | fg=self.config.clr_time) 351 | num_comments = str(item.descendants) if item.descendants else '0' 352 | formatted_item += click.style('| ' + num_comments + ' comments', 353 | fg=self.config.clr_num_comments) 354 | return formatted_item 355 | 356 | def print_item_not_found(self, item_id): 357 | """Print a message the given item id was not found. 358 | 359 | :type item_id: int 360 | :param item_id: The item's id. 361 | """ 362 | click.secho('Item with id {0} not found.'.format(item_id), fg='red') 363 | 364 | def print_items(self, message, item_ids): 365 | """Print the items. 366 | 367 | :type message: str 368 | :param message: A message to print out to the user before outputting 369 | the results. 370 | 371 | :type item_ids: iterable 372 | :param item_ids: A collection of items to print. 373 | Can be a list or dictionary. 374 | """ 375 | self.config.item_ids = [] 376 | index = 1 377 | for item_id in item_ids: 378 | try: 379 | item = self.hacker_news_api.get_item(item_id) 380 | if item.title: 381 | formatted_item = self.format_item(item, index) 382 | self.config.item_ids.append(item.item_id) 383 | click.echo(formatted_item) 384 | index += 1 385 | except InvalidItemID: 386 | self.print_item_not_found(item_id) 387 | self.config.save_cache() 388 | if self.config.show_tip: 389 | click.secho(self.tip_view(str(index-1))) 390 | 391 | def tip_view(self, max_index): 392 | """Create the tip about the view command. 393 | 394 | :type max_index: string 395 | :param max_index: The index uppor bound, used with the 396 | hn view [index] commend. 397 | 398 | :rtype: str 399 | :return: The formatted tip. 400 | """ 401 | tip = click.style(' Tip: View the page or comments for ', 402 | fg=self.config.clr_tooltip) 403 | tip += click.style('1 through ', fg=self.config.clr_view_index) 404 | tip += click.style(str(max_index), fg=self.config.clr_view_index) 405 | tip += click.style(' with the following command:\n', 406 | fg=self.config.clr_tooltip) 407 | tip += click.style(' hn view [#] ', fg=self.config.clr_view_index) 408 | tip += click.style(('optional: [-c] [-cr] [-cu] [-cq "regex"] [-ch]' 409 | ' [-b] [--help]' + '\n'), 410 | fg=self.config.clr_tooltip) 411 | return tip 412 | 413 | def match_comment_unseen(self, regex_query, header_adornment): 414 | """Determine if a comment is unseen based on the query and header. 415 | 416 | :type regex_query: str 417 | :param regex_query: The regex query to match. 418 | 419 | :type header_adornment: str 420 | :param header_adornment: The header adornment. 421 | 422 | :rtype: bool 423 | :return: Specifies if there is a match found. 424 | """ 425 | if regex_query == self.QUERY_UNSEEN and \ 426 | header_adornment == self.COMMENT_UNSEEN: 427 | return True 428 | else: 429 | return False 430 | 431 | def match_regex(self, item, regex_query): 432 | """Determine if there is a match with the given regex_query. 433 | 434 | :type item: :class:`haxor.Item` 435 | :param item: An instance of `haxor.Item`. 436 | 437 | :type regex_query: str 438 | :param regex_query: The regex query to match. 439 | 440 | :rtype: bool 441 | :return: Specifies if there is a match found. 442 | """ 443 | match_time = re.search( 444 | regex_query, 445 | str(pretty_date_time(item.submission_time))) 446 | match_user = re.search(regex_query, item.by) 447 | match_text = re.search(regex_query, item.text) 448 | if not match_text and not match_user and not match_time: 449 | return False 450 | else: 451 | return True 452 | 453 | def show(self, limit): 454 | """Display Show HN posts. 455 | 456 | :type limit: int 457 | :param limit: the number of items to show, optional, defaults to 10. 458 | """ 459 | self.print_items( 460 | message=self.headlines_message('Show HN'), 461 | item_ids=self.hacker_news_api.show_stories(limit)) 462 | 463 | def top(self, limit): 464 | """Display the top posts. 465 | 466 | :type limit: int 467 | :param limit: the number of items to show, optional, defaults to 10. 468 | """ 469 | self.print_items( 470 | message=self.headlines_message('Top'), 471 | item_ids=self.hacker_news_api.top_stories(limit)) 472 | 473 | def user(self, user_id, submission_limit): 474 | """Display basic user info and submitted posts. 475 | 476 | :type user_id: str. 477 | :param user_id: The user'd login name. 478 | 479 | :type submission_limit: int 480 | :param submission_limit: the number of submissions to show. 481 | Optional, defaults to 10. 482 | """ 483 | try: 484 | user = self.hacker_news_api.get_user(user_id) 485 | click.secho('\nUser Id: ', nl=False, fg=self.config.clr_general) 486 | click.secho(user_id, fg=self.config.clr_user) 487 | click.secho('Created: ', nl=False, fg=self.config.clr_general) 488 | click.secho(str(user.created), fg=self.config.clr_user) 489 | click.secho('Karma: ', nl=False, fg=self.config.clr_general) 490 | click.secho(str(user.karma), fg=self.config.clr_user) 491 | self.print_items('User submissions:', 492 | user.submitted[0:submission_limit]) 493 | except InvalidUserID: 494 | self.print_item_not_found(user_id) 495 | 496 | def view(self, index, comments_query, comments, 497 | comments_hide_non_matching, browser): 498 | """View the given index contents. 499 | 500 | Uses ids from ~/.haxornewsconfig stored in self.config.item_ids. 501 | If url is True, opens a browser with the url based on the given index. 502 | Else, displays the post's comments. 503 | 504 | :type index: int 505 | :param index: The index for the given item, used with the 506 | hn view [index] commend. 507 | 508 | :type comments: bool 509 | :param comments: Determines whether to view the comments 510 | or a simplified version of the post url. 511 | 512 | :type comments_hide_non_matching: bool 513 | :param comments_hide_non_matching: determines whether to 514 | hide comments that don't match (False) or truncate them (True). 515 | 516 | :type browser: bool 517 | :param browser: determines whether to view the url in a browser. 518 | """ 519 | if self.config.item_ids is None: 520 | click.secho('There are no posts indexed, run a command such as ' 521 | 'hn top first', 522 | fg='red') 523 | return 524 | item_id = index 525 | if index < self.MAX_LIST_INDEX: 526 | try: 527 | item_id = self.config.item_ids[index-1] 528 | except IndexError: 529 | self.print_item_not_found(item_id) 530 | return 531 | try: 532 | item = self.hacker_news_api.get_item(item_id) 533 | except InvalidItemID: 534 | self.print_item_not_found(self.config.item_ids[index-1]) 535 | return 536 | if not comments and item.url is None: 537 | click.secho('\nNo url associated with post.', 538 | nl=False, 539 | fg=self.config.clr_general) 540 | comments = True 541 | if comments: 542 | comments_url = ('https://news.ycombinator.com/item?id=' + 543 | str(item.item_id)) 544 | click.secho('\nFetching Comments from ' + comments_url, 545 | fg=self.config.clr_general) 546 | if browser: 547 | webbrowser.open(comments_url) 548 | else: 549 | try: 550 | self.print_comments( 551 | item, 552 | regex_query=comments_query, 553 | comments_hide_non_matching=comments_hide_non_matching) 554 | click.echo('') 555 | except IOError: 556 | sys.stderr.close() 557 | self.config.save_cache() 558 | else: 559 | click.secho('\nOpening ' + item.url + ' ...', 560 | fg=self.config.clr_general) 561 | if browser: 562 | webbrowser.open(item.url) 563 | else: 564 | contents = self.web_viewer.generate_url_contents(item.url) 565 | header = click.style('Viewing ' + item.url + '\n\n', 566 | fg=self.config.clr_general) 567 | contents = header + contents 568 | contents += click.style(('\nView this article in a browser with' 569 | ' the -b/--browser flag.\n'), 570 | fg=self.config.clr_general) 571 | contents += click.style(('\nPress q to quit viewing this ' 572 | 'article.\n'), 573 | fg=self.config.clr_general) 574 | if platform.system() == 'Windows': 575 | try: 576 | # Strip out Unicode, which seems to have issues on 577 | # Windows 578 | contents = re.sub(r'[^\x00-\x7F]+', '', contents) 579 | click.echo(contents) 580 | except IOError: 581 | sys.stderr.close() 582 | else: 583 | click.echo_via_pager(contents) 584 | click.echo('') 585 | 586 | def view_setup(self, index, comments_regex_query, comments, 587 | comments_recent, comments_unseen, 588 | comments_hide_non_matching, clear_cache, browser): 589 | """Set up the call to view the given index comments or url. 590 | 591 | This method is meant to be called after a command that outputs a 592 | table of posts. 593 | 594 | :type index: int 595 | :param index: The index for the given item, used with the 596 | hn view [index] commend. 597 | 598 | :type regex_query: str 599 | :param regex_query: The regex query to match. 600 | 601 | :type comments: bool 602 | :param comments: Determines whether to view the comments 603 | or a simplified version of the post url. 604 | 605 | :type comments_recent: bool 606 | :param comments_recent: Determines whether to view only 607 | recently comments (posted within the past 59 minutes or less). 608 | 609 | :type comments_unseen: bool 610 | :param comments_unseen: Determines whether to view only 611 | comments that you have not yet seen. 612 | 613 | :type comments_hide_non_matching: bool 614 | :param comments_hide_non_matching: determines whether to 615 | hide comments that don't match (False) or truncate them (True). 616 | 617 | :type clear_cache: bool 618 | :param clear_cache: foos. 619 | 620 | :type browser: bool 621 | :param browser: Determines whether to clear the comment cache before 622 | running the view command. 623 | """ 624 | if comments_regex_query is not None: 625 | comments = True 626 | if comments_recent: 627 | comments_regex_query = 'seconds ago|minutes ago' 628 | comments = True 629 | if comments_unseen: 630 | comments_regex_query = self.QUERY_UNSEEN 631 | comments = True 632 | if clear_cache: 633 | self.config.clear_item_cache() 634 | self.view(int(index), 635 | comments_regex_query, 636 | comments, 637 | comments_hide_non_matching, 638 | browser) 639 | -------------------------------------------------------------------------------- /haxor_news/hacker_news_cli.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # Copyright 2015 Donne Martin. All Rights Reserved. 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"). You 6 | # may not use this file except in compliance with the License. A copy of 7 | # the License is located at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # or in the "license" file accompanying this file. This file is 12 | # distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF 13 | # ANY KIND, either express or implied. See the License for the specific 14 | # language governing permissions and limitations under the License. 15 | 16 | from __future__ import print_function 17 | from __future__ import division 18 | 19 | import click 20 | 21 | from .hacker_news import HackerNews 22 | 23 | 24 | pass_hacker_news = click.make_pass_decorator(HackerNews) 25 | 26 | 27 | class HackerNewsCli(object): 28 | """Encapsulate the Hacker News Command Line Interface.""" 29 | 30 | @click.group() 31 | @click.pass_context 32 | def cli(ctx): 33 | """Main entry point for HackerNewsCli. 34 | 35 | :type ctx: :class:`click.core.Context` 36 | :param ctx: An instance of click.core.Context that stores an instance 37 | of `hacker_news.HackerNews`. 38 | """ 39 | # Create a HackerNews object and remember it as the context object. 40 | # From this point onwards other commands can refer to it by using the 41 | # @pass_hacker_news decorator. 42 | ctx.obj = HackerNews() 43 | 44 | @cli.command() 45 | @click.argument('limit', required=False, default=10) 46 | @pass_hacker_news 47 | def ask(hacker_news, limit): 48 | """Display Ask HN posts. 49 | 50 | Example(s): 51 | hn ask 52 | hn ask 5 53 | 54 | :type hacker_news: :class:`hacker_news.HackerNews` 55 | :param hacker_news: An instance of `hacker_news.HackerNews`. 56 | 57 | :type limit: int 58 | :param limit: specifies the number of items to show. 59 | Optional, defaults to 10. 60 | """ 61 | hacker_news.ask(limit) 62 | 63 | @cli.command() 64 | @click.argument('limit', required=False, default=10) 65 | @pass_hacker_news 66 | def best(hacker_news, limit): 67 | """Display the best posts of the past few days. 68 | 69 | Example(s): 70 | hn best 71 | hn best 20 72 | 73 | :type hacker_news: :class:`hacker_news.HackerNews` 74 | :param hacker_news: An instance of `hacker_news.HackerNews`. 75 | 76 | :type limit: int 77 | :param limit: specifies the number of items to show. 78 | Optional, defaults to 10. 79 | """ 80 | hacker_news.best(limit) 81 | 82 | @cli.command() 83 | @click.argument('regex_query', required=False) 84 | @click.option('-i', '--id_post', required=False, default=0) 85 | @pass_hacker_news 86 | def freelance(hacker_news, regex_query, id_post): 87 | """Display comments from the seeking freelancer posts. 88 | 89 | Searches the monthly Hacker News seeking freelancer post for comments 90 | matching the given regex_query. Defaults to searching the latest 91 | post. 92 | 93 | You can search any post by providing a freelancer_post_id: 94 | Example: https://news.ycombinator.com/item?id=10492087 95 | freelancer_post_id = 10492087 96 | 97 | Example(s): 98 | hn freelance 99 | hn freelance "Python" 100 | hn freelance "(?i)Python|JavaScript" # (?i) case insensitive 101 | hn freelance "(?i)Python" -i 8394339 # search post 8394339 102 | hn freelance "(?i)(Python|JavaScript).*(rockstar)" > rockstars.txt 103 | 104 | :type hacker_news: :class:`hacker_news.HackerNews` 105 | :param hacker_news: An instance of `hacker_news.HackerNews`. 106 | 107 | :type regex_query: str 108 | :param regex_query: The regex query to match. 109 | 110 | :type id_post: str 111 | :param id_post: The who is hiring post id. 112 | Optional, defaults to the latest post based on your installed 113 | version of haxor-news. 114 | """ 115 | if id_post == 0: 116 | hacker_news.config.load_hiring_and_freelance_ids() 117 | id_post = hacker_news.config.freelance_id 118 | hacker_news.hiring_and_freelance(regex_query, id_post) 119 | 120 | @cli.command() 121 | @click.argument('regex_query', required=False) 122 | @click.option('-i', '--id_post', required=False, default=0) 123 | @pass_hacker_news 124 | def hiring(hacker_news, regex_query, id_post): 125 | """Display comments from the who is hiring posts. 126 | 127 | Searches the monthly Hacker News who is hiring post for comments 128 | matching the given regex_query. Defaults to searching the latest 129 | post. 130 | 131 | You can search any post by providing a who_is_hiring_post_id: 132 | Example: https://news.ycombinator.com/item?id=10492086 133 | who_is_hiring_post_id = 10492086 134 | 135 | Example(s): 136 | hn hiring 137 | hn hiring "Python" 138 | hn hiring "(?i)Python|JavaScript" # (?i) case insensitive 139 | hn hiring "(?i)Python|JavaScript" -i 8394339 # search post 8394339 140 | hn hiring "(?i)(Python|JavaScript).*(rockstar)" > rockstars.txt 141 | 142 | :type hacker_news: :class:`hacker_news.HackerNews` 143 | :param hacker_news: An instance of `hacker_news.HackerNews`. 144 | 145 | :type regex_query: str 146 | :param regex_query: The regex query to match. 147 | 148 | :type id_post: str 149 | :param id_post: The who is hiring post id. 150 | Optional, defaults to the latest post based on your installed 151 | version of haxor-news. 152 | """ 153 | if id_post == 0: 154 | hacker_news.config.load_hiring_and_freelance_ids() 155 | id_post = hacker_news.config.hiring_id 156 | hacker_news.hiring_and_freelance(regex_query, id_post) 157 | 158 | @cli.command() 159 | @click.argument('limit', required=False, default=10) 160 | @pass_hacker_news 161 | def jobs(hacker_news, limit): 162 | """Display job posts. 163 | 164 | Example(s): 165 | hn jobs 166 | hn jobs 15 167 | 168 | :type hacker_news: :class:`hacker_news.HackerNews` 169 | :param hacker_news: An instance of `hacker_news.HackerNews`. 170 | 171 | :type limit: int 172 | :param limit: specifies the number of items to show. 173 | Optional, defaults to 10. 174 | """ 175 | hacker_news.jobs(limit) 176 | 177 | @cli.command() 178 | @click.argument('limit', required=False, default=10) 179 | @pass_hacker_news 180 | def new(hacker_news, limit): 181 | """Display the latest posts. 182 | 183 | Example(s): 184 | hn new 185 | hn new 20 186 | 187 | :type hacker_news: :class:`hacker_news.HackerNews` 188 | :param hacker_news: An instance of `hacker_news.HackerNews`. 189 | 190 | :type limit: int 191 | :param limit: specifies the number of items to show. 192 | Optional, defaults to 10. 193 | """ 194 | hacker_news.new(limit) 195 | 196 | @cli.command() 197 | @click.argument('limit', required=False, default=50) 198 | @pass_hacker_news 199 | def onion(hacker_news, limit): 200 | """Display onions. 201 | 202 | Example(s): 203 | hn onion 204 | hn onion 10 205 | 206 | :type hacker_news: :class:`hacker_news.HackerNews` 207 | :param hacker_news: An instance of `hacker_news.HackerNews`. 208 | 209 | :type limit: int 210 | :param limit: specifies the number of items to show. 211 | Optional, defaults to 10. 212 | """ 213 | hacker_news.onion(limit) 214 | 215 | @cli.command() 216 | @click.argument('limit', required=False, default=10) 217 | @pass_hacker_news 218 | def show(hacker_news, limit): 219 | """Display Show HN posts. 220 | 221 | Example(s): 222 | hn show 223 | hn show 5 224 | 225 | :type hacker_news: :class:`hacker_news.HackerNews` 226 | :param hacker_news: An instance of `hacker_news.HackerNews`. 227 | 228 | :type limit: int 229 | :param limit: specifies the number of items to show. 230 | Optional, defaults to 10. 231 | """ 232 | hacker_news.show(limit) 233 | 234 | @cli.command() 235 | @click.argument('limit', required=False, default=10) 236 | @pass_hacker_news 237 | def top(hacker_news, limit): 238 | """Display the top recent posts. 239 | 240 | Example(s): 241 | hn top 242 | hn top 20 243 | 244 | :type hacker_news: :class:`hacker_news.HackerNews` 245 | :param hacker_news: An instance of `hacker_news.HackerNews`. 246 | 247 | :type limit: int 248 | :param limit: specifies the number of items to show. 249 | Optional, defaults to 10. 250 | """ 251 | hacker_news.top(limit) 252 | 253 | @cli.command() 254 | @click.argument('user_id') 255 | @click.option('-l', '--limit', required=False, default=10) 256 | @pass_hacker_news 257 | def user(hacker_news, user_id, limit): 258 | """Display basic user info and submitted posts. 259 | 260 | Example(s): 261 | hn user tptacek 262 | hn user patio11 263 | 264 | :type hacker_news: :class:`hacker_news.HackerNews` 265 | :param hacker_news: An instance of `hacker_news.HackerNews`. 266 | 267 | :type user_id: str 268 | :param user_id: The user name/id. 269 | 270 | :type limit: int 271 | :param limit: specifies the number of items to show. 272 | Optional, defaults to 10. 273 | """ 274 | hacker_news.user(user_id, limit) 275 | 276 | @cli.command() 277 | @click.argument('index') 278 | @click.option('-cq', '--comments_regex_query', required=False, default=None) 279 | @click.option('-c', '--comments', is_flag=True) 280 | @click.option('-cr', '--comments_recent', is_flag=True) 281 | @click.option('-cu', '--comments_unseen', is_flag=True) 282 | @click.option('-b', '--browser', is_flag=True) 283 | @click.option('-cc', '--clear_cache', is_flag=True) 284 | @click.option('-ch', '--comments_hide_non_matching', is_flag=True) 285 | @pass_hacker_news 286 | def view(hacker_news, index, comments_regex_query, comments, 287 | comments_recent, comments_unseen, 288 | comments_hide_non_matching, clear_cache, browser): 289 | """View the post index or id, hn view --help. 290 | 291 | Example(s): 292 | hn top 293 | hn view 3 294 | hn view 3 -c | less 295 | hn view 3 -c > comments.txt 296 | hn view 3 -cr 297 | hn view 3 --comments_recent 298 | hn view 3 -cu 299 | hn view 3 --comments_unseen 300 | hn view 3 -cu -ch 301 | hn view 3 --comments_unseen --comments_hide_non_matching 302 | hn view 3 --browser 303 | hn view 3 -b -c 304 | hn view 3 -comments -clear_cache 305 | hn view 3 "(?i)case insensitive match" --comments 306 | hn view 3 "(?i)programmer" --comments 307 | hn view 3 "(?i)programmer" --comments | less 308 | hn view 10492086 309 | hn view 10492086 "Python" 310 | hn view 10492086 "(?i)case insensitive match" 311 | hn view 10492086 "(?i)(Python|Django)" > comments.txt 312 | 313 | :type hacker_news: :class:`hacker_news.HackerNews` 314 | :param hacker_news: An instance of `hacker_news.HackerNews`. 315 | 316 | :type index: str 317 | :param index: specifies either: 318 | 1) the index of a post just shown within a list of posts or 319 | 2) the actual post id 320 | For example, calling `hn top` will list the top posts with 321 | 1-based indices for each post: 322 | 1. Post foo 323 | 2. Post bar 324 | 3. Post baz 325 | A subsequent call to `hn view 1` will view 'Post foo'. 326 | Providing an index larger than MAX_LIST_INDEX (1000) will 327 | result in hn view treating index as an actual post id. 328 | 329 | :type comments_regex_query: :class:`x.y` 330 | :param comments_regex_query: the regex query to match. 331 | Passing this option automatically sets comments to True. 332 | 333 | :type comments: bool 334 | :param comments: Determines whether to view the comments 335 | or a simplified version of the post url. 336 | 337 | :type comments_recent: bool 338 | :param comments_recent: Determines whether to view only 339 | recently comments (posted within the past 59 minutes or less). 340 | 341 | :type comments_unseen: bool 342 | :param comments_unseen: determines whether to view only 343 | comments that you have not yet seen. 344 | 345 | :type comments_hide_non_matching: bool 346 | :param comments_hide_non_matching: determines whether to 347 | hide comments that don't match (False) or truncate them (True). 348 | 349 | :type clear_cache: bool 350 | :param clear_cache: Determines whether to clear the comment cache before 351 | running the view command. 352 | 353 | :type browser: bool 354 | :param browser: Determines whether to view the url 355 | in a browser. 356 | """ 357 | try: 358 | post_index = int(index) 359 | except ValueError: 360 | click.secho('Error: Expected an integer post index', fg='red') 361 | else: 362 | hacker_news.view_setup(post_index, 363 | comments_regex_query, 364 | comments, 365 | comments_recent, 366 | comments_unseen, 367 | comments_hide_non_matching, 368 | clear_cache, 369 | browser) 370 | -------------------------------------------------------------------------------- /haxor_news/haxor.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # Copyright 2015 Donne Martin. All Rights Reserved. 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"). You 6 | # may not use this file except in compliance with the License. A copy of 7 | # the License is located at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # or in the "license" file accompanying this file. This file is 12 | # distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF 13 | # ANY KIND, either express or implied. See the License for the specific 14 | # language governing permissions and limitations under the License. 15 | 16 | from __future__ import print_function 17 | 18 | import os 19 | import platform 20 | import subprocess 21 | import sys 22 | 23 | import click 24 | from prompt_toolkit import AbortAction, Application, CommandLineInterface 25 | from prompt_toolkit.filters import Always 26 | from prompt_toolkit.interface import AcceptAction 27 | from prompt_toolkit.buffer import Buffer 28 | from prompt_toolkit.shortcuts import create_default_layout, create_eventloop 29 | from prompt_toolkit.history import FileHistory 30 | from prompt_toolkit.auto_suggest import AutoSuggestFromHistory 31 | 32 | from .__init__ import __version__ 33 | from .completer import Completer 34 | from .hacker_news_cli import HackerNewsCli 35 | from .keys import KeyManager 36 | from .style import StyleFactory 37 | from .toolbar import Toolbar 38 | from .utils import TextUtils 39 | 40 | 41 | class Haxor(object): 42 | """Encapsulate the Hacker News CLI. 43 | 44 | :type cli: :class:`prompt_toolkit.CommandLineInterface` 45 | :param cli: An instance of `prompt_toolkit.CommandLineInterface`. 46 | 47 | :type CMDS_ENABLE_PAGINATE: list (const) 48 | :param CMDS_ENABLE_PAGINATE: A list of commands that kick off pagination. 49 | 50 | :type CMDS_NO_PAGINATE: list (const) 51 | :param CMDS_NO_PAGINATE: A list of commands that disable pagination. 52 | 53 | :type completer: :class:`prompt_toolkit.completer` 54 | :param completer: An instance of `prompt_toolkit.completer`. 55 | 56 | :type hacker_news_cli: :class:`hacker_news_cli.HackerNewsCli` 57 | :param hacker_news_cli: An instance of `hacker_news_cli.HackerNewsCli`. 58 | 59 | :type key_manager: :class:`prompt_toolkit.key_binding.manager. 60 | KeyBindingManager` 61 | :param key_manager: An instance of `prompt_toolkit.key_binding.manager. 62 | KeyBindingManager`. 63 | 64 | :type PAGINATE_CMD: str (const) 65 | :param PAGINATE_CMD: The command to enable pagination. 66 | 67 | :type paginate_comments: bool 68 | :param paginate_comments: Determines whether to paginate 69 | comments. 70 | 71 | :type text_utils: :class:`util.TextUtils` 72 | :param text_utils: An instance of `util.TextUtils`. 73 | 74 | :type theme: str 75 | :param theme: The prompt_toolkit lexer theme. 76 | """ 77 | 78 | CMDS_NO_PAGINATE = [ 79 | '-b', 80 | '--browser', 81 | '>', 82 | '<', 83 | ] 84 | CMDS_ENABLE_PAGINATE = [ 85 | '-cq', 86 | '--comments_regex_query', 87 | '-c', 88 | '--comments', 89 | '-cr', 90 | '--comments_recent', 91 | '-cu', 92 | '--comments_unseen', 93 | '-ch', 94 | '--comments_hide_non_matching', 95 | 'hiring', 96 | 'freelance', 97 | ] 98 | PAGINATE_CMD = ' | less -r' 99 | PAGINATE_CMD_WIN = ' | more' 100 | 101 | def __init__(self): 102 | self.cli = None 103 | self.key_manager = None 104 | self.theme = 'vim' 105 | self.paginate_comments = True 106 | self.hacker_news_cli = HackerNewsCli() 107 | self.text_utils = TextUtils() 108 | self.completer = Completer(fuzzy_match=False, 109 | text_utils=self.text_utils) 110 | self._create_cli() 111 | if platform.system() == 'Windows': 112 | self.CMDS_ENABLE_PAGINATE.append('view') 113 | 114 | def _create_key_manager(self): 115 | """Create the :class:`KeyManager`. 116 | 117 | The inputs to KeyManager are expected to be callable, so we can't 118 | use the standard @property and @attrib.setter for these attributes. 119 | Lambdas cannot contain assignments so we're forced to define setters. 120 | 121 | :rtype: :class:`prompt_toolkit.key_binding.manager 122 | :return: KeyBindingManager with callables to set the toolbar options. 123 | """ 124 | 125 | def set_paginate_comments(paginate_comments): 126 | """Setter for paginating comments mode. 127 | 128 | :type paginate: bool 129 | :param paginate: The paginate comments mode. 130 | """ 131 | self.paginate_comments = paginate_comments 132 | 133 | return KeyManager( 134 | set_paginate_comments, lambda: self.paginate_comments) 135 | 136 | def _create_cli(self): 137 | """Create the prompt_toolkit's CommandLineInterface.""" 138 | history = FileHistory(os.path.expanduser('~/.haxornewshistory')) 139 | toolbar = Toolbar(lambda: self.paginate_comments) 140 | layout = create_default_layout( 141 | message=u'haxor> ', 142 | reserve_space_for_menu=8, 143 | get_bottom_toolbar_tokens=toolbar.handler, 144 | ) 145 | cli_buffer = Buffer( 146 | history=history, 147 | auto_suggest=AutoSuggestFromHistory(), 148 | enable_history_search=True, 149 | completer=self.completer, 150 | complete_while_typing=Always(), 151 | accept_action=AcceptAction.RETURN_DOCUMENT) 152 | self.key_manager = self._create_key_manager() 153 | style_factory = StyleFactory(self.theme) 154 | application = Application( 155 | mouse_support=False, 156 | style=style_factory.style, 157 | layout=layout, 158 | buffer=cli_buffer, 159 | key_bindings_registry=self.key_manager.manager.registry, 160 | on_exit=AbortAction.RAISE_EXCEPTION, 161 | on_abort=AbortAction.RETRY, 162 | ignore_case=True) 163 | eventloop = create_eventloop() 164 | self.cli = CommandLineInterface( 165 | application=application, 166 | eventloop=eventloop) 167 | 168 | def _add_comment_pagination(self, document_text): 169 | """Add the command to enable comment pagination where applicable. 170 | 171 | Pagination is enabled if the command views comments and the 172 | browser flag is not enabled. 173 | 174 | :type document_text: str 175 | :param document_text: The input command. 176 | 177 | :rtype: str 178 | :return: the input command with pagination enabled. 179 | """ 180 | if not any(sub in document_text for sub in self.CMDS_NO_PAGINATE): 181 | if any(sub in document_text for sub in self.CMDS_ENABLE_PAGINATE): 182 | if platform.system() == 'Windows': 183 | document_text += self.PAGINATE_CMD_WIN 184 | else: 185 | document_text += self.PAGINATE_CMD 186 | return document_text 187 | 188 | def handle_exit(self, document): 189 | """Exits if the user typed exit or quit 190 | 191 | :type document: :class:`prompt_toolkit.document.Document` 192 | :param document: An instance of `prompt_toolkit.document.Document`. 193 | """ 194 | if document.text in ('exit', 'quit'): 195 | sys.exit() 196 | 197 | def run_command(self, document): 198 | """Run the given command. 199 | 200 | :type document: :class:`prompt_toolkit.document.Document` 201 | :param document: An instance of `prompt_toolkit.document.Document`. 202 | """ 203 | try: 204 | if self.paginate_comments: 205 | text = document.text 206 | text = self._add_comment_pagination(text) 207 | subprocess.call(text, shell=True) 208 | except Exception as e: 209 | click.secho(e, fg='red') 210 | 211 | def run_cli(self): 212 | """Run the main loop.""" 213 | click.echo('Version: ' + __version__) 214 | click.echo('Syntax: hn [params] [options]') 215 | while True: 216 | document = self.cli.run(reset_current_buffer=True) 217 | self.handle_exit(document) 218 | self.run_command(document) 219 | -------------------------------------------------------------------------------- /haxor_news/keys.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # Copyright 2015 Donne Martin. All Rights Reserved. 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"). You 6 | # may not use this file except in compliance with the License. A copy of 7 | # the License is located at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # or in the "license" file accompanying this file. This file is 12 | # distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF 13 | # ANY KIND, either express or implied. See the License for the specific 14 | # language governing permissions and limitations under the License. 15 | 16 | from __future__ import print_function 17 | from prompt_toolkit.key_binding.manager import KeyBindingManager 18 | from prompt_toolkit.keys import Keys 19 | 20 | 21 | class KeyManager(object): 22 | """A custom :class:`prompt_toolkit.KeyBindingManager`. 23 | 24 | Handle togging of: 25 | * Comment pagination. 26 | 27 | :type manager: :class:`prompt_toolkit.key_binding.manager. 28 | KeyBindingManager` 29 | :param manager: An instance of `prompt_toolkit.key_binding.manager. 30 | KeyBindingManager`. 31 | """ 32 | 33 | def __init__(self, set_paginate_comments, get_paginate_comments): 34 | self.manager = None 35 | self._create_key_manager(set_paginate_comments, get_paginate_comments) 36 | 37 | def _create_key_manager(self, set_paginate_comments, get_paginate_comments): 38 | """Create and initialize the keybinding manager. 39 | 40 | :type set_paginate_comments: callable 41 | :param set_paginate_comments: Sets the paginate comments config. 42 | 43 | :type get_paginate_comments: callable 44 | :param get_paginate_comments: Gets the paginate comments config. 45 | 46 | :rtype: :class:`prompt_toolkit.key_binding.manager. 47 | KeyBindingManager` 48 | :return: An instance of `prompt_toolkit.key_binding.manager. 49 | KeyBindingManager`. 50 | """ 51 | assert callable(set_paginate_comments) 52 | assert callable(get_paginate_comments) 53 | self.manager = KeyBindingManager( 54 | enable_search=True, 55 | enable_abort_and_exit_bindings=True, 56 | enable_system_bindings=True, 57 | enable_auto_suggest_bindings=True) 58 | 59 | @self.manager.registry.add_binding(Keys.F2) 60 | def handle_f2(_): 61 | """Enable/Disable paginate comments mode. 62 | 63 | This method is currently disabled. 64 | 65 | :type _: :class:`prompt_toolkit.Event` 66 | :param _: (Unused) 67 | 68 | :raises: :class:`EOFError` to quit the app. 69 | """ 70 | # set_paginate_comments(not get_paginate_comments()) 71 | pass 72 | 73 | @self.manager.registry.add_binding(Keys.F10) 74 | def handle_f10(_): 75 | """Quit when the `F10` key is pressed. 76 | 77 | :type _: :class:`prompt_toolkit.Event` 78 | :param _: (Unused) 79 | 80 | :raises: :class:`EOFError` to quit the app. 81 | """ 82 | raise EOFError 83 | 84 | @self.manager.registry.add_binding(Keys.ControlSpace) 85 | def handle_ctrl_space(event): 86 | """Initialize autocompletion at the cursor. 87 | 88 | If the autocompletion menu is not showing, display it with the 89 | appropriate completions for the context. 90 | 91 | If the menu is showing, select the next completion. 92 | 93 | :type event: :class:`prompt_toolkit.Event` 94 | :param event: An instance of `prompt_toolkit.Event`. 95 | """ 96 | b = event.cli.current_buffer 97 | if b.complete_state: 98 | b.complete_next() 99 | else: 100 | event.cli.start_completion(select_first=False) 101 | -------------------------------------------------------------------------------- /haxor_news/lib/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # Copyright 2015 Donne Martin. All Rights Reserved. 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"). You 6 | # may not use this file except in compliance with the License. A copy of 7 | # the License is located at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # or in the "license" file accompanying this file. This file is 12 | # distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF 13 | # ANY KIND, either express or implied. See the License for the specific 14 | # language governing permissions and limitations under the License. 15 | -------------------------------------------------------------------------------- /haxor_news/lib/debug_timer.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # Copyright 2015 Donne Martin. All Rights Reserved. 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"). You 6 | # may not use this file except in compliance with the License. A copy of 7 | # the License is located at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # or in the "license" file accompanying this file. This file is 12 | # distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF 13 | # ANY KIND, either express or implied. See the License for the specific 14 | # language governing permissions and limitations under the License. 15 | 16 | from __future__ import print_function 17 | from __future__ import division 18 | 19 | import click 20 | import time 21 | 22 | 23 | def timeit(method): 24 | """From: https://www.andreas-jung.com/contents/a-python-decorator-for-measuring-the-execution-time-of-methods # NOQA 25 | """ 26 | def timed(*args, **kw): 27 | ts = time.time() 28 | result = method(*args, **kw) 29 | te = time.time() 30 | message = '%r (%r, %r) %2.2f sec' % (method.__name__, args, kw, te-ts) 31 | click.secho(message + '\n', fg='red') 32 | return result 33 | return timed 34 | -------------------------------------------------------------------------------- /haxor_news/lib/haxor/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # Copyright 2015 Donne Martin. All Rights Reserved. 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"). You 6 | # may not use this file except in compliance with the License. A copy of 7 | # the License is located at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # or in the "license" file accompanying this file. This file is 12 | # distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF 13 | # ANY KIND, either express or implied. See the License for the specific 14 | # language governing permissions and limitations under the License. 15 | -------------------------------------------------------------------------------- /haxor_news/lib/haxor/haxor.py: -------------------------------------------------------------------------------- 1 | # The MIT License (MIT) 2 | 3 | # Copyright (c) 2014-15 Avinash Sajjanshetty 4 | 5 | # Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | # this software and associated documentation files (the "Software"), to deal in 7 | # the Software without restriction, including without limitation the rights to 8 | # use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | # the Software, and to permit persons to whom the Software is furnished to do so, 10 | # 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, FITNESS 17 | # FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | # COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | # IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | # CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | 22 | """ 23 | haxor 24 | Unofficial Python wrapper for official Hacker News API 25 | 26 | @author avinash sajjanshetty 27 | @email hi@avi.im 28 | """ 29 | 30 | from __future__ import absolute_import 31 | from __future__ import unicode_literals 32 | import datetime 33 | import json 34 | import sys 35 | 36 | import requests 37 | 38 | from .settings import supported_api_versions 39 | 40 | __all__ = [ 41 | 'User', 42 | 'Item', 43 | 'HackerNewsApi', 44 | 'InvalidAPIVersion', 45 | 'InvalidItemID', 46 | 'InvalidUserID'] 47 | 48 | 49 | class InvalidItemID(Exception): 50 | pass 51 | 52 | 53 | class InvalidUserID(Exception): 54 | pass 55 | 56 | 57 | class InvalidAPIVersion(Exception): 58 | pass 59 | 60 | 61 | class HTTPError(Exception): 62 | pass 63 | 64 | 65 | class HackerNewsApi(object): 66 | 67 | def __init__(self, version='v0'): 68 | """ 69 | Args: 70 | version (string): specifies Hacker News API version. Default is `v0`. 71 | 72 | Raises: 73 | InvalidAPIVersion: If Hacker News version is not supported. 74 | 75 | """ 76 | self.session = requests.Session() 77 | try: 78 | self.base_url = supported_api_versions[version] 79 | except KeyError: 80 | raise InvalidAPIVersion 81 | 82 | def _get(self, url): 83 | """Internal method used for GET requests 84 | 85 | Args: 86 | url (string): URL to send GET. 87 | 88 | Returns: 89 | requests' response object 90 | 91 | Raises: 92 | HTTPError: If HTTP request failed. 93 | 94 | """ 95 | response = self.session.get(url) 96 | if response.status_code == requests.codes.ok: 97 | return response 98 | else: 99 | raise HTTPError 100 | 101 | def _get_page(self, page): 102 | return self._get('{0}{1}.json'.format(self.base_url, page)) 103 | 104 | def _get_page_param(self, page, param): 105 | return self._get('{0}{1}/{2}.json'.format(self.base_url, page, param)) 106 | 107 | def get_item(self, item_id): 108 | """Returns Hacker News `Item` object. 109 | 110 | Args: 111 | item_id (int or string): Unique item id of Hacker News story, comment etc. 112 | 113 | Returns: 114 | `Item` object representing Hacker News item. 115 | 116 | Raises: 117 | InvalidItemID: If corresponding Hacker News story does not exist. 118 | 119 | """ 120 | 121 | response = self._get_page_param('item', item_id).json() 122 | 123 | if not response: 124 | raise InvalidItemID 125 | 126 | return Item(response) 127 | 128 | def get_user(self, user_id): 129 | """Returns Hacker News `User` object. 130 | 131 | Args: 132 | user_id (string): unique user id of a Hacker News user. 133 | 134 | Returns: 135 | `User` object representing a user on Hacker News. 136 | 137 | Raises: 138 | InvalidUserID: If no such user exists on Hacker News. 139 | 140 | """ 141 | response = self._get_page_param('user', user_id).json() 142 | 143 | if not response: 144 | raise InvalidUserID 145 | 146 | return User(response) 147 | 148 | def top_stories(self, limit=None): 149 | """Returns list of item ids of current top stories 150 | 151 | Args: 152 | limit (int): specifies the number of stories to be returned. 153 | 154 | Returns: 155 | `list` object containing ids of top stories. 156 | """ 157 | return self._get_page('topstories').json()[:limit] 158 | 159 | def new_stories(self, limit=None): 160 | """Returns list of item ids of current new stories 161 | 162 | Args: 163 | limit (int): specifies the number of stories to be returned. 164 | 165 | Returns: 166 | `list` object containing ids of new stories. 167 | """ 168 | return self._get_page('newstories').json()[:limit] 169 | 170 | def ask_stories(self, limit=None): 171 | """Returns list of item ids of latest Ask HN stories 172 | 173 | Args: 174 | limit (int): specifies the number of stories to be returned. 175 | 176 | Returns: 177 | `list` object containing ids of Ask HN stories. 178 | """ 179 | return self._get_page('askstories').json()[:limit] 180 | 181 | def best_stories(self, limit=None): 182 | """Returns list of item ids of best HN stories 183 | 184 | Args: 185 | limit (int): specifies the number of stories to be returned. 186 | 187 | Returns: 188 | `list` object containing ids of best stories. 189 | """ 190 | return self._get_page('beststories').json()[:limit] 191 | 192 | def show_stories(self, limit=None): 193 | """Returns list of item ids of latest Show HN stories 194 | 195 | Args: 196 | limit (int): specifies the number of stories to be returned. 197 | 198 | Returns: 199 | `list` object containing ids of Show HN stories. 200 | """ 201 | return self._get_page('showstories').json()[:limit] 202 | 203 | def job_stories(self, limit=None): 204 | """Returns list of item ids of latest Job stories 205 | 206 | Args: 207 | limit (int): specifies the number of stories to be returned. 208 | 209 | Returns: 210 | `list` object containing ids of Job stories. 211 | """ 212 | return self._get_page('jobstories').json()[:limit] 213 | 214 | def updates(self): 215 | """Returns list of item ids and user ids that have been 216 | changed/updated recently. 217 | 218 | Returns: 219 | `dict` with two keys whose values are `list` objects 220 | """ 221 | return self._get_page('updates').json() 222 | 223 | def get_max_item(self): 224 | """Returns list of item ids of current top stories 225 | 226 | Args: 227 | limit (int): specifies the number of stories to be returned. 228 | 229 | Returns: 230 | `int` if successful. 231 | """ 232 | return self._get_page('maxitem').json() 233 | 234 | 235 | class Item(object): 236 | 237 | """ 238 | Represents stories, comments, jobs, Ask HNs and polls 239 | """ 240 | 241 | def __init__(self, data): 242 | self.item_id = data.get('id') 243 | self.deleted = data.get('deleted') 244 | self.item_type = data.get('type') 245 | self.by = data.get('by') 246 | self.submission_time = datetime.datetime.fromtimestamp( 247 | data.get( 248 | 'time', 249 | 0)) 250 | self.text = data.get('text') 251 | self.dead = data.get('dead') 252 | self.parent = data.get('parent') 253 | self.kids = data.get('kids') 254 | self.url = data.get('url') 255 | self.score = data.get('score') 256 | self.title = data.get('title') 257 | self.parts = data.get('parts') 258 | self.descendants = data.get('descendants') 259 | self.raw = json.dumps(data) 260 | 261 | def __repr__(self): 262 | retval = ''.format( 263 | self.item_id, self.title) 264 | if sys.version_info.major < 3: 265 | return retval.encode('utf-8', errors='backslashreplace') 266 | return retval 267 | 268 | 269 | class User(object): 270 | 271 | """ 272 | Represents a hacker i.e. a user on Hacker News 273 | """ 274 | 275 | def __init__(self, data): 276 | self.user_id = data.get('id') 277 | self.delay = data.get('delay') 278 | self.created = datetime.datetime.fromtimestamp(data.get('created', 0)) 279 | self.karma = data.get('karma') 280 | self.about = data.get('about') 281 | self.submitted = data.get('submitted') 282 | self.raw = json.dumps(data) 283 | 284 | def __repr__(self): 285 | retval = ''.format(self.user_id) 286 | if sys.version_info.major < 3: 287 | return retval.encode('utf-8', errors='backslashreplace') 288 | return retval 289 | -------------------------------------------------------------------------------- /haxor_news/lib/haxor/settings.py: -------------------------------------------------------------------------------- 1 | # The MIT License (MIT) 2 | 3 | # Copyright (c) 2014-15 Avinash Sajjanshetty 4 | 5 | # Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | # this software and associated documentation files (the "Software"), to deal in 7 | # the Software without restriction, including without limitation the rights to 8 | # use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | # the Software, and to permit persons to whom the Software is furnished to do so, 10 | # 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, FITNESS 17 | # FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | # COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | # IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | # CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | 22 | supported_api_versions = {'v0': 'https://hacker-news.firebaseio.com/v0/' } 23 | -------------------------------------------------------------------------------- /haxor_news/lib/html2text/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # Copyright 2015 Donne Martin. All Rights Reserved. 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"). You 6 | # may not use this file except in compliance with the License. A copy of 7 | # the License is located at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # or in the "license" file accompanying this file. This file is 12 | # distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF 13 | # ANY KIND, either express or implied. See the License for the specific 14 | # language governing permissions and limitations under the License. 15 | -------------------------------------------------------------------------------- /haxor_news/lib/pretty_date_time.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # Copyright 2015 Donne Martin. All Rights Reserved. 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"). You 6 | # may not use this file except in compliance with the License. A copy of 7 | # the License is located at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # or in the "license" file accompanying this file. This file is 12 | # distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF 13 | # ANY KIND, either express or implied. See the License for the specific 14 | # language governing permissions and limitations under the License. 15 | 16 | from __future__ import print_function 17 | from __future__ import division 18 | 19 | from datetime import datetime 20 | 21 | 22 | def pretty_date_time(date_time): 23 | """Print a pretty datetime similar to what's seen on Hacker News. 24 | 25 | Gets a datetime object or a int() Epoch timestamp and return a 26 | pretty string like 'an hour ago', 'Yesterday', '3 months ago', 27 | 'just now', etc. 28 | 29 | Adapted from: http://stackoverflow.com/questions/1551382/user-friendly-time-format-in-python # NOQA 30 | 31 | :type foo: :class:`datetime.datetime` 32 | :param foo: An instance of `datetime.datetime`. 33 | 34 | :rtype: str 35 | :return: the pretty datetime. 36 | """ 37 | now = datetime.now() 38 | if type(date_time) is int: 39 | diff = now - datetime.fromtimestamp(date_time) 40 | elif isinstance(date_time, datetime): 41 | diff = now - date_time 42 | elif not date_time: 43 | diff = now - now 44 | second_diff = diff.seconds 45 | day_diff = diff.days 46 | if day_diff < 0: 47 | return '' 48 | if day_diff == 0: 49 | if second_diff < 10: 50 | return "just now" 51 | if second_diff < 60: 52 | return str(second_diff) + " seconds ago" 53 | if second_diff < 120: 54 | return "1 minute ago" 55 | if second_diff < 3600: 56 | return str(second_diff // 60) + " minutes ago" 57 | if second_diff < 7200: 58 | return "1 hour ago" 59 | if second_diff < 86400: 60 | return str(second_diff // 3600) + " hours ago" 61 | if day_diff == 1: 62 | return "Yesterday" 63 | if day_diff < 7: 64 | return str(day_diff) + " days ago" 65 | if day_diff < 31: 66 | return str(day_diff // 7) + " week(s) ago" 67 | if day_diff < 365: 68 | return str(day_diff // 30) + " month(s) ago" 69 | return str(day_diff // 365) + " year(s) ago" 70 | -------------------------------------------------------------------------------- /haxor_news/main.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | # Copyright 2015 Donne Martin. All Rights Reserved. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"). You 7 | # may not use this file except in compliance with the License. A copy of 8 | # the License is located at 9 | # 10 | # http://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # or in the "license" file accompanying this file. This file is 13 | # distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF 14 | # ANY KIND, either express or implied. See the License for the specific 15 | # language governing permissions and limitations under the License. 16 | 17 | from __future__ import print_function 18 | 19 | from .haxor import Haxor 20 | 21 | 22 | def cli(): 23 | """Creates and calls Haxor.""" 24 | try: 25 | haxor = Haxor() 26 | haxor.run_cli() 27 | except (EOFError, KeyboardInterrupt): 28 | haxor.cli.set_return_value(None) 29 | 30 | 31 | if __name__ == "__main__": 32 | cli() 33 | -------------------------------------------------------------------------------- /haxor_news/main_cli.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | # Copyright 2015 Donne Martin. All Rights Reserved. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"). You 7 | # may not use this file except in compliance with the License. A copy of 8 | # the License is located at 9 | # 10 | # http://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # or in the "license" file accompanying this file. This file is 13 | # distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF 14 | # ANY KIND, either express or implied. See the License for the specific 15 | # language governing permissions and limitations under the License. 16 | 17 | from __future__ import print_function 18 | 19 | from .hacker_news_cli import HackerNewsCli 20 | 21 | 22 | def cli(): 23 | """Creates and calls Haxor.""" 24 | haxor_news = HackerNewsCli() 25 | haxor_news.cli() 26 | 27 | 28 | if __name__ == "__main__": 29 | cli() 30 | -------------------------------------------------------------------------------- /haxor_news/onions.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # Copyright 2015 Donne Martin. All Rights Reserved. 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"). You 6 | # may not use this file except in compliance with the License. A copy of 7 | # the License is located at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # or in the "license" file accompanying this file. This file is 12 | # distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF 13 | # ANY KIND, either express or implied. See the License for the specific 14 | # language governing permissions and limitations under the License. 15 | 16 | onions = [ 17 | u'Developer Accused Of Unreadable Code Refuses To Comment.', 18 | u'Top 9 Reasons Arrays Are Confusing, You Won’t Believe #0.', 19 | u'26 Variable Names For Busy Developers: a,b,c,d,e,f,g,h,i,j,k,l,m,n,o,p,q,r,s,t,u,v,w,x,y,z.', 20 | u'Datacenter 1 Uptime: 94.6%, Datacenter 2 Uptime: 92.0%, Cloud Sales Rep: "We have 186.6% Uptime."', 21 | u'Hackathon Developer Gets Development Environment Working With Two Whole Hours To Go.', 22 | u'Recruiter Confident Java Developer Can Learn The Script Part.', 23 | u"Ops Guy Doesn't Seem To Care That Code Worked Locally.", 24 | u'Google announces 93% of users are now on one of the last 16 versions of Android #io14 #GoogleIO2014 #AndroidOne.', 25 | u'Developer who inherited 5-year-old Rails codebase secretly hoping for company collapse.', 26 | u'More realistic Java Garbage Collector runs only on Tuesdays at 4am and makes an obscene amount of noise.', 27 | u'Daft Punk admits #1 hit song "Up All Night To Get Lucky" is actually about programming.', 28 | u'Area developer breaks own leg to get out of daily standup.', 29 | u'New node.js co-working space has 1 table and everyone takes turns.', 30 | u'New Github IDE can tab-complete an entire Rails app.', 31 | u'Predictive analytics startup uses own software to predict lack of adoption.', 32 | u'Top 5 World Languages: 1. Mandarin 2. English 3. Spanish 4. Hindi 5. JavaScript.', 33 | u'A gorgeous, opinionated JavaScript microframework hand-delivered to your door every month.', 34 | u'VPNs are pieces of SSH IT.', 35 | u'Q: What do you call a VC who writes code? A: A Rich Text Editor.', 36 | u"My boss loved The Mythical Man Month. He gave me 2 copies and said 'here, it'll take you half the time.'", 37 | u'Hacker News is DOWN, but your chances of getting into YC if you know how to scale a plain text website are UP.', 38 | u'TIL the movie Inception was based on node.js callbacks.', 39 | u"Getting the NSA a present this year? Don't spoil the surprise by buying it online.", 40 | u'"Losing data is a feature." – Why Snapchat loves MongoDB.', 41 | u'Leaked NSA source code contains no private methods.', 42 | u'Startup naming trends: -ster 1999, -ify 2006, -ly 2009, .io 2013.', 43 | u"BREAKING: Rails 5 will run each HTTP request in it's own Docker container.", 44 | u'Programming is 1% inspiration, 99% trying to get your environment working.', 45 | u'Microsoft board members vote 4-3 to temporarily raise the technical debt ceiling so work on Windows 8 can continue.', 46 | u'Our retention strategy is Stockholm Syndrome.', 47 | u"OkCupid has suspended Martin Odersky. The creator of Scala uploaded a 37,000 word answer to profile question 'What is your type?'", 48 | u'MongoDB adds a "PLEASE" keyword for inserts, boosting chance that data is stored to above 75%.', 49 | u'Self-proclaimed "visionary" seeks technical cofounder.', 50 | u'Obama – "The national technical debt is increasing at an alarming rate. We have to stop writing PHP."', 51 | u'99% of programming is choosing a templating language and the rest is explaining your decision in a blog post.', 52 | u'$ git diff + $ git commit -am "Mobile ready".', 53 | u"Hotshot systems architect seeks maintenance guy to take over last year's project.", 54 | u'JavaScript library with no code but cool logo hits 5,000 stars.', 55 | u'Have to write SQL? SELECT * FROM STACK OVERFLOW.', 56 | u'Novice programmers swear when they get stuck. Expert programmers swear every fucking second.', 57 | u'Enterprise CTO not impressed by pair programming. "We often have hundreds of developers working on the same thing."', 58 | u'Q: Why is Java slow? A: Because Time is a Long.', 59 | u"`git push --force` I got 99 problems but a merge conflict ain't one.", 60 | u'Recruiter mistakenly hires real ninja. Team finds this totally sweet until ninja commits seppuku when told to use graphical IDE.', 61 | ] 62 | -------------------------------------------------------------------------------- /haxor_news/settings.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # Copyright 2015 Donne Martin. All Rights Reserved. 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"). You 6 | # may not use this file except in compliance with the License. A copy of 7 | # the License is located at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # or in the "license" file accompanying this file. This file is 12 | # distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF 13 | # ANY KIND, either express or implied. See the License for the specific 14 | # language governing permissions and limitations under the License. 15 | 16 | # Updated monthly, see HackerNewsCli.hiring docstring. 17 | who_is_hiring_post_id = 26661443 18 | freelancer_post_id = 26661442 19 | -------------------------------------------------------------------------------- /haxor_news/style.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 2 | 3 | # Copyright 2015 Donne Martin. All Rights Reserved. 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"). You 6 | # may not use this file except in compliance with the License. A copy of 7 | # the License is located at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # or in the "license" file accompanying this file. This file is 12 | # distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF 13 | # ANY KIND, either express or implied. See the License for the specific 14 | # language governing permissions and limitations under the License. 15 | 16 | from pygments.token import Token 17 | from pygments.util import ClassNotFound 18 | from prompt_toolkit.styles import default_style_extensions, style_from_dict 19 | import pygments.styles 20 | 21 | 22 | class StyleFactory(object): 23 | """Provide styles for the autocomplete menu and the toolbar. 24 | 25 | :type style: :class:`pygments.style.StyleMeta` 26 | :param style: An instance of `pygments.style.StyleMeta`. 27 | """ 28 | 29 | def __init__(self, name): 30 | self.style = self.style_factory(name) 31 | 32 | def style_factory(self, name): 33 | """Retrieve the specified pygments style. 34 | 35 | If the specified style is not found, the vim style is returned. 36 | 37 | :type style_name: str 38 | :param style_name: The pygments style name. 39 | 40 | :rtype: :class:`pygments.style.StyleMeta` 41 | :return: An instance of `pygments.style.StyleMeta`. 42 | """ 43 | try: 44 | style = pygments.styles.get_style_by_name(name) 45 | except ClassNotFound: 46 | style = pygments.styles.get_style_by_name('native') 47 | 48 | # Create styles dictionary. 49 | styles = {} 50 | styles.update(style.styles) 51 | styles.update(default_style_extensions) 52 | styles.update({ 53 | Token.Menu.Completions.Completion.Current: 'bg:#00aaaa #000000', 54 | Token.Menu.Completions.Completion: 'bg:#008888 #ffffff', 55 | Token.Menu.Completions.Meta.Current: 'bg:#00aaaa #000000', 56 | Token.Menu.Completions.Meta: 'bg:#00aaaa #ffffff', 57 | Token.Menu.Completions.ProgressButton: 'bg:#003333', 58 | Token.Menu.Completions.ProgressBar: 'bg:#00aaaa', 59 | Token.Scrollbar: 'bg:#00aaaa', 60 | Token.Scrollbar.Button: 'bg:#003333', 61 | Token.Toolbar: 'bg:#222222 #cccccc', 62 | Token.Toolbar.Off: 'bg:#222222 #696969', 63 | Token.Toolbar.On: 'bg:#222222 #ffffff', 64 | Token.Toolbar.Search: 'noinherit bold', 65 | Token.Toolbar.Search.Text: 'nobold', 66 | Token.Toolbar.System: 'noinherit bold', 67 | Token.Toolbar.Arg: 'noinherit bold', 68 | Token.Toolbar.Arg.Text: 'nobold' 69 | }) 70 | 71 | return style_from_dict(styles) 72 | -------------------------------------------------------------------------------- /haxor_news/toolbar.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # Copyright 2015 Donne Martin. All Rights Reserved. 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"). You 6 | # may not use this file except in compliance with the License. A copy of 7 | # the License is located at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # or in the "license" file accompanying this file. This file is 12 | # distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF 13 | # ANY KIND, either express or implied. See the License for the specific 14 | # language governing permissions and limitations under the License. 15 | 16 | from __future__ import print_function 17 | from pygments.token import Token 18 | 19 | 20 | class Toolbar(object): 21 | """Show information about the aws-shell in a tool bar. 22 | 23 | :type handler: callable 24 | :param handler: Wraps the callable `get_toolbar_items`. 25 | """ 26 | 27 | def __init__(self, paginate_comments_cfg): 28 | self.handler = self._create_toolbar_handler(paginate_comments_cfg) 29 | 30 | def _create_toolbar_handler(self, paginate_comments_cfg): 31 | """Create the toolbar handler. 32 | 33 | :type paginate_comments_cfg: callable 34 | :param paginate_comments_cfg: Specifies whether to paginate comments. 35 | 36 | :rtype: callable 37 | :returns: get_toolbar_items. 38 | """ 39 | assert callable(paginate_comments_cfg) 40 | 41 | def get_toolbar_items(_): 42 | """Return the toolbar items. 43 | 44 | :type _: :class:`prompt_toolkit.Cli` 45 | :param _: (Unused) 46 | 47 | :rtype: list 48 | :return: A list of (pygments.Token.Toolbar, str). 49 | """ 50 | # if paginate_comments_cfg(): 51 | # paginate_comments_token = Token.Toolbar.On 52 | # paginate_comments = 'ON' 53 | # else: 54 | # paginate_comments_token = Token.Toolbar.Off 55 | # paginate_comments = 'OFF' 56 | return [ 57 | # (paginate_comments_token, 58 | # ' [F2] Paginate Comments: {0} '.format(paginate_comments)), 59 | (Token.Toolbar, ' [F10] Exit ') 60 | ] 61 | 62 | return get_toolbar_items 63 | -------------------------------------------------------------------------------- /haxor_news/utils.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # Copyright 2015 Donne Martin. All Rights Reserved. 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"). You 6 | # may not use this file except in compliance with the License. A copy of 7 | # the License is located at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # or in the "license" file accompanying this file. This file is 12 | # distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF 13 | # ANY KIND, either express or implied. See the License for the specific 14 | # language governing permissions and limitations under the License. 15 | 16 | from __future__ import unicode_literals 17 | from __future__ import print_function 18 | 19 | import re 20 | 21 | import six 22 | import shlex 23 | from prompt_toolkit.completion import Completion 24 | 25 | from .completions import META_LOOKUP 26 | 27 | 28 | class TextUtils(object): 29 | """Utilities for parsing and matching text.""" 30 | 31 | def find_matches(self, word, collection, fuzzy): 32 | """Find all matches in collection for word. 33 | 34 | :type word: str 35 | :param word: The word before the cursor. 36 | 37 | :type collection: iterable 38 | :param collection: A collection of words to match. 39 | 40 | :type fuzzy: bool 41 | :param fuzzy: Determines whether to use fuzzy matching. 42 | 43 | :rtype: generator 44 | :return: Yields an instance of `prompt_toolkit.completion.Completion`. 45 | """ 46 | word = self._last_token(word).lower() 47 | for suggestion in self._find_collection_matches( 48 | word, collection, fuzzy): 49 | yield suggestion 50 | 51 | def get_tokens(self, text): 52 | """Parse out all tokens. 53 | 54 | :type text: str 55 | :param text: A string to be split into tokens. 56 | 57 | :rtype: list 58 | :return: A list of strings for each word in the text. 59 | """ 60 | if text is not None: 61 | text = text.strip() 62 | words = self._safe_split(text) 63 | return words 64 | return [] 65 | 66 | def _last_token(self, text): 67 | """Find the last word in text. 68 | 69 | :type text: str 70 | :param text: A string to parse and obtain the last word. 71 | 72 | :rtype: str 73 | :return: The last word in the text. 74 | """ 75 | if text is not None: 76 | text = text.strip() 77 | if len(text) > 0: 78 | word = self._safe_split(text)[-1] 79 | word = word.strip() 80 | return word 81 | return '' 82 | 83 | def _fuzzy_finder(self, text, collection, case_sensitive=True): 84 | """Customized fuzzy finder with optional case-insensitive matching. 85 | 86 | Adapted from: https://github.com/amjith/fuzzyfinder. 87 | 88 | :type text: str 89 | :param text: Input string entered by user. 90 | 91 | :type collection: iterable 92 | :param collection: collection of strings which will be filtered based 93 | on the input `text`. 94 | 95 | :type case_sensitive: bool 96 | :param case_sensitive: Determines whether the find will be case 97 | sensitive. 98 | 99 | :rtype: generator 100 | :return: Yields a list of suggestions narrowed down from `collections` 101 | using the `text` input. 102 | """ 103 | suggestions = [] 104 | if case_sensitive: 105 | pat = '.*?'.join(map(re.escape, text)) 106 | else: 107 | pat = '.*?'.join(map(re.escape, text.lower())) 108 | regex = re.compile(pat) 109 | for item in collection: 110 | if case_sensitive: 111 | r = regex.search(item) 112 | else: 113 | r = regex.search(item.lower()) 114 | if r: 115 | suggestions.append((len(r.group()), r.start(), item)) 116 | return (z for _, _, z in sorted(suggestions)) 117 | 118 | def _find_collection_matches(self, word, collection, fuzzy): 119 | """Yield all matching names in list. 120 | 121 | :type word: str 122 | :param word: The word before the cursor. 123 | 124 | :type collection: iterable 125 | :param collection: A collection of words to match. 126 | 127 | :type fuzzy: bool 128 | :param fuzzy: Determines whether to use fuzzy matching. 129 | 130 | :rtype: generator 131 | :return: Yields an instance of `prompt_toolkit.completion.Completion`. 132 | """ 133 | word = word.lower() 134 | if fuzzy: 135 | for suggestion in self._fuzzy_finder(word, 136 | collection, 137 | case_sensitive=False): 138 | yield Completion(suggestion, 139 | -len(word), 140 | display_meta='display_meta') 141 | else: 142 | for name in sorted(collection): 143 | if name.lower().startswith(word) or not word: 144 | display = None 145 | display_meta = None 146 | if name in META_LOOKUP: 147 | display_meta = META_LOOKUP[name] 148 | yield Completion(name, 149 | -len(word), 150 | display=display, 151 | display_meta=display_meta) 152 | 153 | def _shlex_split(self, text): 154 | """Wrapper for shlex, because it does not seem to handle unicode in 2.6. 155 | 156 | :type text: str 157 | :param text: A string to split. 158 | 159 | :rtype: list 160 | :return: A list that contains words for each split element of text. 161 | """ 162 | if six.PY2: 163 | text = text.encode('utf-8') 164 | return shlex.split(text) 165 | 166 | def _safe_split(self, text): 167 | """Safely splits the input text. 168 | 169 | Shlex can't always split. For example, "\" crashes the completer. 170 | 171 | :type text: str 172 | :param text: A string to split. 173 | 174 | :rtype: list 175 | :return: A list that contains words for each split element of text. 176 | """ 177 | try: 178 | words = self._shlex_split(text) 179 | return words 180 | except: 181 | return text 182 | -------------------------------------------------------------------------------- /haxor_news/web_viewer.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | # -*- coding: utf-8 -*- 4 | 5 | # Copyright 2015 Donne Martin. All Rights Reserved. 6 | # 7 | # Licensed under the Apache License, Version 2.0 (the "License"). You 8 | # may not use this file except in compliance with the License. A copy of 9 | # the License is located at 10 | # 11 | # http://www.apache.org/licenses/LICENSE-2.0 12 | # 13 | # or in the "license" file accompanying this file. This file is 14 | # distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF 15 | # ANY KIND, either express or implied. See the License for the specific 16 | # language governing permissions and limitations under the License. 17 | 18 | import re 19 | 20 | from .compat import HTMLParser 21 | from .lib.html2text.html2text import HTML2Text 22 | import click 23 | import requests 24 | 25 | 26 | class WebViewer(object): 27 | """Handle viewing of web content within the terminal. 28 | 29 | :type html: :class:`HTMLParser.HTMLParser` 30 | :param html: An instance of `HTMLParser.HTMLParser`. 31 | 32 | :type html_to_text: :class:`html2text.html2text.HTML2Text` 33 | :param html_to_text: An instance of `html2text.html2text.HTML2Text`. 34 | """ 35 | 36 | def __init__(self): 37 | try: 38 | self.html = HTMLParser.HTMLParser() 39 | except: 40 | self.html = HTMLParser 41 | self.html_to_text = None 42 | self._init_html_to_text() 43 | 44 | def _init_html_to_text(self): 45 | """Initialize HTML2Text.""" 46 | self.html_to_text = HTML2Text() 47 | self.html_to_text.body_width = 0 48 | self.html_to_text.ignore_images = False 49 | self.html_to_text.ignore_emphasis = False 50 | self.html_to_text.ignore_links = False 51 | self.html_to_text.skip_internal_links = False 52 | self.html_to_text.inline_links = False 53 | self.html_to_text.links_each_paragraph = False 54 | 55 | def format_markdown(self, text): 56 | """Add color to the input markdown using click.style. 57 | 58 | :type text: str 59 | :param text: The markdown text. 60 | 61 | :rtype: str 62 | :return: The input `text`, formatted. 63 | """ 64 | pattern_url_name = r'[^]]*' 65 | pattern_url_link = r'[^)]+' 66 | pattern_url = r'([!]*\[{0}]\(\s*{1}\s*\))'.format( 67 | pattern_url_name, 68 | pattern_url_link) 69 | regex_url = re.compile(pattern_url) 70 | text = regex_url.sub(click.style(r'\1', fg='green'), text) 71 | pattern_url_ref_name = r'[^]]*' 72 | pattern_url_ref_link = r'[^]]+' 73 | pattern_url_ref = r'([!]*\[{0}]\[\s*{1}\s*\])'.format( 74 | pattern_url_ref_name, 75 | pattern_url_ref_link) 76 | regex_url_ref = re.compile(pattern_url_ref) 77 | text = regex_url_ref.sub(click.style(r'\1', fg='green'), 78 | text) 79 | regex_list = re.compile(r'( \*.*)') 80 | text = regex_list.sub(click.style(r'\1', fg='cyan'), 81 | text) 82 | regex_header = re.compile(r'(#+) (.*)') 83 | text = regex_header.sub(click.style(r'\2', fg='yellow'), 84 | text) 85 | regex_bold = re.compile(r'(\*\*|__)(.*?)\1') 86 | text = regex_bold.sub(click.style(r'\2', fg='cyan'), 87 | text) 88 | regex_code = re.compile(r'(`)(.*?)\1') 89 | text = regex_code.sub(click.style(r'\1\2\1', fg='cyan'), 90 | text) 91 | text = re.sub(r'(\s*\r?\n\s*){2,}', r'\n\n', text) 92 | return text 93 | 94 | def generate_url_contents(self, url): 95 | """Generate the formatted contents of the given item's url. 96 | 97 | Converts the HTML to text using HTML2Text, colors it, then displays 98 | the output in a pager. 99 | 100 | :type url: str 101 | :param url: The url whose contents to fetch. 102 | 103 | :rtype: str 104 | :return: The string representation of the formatted url contents. 105 | """ 106 | try: 107 | headers = {'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/39.0.2171.95 Safari/537.36'} # NOQA 108 | raw_response = requests.get(url, headers=headers) 109 | except (requests.exceptions.SSLError, 110 | requests.exceptions.ConnectionError) as e: 111 | contents = 'Error: ' + str(e) + '\n' 112 | contents += 'Try running hn view # with the --browser/-b flag\n' 113 | return contents 114 | text = raw_response.text 115 | contents = self.html_to_text.handle(text) 116 | # Strip out Unicode, which seems to have issues when html2txt is 117 | # coupled with click.echo_via_pager. 118 | contents = re.sub(r'[^\x00-\x7F]+', '', contents) 119 | contents = self.format_markdown(contents) 120 | return contents 121 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | codecov>=1.3.3 2 | flake8>=2.4.1 3 | gitchangelog>=2.2.1 4 | mock>=1.0.1 5 | pexpect>=3.3 6 | pytest>=2.7.0 7 | sphinx>=1.3.1 8 | Sphinx-PyPI-upload>=0.2.1 9 | tox>=1.9.2 10 | -------------------------------------------------------------------------------- /scripts/create_changelog.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | pandoc --from=markdown --to=rst --output=CHANGELOG.rst CHANGELOG.md 4 | -------------------------------------------------------------------------------- /scripts/create_readme_rst.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | pandoc --from=markdown --to=rst --output=docs/source/README.rst README.md -------------------------------------------------------------------------------- /scripts/run_code_checks.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | flake8 --max-line-length=80 --exclude=build,docs,scratch,docs,haxor,html2text,onions.py,compat.py . 4 | -------------------------------------------------------------------------------- /scripts/set_changelog_as_readme.sh: -------------------------------------------------------------------------------- 1 | cp CHANGELOG.rst README.rst -------------------------------------------------------------------------------- /scripts/update_docs.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | scripts/create_readme_rst.sh 4 | python setup.py build_sphinx -------------------------------------------------------------------------------- /scripts/upload_pypi.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # N. Set CHANGELOG as `README.md` 4 | scripts/set_changelog_as_readme.sh 5 | # O. Register package with PyPi 6 | python setup.py register -r pypi 7 | # P. Upload to PyPi 8 | python setup.py sdist upload -r pypi 9 | # Q. Upload Sphinx docs to PyPi 10 | python setup.py upload_sphinx 11 | # R. Restore `README.md` 12 | scripts/set_changelog_as_readme_undo.sh 13 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from haxor_news.__init__ import __version__ 2 | try: 3 | from setuptools import setup, find_packages 4 | except ImportError: 5 | from distutils.core import setup 6 | 7 | 8 | setup( 9 | description=('View and filter Hacker News from the command line: ' 10 | 'Posts, comments, and linked web content.'), 11 | author='Donne Martin', 12 | url='https://github.com/donnemartin/haxor-news', 13 | download_url='https://pypi.python.org/pypi/haxor-news', 14 | author_email='donne.martin@gmail.com', 15 | version=__version__, 16 | license='Apache License 2.0', 17 | install_requires=[ 18 | 'click>=5.1,<8.0', 19 | 'colorama>=0.3.3,<1.0.0', 20 | 'requests>=2.4.3,<3.0.0', 21 | 'pygments>=2.0.2,<3.0.0', 22 | 'prompt-toolkit>=1.0.0,<1.1.0', 23 | 'six>=1.9.0,<2.0.0', 24 | ], 25 | extras_require={ 26 | 'testing': [ 27 | 'mock>=1.0.1,<2.0.0', 28 | 'tox>=1.9.2,<2.0.0' 29 | ], 30 | }, 31 | entry_points={ 32 | 'console_scripts': [ 33 | 'haxor-news = haxor_news.main:cli', 34 | 'hn = haxor_news.main_cli:cli' 35 | ] 36 | }, 37 | packages=find_packages(), 38 | scripts=[], 39 | name='haxor-news', 40 | classifiers=[ 41 | 'Intended Audience :: Developers', 42 | 'Intended Audience :: System Administrators', 43 | 'License :: OSI Approved :: Apache Software License', 44 | 'Natural Language :: English', 45 | 'Programming Language :: Python', 46 | 'Programming Language :: Python :: 2.6', 47 | 'Programming Language :: Python :: 2.7', 48 | 'Programming Language :: Python :: 3', 49 | 'Programming Language :: Python :: 3.4', 50 | 'Programming Language :: Python :: 3.5', 51 | 'Programming Language :: Python :: 3.6', 52 | 'Programming Language :: Python :: 3.7', 53 | 'Topic :: Software Development', 54 | 'Topic :: Software Development :: Libraries :: Python Modules', 55 | ], 56 | ) 57 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # Copyright 2015 Donne Martin. All Rights Reserved. 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"). You 6 | # may not use this file except in compliance with the License. A copy of 7 | # the License is located at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # or in the "license" file accompanying this file. This file is 12 | # distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF 13 | # ANY KIND, either express or implied. See the License for the specific 14 | # language governing permissions and limitations under the License. 15 | -------------------------------------------------------------------------------- /tests/compat.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # Copyright 2015 Donne Martin. All Rights Reserved. 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"). You 6 | # may not use this file except in compliance with the License. A copy of 7 | # the License is located at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # or in the "license" file accompanying this file. This file is 12 | # distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF 13 | # ANY KIND, either express or implied. See the License for the specific 14 | # language governing permissions and limitations under the License. 15 | 16 | import sys 17 | if sys.version_info < (2, 7): 18 | import unittest2 as unittest 19 | else: 20 | import unittest 21 | -------------------------------------------------------------------------------- /tests/data/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # Copyright 2015 Donne Martin. All Rights Reserved. 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"). You 6 | # may not use this file except in compliance with the License. A copy of 7 | # the License is located at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # or in the "license" file accompanying this file. This file is 12 | # distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF 13 | # ANY KIND, either express or implied. See the License for the specific 14 | # language governing permissions and limitations under the License. 15 | -------------------------------------------------------------------------------- /tests/data/comment.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # Copyright 2015 Donne Martin. All Rights Reserved. 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"). You 6 | # may not use this file except in compliance with the License. A copy of 7 | # the License is located at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # or in the "license" file accompanying this file. This file is 12 | # distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF 13 | # ANY KIND, either express or implied. See the License for the specific 14 | # language governing permissions and limitations under the License. 15 | 16 | raw_comment = """I downvoted you, but I thought I'd explain why - I don't think it's reasonable to characterise this as childish. Government surveillance is something which our industry is the best positioned to speak out against. This type of thing seems to be clearly political speech, and not a prank. 17 | 18 | You may enjoy The Master Algorithm: http://www.amazon.com/Master-Algorithm-Ultimate-Learning- 21 | Mac... 22 | 23 | There was a great article called The Space Doctor's Big Idea There was a great article called The Space Doctor's Big Idea. 24 | 25 | What would a satisfactory "why" even look like exactly? As 26 | in, what form might it take compared to some other scientific discipline 27 | where we do know what's going on?

Personally, I think the whole 28 | thing is a red herring -- people in the field have some idea of how 29 | neural nets work, and there are many disciplines considered by many to be 30 | mature sciences that are far from settled on a grand theoretical 31 | scale.

That said... 32 | """ # NOQA 33 | 34 | formatted_heading = '\x1b[33m\n foo - just now\x1b[0m' 35 | 36 | formatted_comment = """ I downvoted you, but I thought I\'d explain why - I don\'t think it\'s\n reasonable to characterise this as childish. Government surveillance is\n something which our industry is the best positioned to speak out\n against. This type of thing seems to be clearly political speech, and\n not a prank.\n\n You may enjoy The Master Algorithm: http://www.amazon.com/Master-\n Algorithm-Ultimate-Learning-\n Mac...\n\n There was a great\n article called \x1b[36mThe Space Doctor\'s Big Idea\x1b[0m There was a\n great article called \x1b[36mThe Space Doctor\'s Big Idea\x1b[0m.\n What would a satisfactory "why" even look like exactly? As\n in, what\n form might it take compared to some other scientific discipline\n where we \x1b[36mdo\x1b[0m know what\'s going on?\n\n \x1b[0mPersonally,\n I think the whole\n thing is a red herring -- people in the field have\n \x1b[36msome idea\x1b[0m of how\n neural nets work, and there are\n many disciplines considered by many to be\n mature sciences that are\n far from settled on a grand theoretical\n scale.\n\n \x1b[0mThat\n said...""" # NOQA 37 | -------------------------------------------------------------------------------- /tests/data/item.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # Copyright 2015 Donne Martin. All Rights Reserved. 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"). You 6 | # may not use this file except in compliance with the License. A copy of 7 | # the License is located at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # or in the "license" file accompanying this file. This file is 12 | # distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF 13 | # ANY KIND, either express or implied. See the License for the specific 14 | # language governing permissions and limitations under the License. 15 | 16 | formatted_items = [ 17 | ('\x1b[35m 1. \x1b[0mtitle foo \x1b[0m\x1b[35m()\x1b[0m\n' 18 | ' \x1b[32m' 19 | '10 points \x1b[0m\x1b[36mby foo \x1b[0m\x1b[33mjust now \x1b[' 20 | '0m\x1b[32m| 2 comments\x1b[0m'), 21 | ('\x1b[35m 2. \x1b[0mtitle bar \x1b[0m\x1b[35m()\x1b[0m\n' 22 | ' \x1b[32m' 23 | '20 points \x1b[0m\x1b[36mby bar \x1b[0m\x1b[33mjust now \x1b[' 24 | '0m\x1b[32m| 1 comments\x1b[0m'), 25 | ('\x1b[35m 3. \x1b[0mtitle baz \x1b[0m\x1b[35m()\x1b[0m\n' 26 | ' \x1b[32m' 27 | '30 points \x1b[0m\x1b[36mby baz \x1b[0m\x1b[33mjust now \x1b[' 28 | '0m\x1b[32m| 0 comments\x1b[0m'), 29 | ] 30 | -------------------------------------------------------------------------------- /tests/data/regex.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # Copyright 2015 Donne Martin. All Rights Reserved. 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"). You 6 | # may not use this file except in compliance with the License. A copy of 7 | # the License is located at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # or in the "license" file accompanying this file. This file is 12 | # distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF 13 | # ANY KIND, either express or implied. See the License for the specific 14 | # language governing permissions and limitations under the License. 15 | 16 | raw_text_for_regex = u'foo python bar Rockstar' 17 | -------------------------------------------------------------------------------- /tests/data/tip.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # Copyright 2015 Donne Martin. All Rights Reserved. 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"). You 6 | # may not use this file except in compliance with the License. A copy of 7 | # the License is located at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # or in the "license" file accompanying this file. This file is 12 | # distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF 13 | # ANY KIND, either express or implied. See the License for the specific 14 | # language governing permissions and limitations under the License. 15 | 16 | formatted_tip = ' Tip: View the page or comments for \x1b[0m\x1b[35m1 through \x1b[0m\x1b[35m10\x1b[0m with the following command:\n\x1b[0m\x1b[35m hn view [#] \x1b[0moptional: [-c] [-cr] [-cu] [-cq "regex"] [-ch] [-b] [--help]\n\x1b[0m' # NOQA 17 | -------------------------------------------------------------------------------- /tests/data/title.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # Copyright 2015 Donne Martin. All Rights Reserved. 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"). You 6 | # may not use this file except in compliance with the License. A copy of 7 | # the License is located at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # or in the "license" file accompanying this file. This file is 12 | # distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF 13 | # ANY KIND, either express or implied. See the License for the specific 14 | # language governing permissions and limitations under the License. 15 | 16 | raw_title = u'The secret message hidden in every HTTP/2 connection' 17 | 18 | formatted_title = u'\x1b[35m 1. \x1b[0mThe secret message hidden in every HTTP/2 connection \x1b[0m' # NOQA 19 | -------------------------------------------------------------------------------- /tests/data/url.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # Copyright 2015 Donne Martin. All Rights Reserved. 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"). You 6 | # may not use this file except in compliance with the License. A copy of 7 | # the License is located at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # or in the "license" file accompanying this file. This file is 12 | # distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF 13 | # ANY KIND, either express or implied. See the License for the specific 14 | # language governing permissions and limitations under the License. 15 | 16 | formatted_url = 'Viewing foo\n\n\x1b[0mbar' 17 | -------------------------------------------------------------------------------- /tests/mock_hacker_news_api.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # Copyright 2015 Donne Martin. All Rights Reserved. 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"). You 6 | # may not use this file except in compliance with the License. A copy of 7 | # the License is located at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # or in the "license" file accompanying this file. This file is 12 | # distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF 13 | # ANY KIND, either express or implied. See the License for the specific 14 | # language governing permissions and limitations under the License. 15 | 16 | from haxor_news.lib.haxor.haxor import InvalidItemID, InvalidUserID 17 | 18 | 19 | class MockItem(object): 20 | 21 | def __init__(self): 22 | self.item_id = None 23 | self.by = None 24 | self.submission_time = None 25 | self.text = None 26 | self.kids = None 27 | self.url = None 28 | self.score = None 29 | self.title = None 30 | self.descendants = None 31 | 32 | 33 | class MockUser(object): 34 | 35 | def __init__(self): 36 | self.user_id = None 37 | self.created = None 38 | self.karma = None 39 | self.submitted = None 40 | 41 | 42 | class MockHackerNewsApi(object): 43 | 44 | def __init__(self): 45 | self.items = self._generate_mock_items() 46 | self.users = self._generate_mock_users() 47 | 48 | def _generate_mock_items(self): 49 | items = [] 50 | item0 = MockItem() 51 | item0.item_id = 0 52 | item0.by = 'foo' 53 | item0.submission_time = None 54 | item0.text = 'text foo' 55 | item0.kids = [1] 56 | item0.url = 'foo.com' 57 | item0.score = 10 58 | item0.title = 'title foo' 59 | item0.descendants = 2 60 | items.append(item0) 61 | item1 = MockItem() 62 | item1.item_id = 1 63 | item1.by = 'bar' 64 | item1.submission_time = None 65 | item1.text = 'text bar' 66 | item1.kids = [2] 67 | item1.url = 'bar.com' 68 | item1.score = 20 69 | item1.title = 'title bar' 70 | item1.descendants = 1 71 | items.append(item1) 72 | item2 = MockItem() 73 | item2.item_id = 2 74 | item2.by = 'baz' 75 | item2.submission_time = None 76 | item2.text = 'text baz' 77 | item2.kids = [] 78 | item2.url = 'baz.com' 79 | item2.score = 30 80 | item2.title = 'title baz' 81 | item2.descendants = 0 82 | items.append(item2) 83 | return items 84 | 85 | def _generate_mock_users(self): 86 | users = [] 87 | user0 = MockUser() 88 | user0.user_id = 'foo' 89 | user0.created = None 90 | user0.karma = 10 91 | user0.submitted = [0, 2] 92 | users.append(user0) 93 | user1 = MockUser() 94 | user1.user_id = 'bar' 95 | user1.created = None 96 | user1.karma = 20 97 | user1.submitted = [1] 98 | users.append(user1) 99 | return users 100 | 101 | def item_ids(self, limit): 102 | return [item.item_id for item in self.items[:limit]] 103 | 104 | def get_item(self, item_id): 105 | item_id = int(item_id) 106 | try: 107 | if item_id < len(self.items): 108 | return self.items[item_id] 109 | else: 110 | raise InvalidItemID 111 | except IndexError: 112 | raise InvalidItemID 113 | 114 | def get_user(self, user_id): 115 | for user in self.users: 116 | if user.user_id == user_id: 117 | return user 118 | raise InvalidUserID 119 | 120 | def top_stories(self, limit=None): 121 | return self.item_ids(limit) 122 | 123 | def new_stories(self, limit=None): 124 | return self.item_ids(limit) 125 | 126 | def ask_stories(self, limit=None): 127 | return self.item_ids(limit) 128 | 129 | def best_stories(self, limit=None): 130 | return self.item_ids(limit) 131 | 132 | def show_stories(self, limit=None): 133 | return self.item_ids(limit) 134 | 135 | def job_stories(self, limit=None): 136 | return self.item_ids(limit) 137 | -------------------------------------------------------------------------------- /tests/run_tests.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | # Copyright 2015 Donne Martin. All Rights Reserved. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"). You 7 | # may not use this file except in compliance with the License. A copy of 8 | # the License is located at 9 | # 10 | # http://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # or in the "license" file accompanying this file. This file is 13 | # distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF 14 | # ANY KIND, either express or implied. See the License for the specific 15 | # language governing permissions and limitations under the License. 16 | 17 | from tests.compat import unittest 18 | 19 | from test_completer import CompleterTest # NOQA 20 | from test_hacker_news import HackerNewsTest # NOQA 21 | from test_hacker_news_cli import HackerNewsCliTest # NOQA 22 | from test_haxor import HaxorTest # NOQA 23 | from test_keys import KeysTest # NOQA 24 | from test_toolbar import ToolbarTest # NOQA 25 | from test_config import ConfigTest # NOQA 26 | # from test_config_integration import ConfigTestIntegration # NOQA 27 | try: 28 | from test_cli import CliTest # NOQA 29 | except: 30 | # pexpect import fails on Windows 31 | pass 32 | 33 | 34 | if __name__ == '__main__': 35 | unittest.main() 36 | -------------------------------------------------------------------------------- /tests/test_cli.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # Copyright 2015 Donne Martin. All Rights Reserved. 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"). You 6 | # may not use this file except in compliance with the License. A copy of 7 | # the License is located at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # or in the "license" file accompanying this file. This file is 12 | # distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF 13 | # ANY KIND, either express or implied. See the License for the specific 14 | # language governing permissions and limitations under the License. 15 | 16 | import pip 17 | import pexpect 18 | from tests.compat import unittest 19 | 20 | 21 | class CliTest(unittest.TestCase): 22 | 23 | def test_run_cli(self): 24 | self.cli = None 25 | self.step_cli_installed() 26 | self.step_run_cli() 27 | self.step_see_prompt() 28 | self.step_send_ctrld() 29 | 30 | def step_cli_installed(self): 31 | """Make sure haxor is in installed packages. 32 | """ 33 | dists = set([di.key for di in pip.get_installed_distributions()]) 34 | assert 'haxor-news' in dists 35 | 36 | def step_run_cli(self): 37 | """Run the process using pexpect. 38 | """ 39 | self.cli = pexpect.spawnu('haxor-news') 40 | 41 | def step_see_prompt(self): 42 | """Expect to see prompt. 43 | """ 44 | self.cli.expect('haxor> ') 45 | 46 | def step_send_ctrld(self): 47 | """Send Ctrl + D to exit. 48 | """ 49 | self.cli.sendcontrol('d') 50 | self.cli.expect(pexpect.EOF) 51 | -------------------------------------------------------------------------------- /tests/test_completer.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # Copyright 2015 Donne Martin. All Rights Reserved. 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"). You 6 | # may not use this file except in compliance with the License. A copy of 7 | # the License is located at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # or in the "license" file accompanying this file. This file is 12 | # distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF 13 | # ANY KIND, either express or implied. See the License for the specific 14 | # language governing permissions and limitations under the License. 15 | 16 | from __future__ import unicode_literals 17 | from __future__ import print_function 18 | from __future__ import division 19 | 20 | import mock 21 | from tests.compat import unittest 22 | 23 | from prompt_toolkit.document import Document 24 | 25 | from haxor_news.completer import Completer 26 | from haxor_news.settings import freelancer_post_id, who_is_hiring_post_id 27 | from haxor_news.utils import TextUtils 28 | 29 | 30 | class CompleterTest(unittest.TestCase): 31 | 32 | def setUp(self): 33 | self.completer = Completer(fuzzy_match=False, 34 | text_utils=TextUtils()) 35 | self.completer_event = self.create_completer_event() 36 | 37 | def create_completer_event(self): 38 | return mock.Mock() 39 | 40 | def _get_completions(self, command): 41 | position = len(command) 42 | result = set(self.completer.get_completions( 43 | Document(text=command, cursor_position=position), 44 | self.completer_event)) 45 | return result 46 | 47 | def verify_completions(self, commands, expected): 48 | result = set() 49 | for command in commands: 50 | # Call the AWS CLI autocompleter 51 | result.update(self._get_completions(command)) 52 | result_texts = [] 53 | for item in result: 54 | # Each result item is a Completion object, 55 | # we are only interested in the text portion 56 | result_texts.append(item.text) 57 | assert result_texts 58 | if len(expected) == 1: 59 | assert expected[0] in result_texts 60 | else: 61 | for item in expected: 62 | assert item in result_texts 63 | 64 | def test_blank(self): 65 | text = '' 66 | expected = set([]) 67 | result = self._get_completions(text) 68 | assert result == expected 69 | 70 | def test_no_completions(self): 71 | text = 'foo' 72 | expected = set([]) 73 | result = self._get_completions(text) 74 | assert result == expected 75 | 76 | def test_command(self): 77 | text = ['h'] 78 | expected = ['hn'] 79 | self.verify_completions(text, expected) 80 | 81 | def test_subcommand(self): 82 | text = ['hn as'] 83 | expected = ['ask'] 84 | self.verify_completions(text, expected) 85 | 86 | def test_arg_freelance(self): 87 | text = ['hn freelance '] 88 | expected = ['"(?i)(Python|Django)"'] 89 | self.verify_completions(text, expected) 90 | 91 | def test_arg_hiring(self): 92 | text = ['hn hiring '] 93 | expected = ['"(?i)(Python|Django)"'] 94 | self.verify_completions(text, expected) 95 | 96 | def test_arg_limit(self): 97 | text = ['hn top '] 98 | expected = ['10'] 99 | self.verify_completions(text, expected) 100 | 101 | def test_arg_user(self): 102 | text = ['hn user '] 103 | expected = ['"user"'] 104 | self.verify_completions(text, expected) 105 | 106 | def test_arg_view(self): 107 | text = ['hn view '] 108 | expected = ['1'] 109 | self.verify_completions(text, expected) 110 | 111 | def test_option_freelance(self): 112 | text = ['hn freelance "" '] 113 | expected = [ 114 | '--id_post ' + str(freelancer_post_id), 115 | '-i ' + str(freelancer_post_id), 116 | ] 117 | self.verify_completions(text, expected) 118 | 119 | def test_option_hiring(self): 120 | text = ['hn hiring "" '] 121 | expected = [ 122 | '--id_post ' + str(who_is_hiring_post_id), 123 | '-i ' + str(who_is_hiring_post_id), 124 | ] 125 | self.verify_completions(text, expected) 126 | 127 | def test_option_user(self): 128 | text = ['hn user "" '] 129 | expected = [ 130 | '--limit 10', 131 | '-l 10', 132 | ] 133 | self.verify_completions(text, expected) 134 | 135 | def test_option_view(self): 136 | text = ['hn view 0 '] 137 | expected = [ 138 | '--comments_regex_query ""', 139 | '-cq ""', 140 | '--comments', 141 | '-c', 142 | '--comments_recent', 143 | '-cr', 144 | '--comments_unseen', 145 | '-cu', 146 | '--comments_hide_non_matching', 147 | '-ch', 148 | '--clear_cache', 149 | '-cc', 150 | '--browser', 151 | '-b', 152 | ] 153 | self.verify_completions(text, expected) 154 | 155 | def test_completing_option(self): 156 | text = ['hn view 0 -'] 157 | expected = [ 158 | '--comments_regex_query ""', 159 | '-cq ""', 160 | '--comments', 161 | '-c', 162 | '--comments_recent', 163 | '-cr', 164 | '--comments_unseen', 165 | '-cu', 166 | '--comments_hide_non_matching', 167 | '-ch', 168 | '--clear_cache', 169 | '-cc', 170 | '--browser', 171 | '-b', 172 | ] 173 | self.verify_completions(text, expected) 174 | 175 | def test_multiple_options(self): 176 | text = ['hn view 0 -c --brow'] 177 | expected = ['--browser'] 178 | self.verify_completions(text, expected) 179 | 180 | def test_fuzzy(self): 181 | text = ['hn vw'] 182 | expected = ['view'] 183 | self.completer.fuzzy_match = True 184 | self.verify_completions(text, expected) 185 | -------------------------------------------------------------------------------- /tests/test_config.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # Copyright 2015 Donne Martin. All Rights Reserved. 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"). You 6 | # may not use this file except in compliance with the License. A copy of 7 | # the License is located at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # or in the "license" file accompanying this file. This file is 12 | # distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF 13 | # ANY KIND, either express or implied. See the License for the specific 14 | # language governing permissions and limitations under the License. 15 | 16 | from __future__ import print_function 17 | from __future__ import division 18 | 19 | import mock 20 | import os 21 | from tests.compat import unittest 22 | 23 | from haxor_news.hacker_news import HackerNews 24 | from tests.mock_hacker_news_api import MockHackerNewsApi 25 | 26 | 27 | class ConfigTest(unittest.TestCase): 28 | 29 | def setUp(self): 30 | self.hn = HackerNews() 31 | self.hn.hacker_news_api = MockHackerNewsApi() 32 | self.limit = len(self.hn.hacker_news_api.items) 33 | self.valid_id = 0 34 | self.invalid_id = 9000 35 | self.query = 'foo' 36 | 37 | def test_config(self): 38 | expected = os.path.join(os.path.abspath(os.environ.get('HOME', '')), 39 | self.hn.config.CONFIG) 40 | assert self.hn.config.get_config_path(self.hn.config.CONFIG) == expected 41 | 42 | @mock.patch('haxor_news.config.Config.save_cache') 43 | def test_clear_item_cache(self, mock_save_cache): 44 | item_ids = self.hn.config.item_ids 45 | self.hn.config.clear_item_cache() 46 | assert self.hn.config.item_ids == item_ids 47 | assert self.hn.config.item_cache == [] 48 | mock_save_cache.assert_called_with() 49 | 50 | def test_save_and_load_item_ids(self): 51 | self.hn.config.item_ids = [0, 1, 2] 52 | self.hn.config.item_cache = [3, 4, 5] 53 | self.hn.config.save_cache() 54 | item_ids = self.hn.config.item_ids 55 | assert item_ids == [0, 1, 2] 56 | item_cache = self.hn.config.item_cache 57 | assert item_cache == [3, 4, 5] 58 | 59 | @mock.patch('haxor_news.hacker_news.HackerNews.view') 60 | @mock.patch('haxor_news.config.Config.clear_item_cache') 61 | def test_view_comment_clear_cache(self, mock_clear_item_cache, mock_view): 62 | index = 0 63 | comments = False 64 | comments_recent = False 65 | comments_unseen = True 66 | comments_hide_non_matching = False 67 | comments_clear_cache = True 68 | browser = False 69 | self.hn.view_setup( 70 | index, self.query, comments, comments_recent, 71 | comments_unseen, comments_hide_non_matching, 72 | comments_clear_cache, browser) 73 | comments_expected = True 74 | mock_clear_item_cache.assert_called_with() 75 | mock_view.assert_called_with( 76 | index, self.hn.QUERY_UNSEEN, comments_expected, 77 | comments_hide_non_matching, browser) 78 | -------------------------------------------------------------------------------- /tests/test_config_integration.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # Copyright 2015 Donne Martin. All Rights Reserved. 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"). You 6 | # may not use this file except in compliance with the License. A copy of 7 | # the License is located at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # or in the "license" file accompanying this file. This file is 12 | # distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF 13 | # ANY KIND, either express or implied. See the License for the specific 14 | # language governing permissions and limitations under the License. 15 | 16 | from __future__ import print_function 17 | from __future__ import division 18 | 19 | import os 20 | from tests.compat import unittest 21 | 22 | from haxor_news.hacker_news import HackerNews 23 | from haxor_news.settings import freelancer_post_id, who_is_hiring_post_id 24 | from tests.mock_hacker_news_api import MockHackerNewsApi 25 | 26 | 27 | class ConfigTestIntegration(unittest.TestCase): 28 | 29 | def setUp(self): 30 | self.hn = HackerNews() 31 | self.hn.hacker_news_api = MockHackerNewsApi() 32 | self.limit = len(self.hn.hacker_news_api.items) 33 | 34 | def test_load_hiring_and_freelance_ids(self): 35 | self.hn.config.load_hiring_and_freelance_ids() 36 | assert self.hn.config.hiring_id != who_is_hiring_post_id 37 | assert self.hn.config.freelance_id != freelancer_post_id 38 | 39 | def test_load_hiring_and_freelance_ids_invalid_url(self): 40 | self.hn.config.load_hiring_and_freelance_ids(url='https://example.com') 41 | assert self.hn.config.hiring_id == who_is_hiring_post_id 42 | assert self.hn.config.freelance_id == freelancer_post_id 43 | os.remove('./downloaded_settings.py') 44 | 45 | def test_load_hiring_and_freelance_ids_from_cache_or_defaults(self): 46 | self.hn.config.load_hiring_and_freelance_ids_from_cache_or_defaults() 47 | assert self.hn.config.hiring_id == who_is_hiring_post_id 48 | assert self.hn.config.freelance_id == freelancer_post_id 49 | -------------------------------------------------------------------------------- /tests/test_hacker_news.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # Copyright 2015 Donne Martin. All Rights Reserved. 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"). You 6 | # may not use this file except in compliance with the License. A copy of 7 | # the License is located at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # or in the "license" file accompanying this file. This file is 12 | # distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF 13 | # ANY KIND, either express or implied. See the License for the specific 14 | # language governing permissions and limitations under the License. 15 | 16 | from __future__ import print_function 17 | from __future__ import division 18 | 19 | import mock 20 | from tests.compat import unittest 21 | 22 | from haxor_news.hacker_news import HackerNews 23 | from tests.data.comment import formatted_comment, formatted_heading, raw_comment 24 | from tests.data.item import formatted_items 25 | from tests.data.markdown import formatted_markdown, raw_markdown 26 | from tests.data.regex import raw_text_for_regex 27 | from tests.data.tip import formatted_tip 28 | from tests.data.title import formatted_title, raw_title 29 | from tests.mock_hacker_news_api import MockHackerNewsApi 30 | 31 | 32 | class HackerNewsTest(unittest.TestCase): 33 | 34 | def setUp(self): 35 | self.hn = HackerNews() 36 | self.hn.hacker_news_api = MockHackerNewsApi() 37 | self.limit = len(self.hn.hacker_news_api.items) 38 | self.valid_id = 0 39 | self.invalid_id = 9000 40 | self.query = 'foo' 41 | 42 | def top(self, limit=2): 43 | self.hn.print_items( 44 | message=self.headlines_message('Top'), 45 | item_ids=self.hn.hacker_news_api.top_stories(limit)) 46 | 47 | @mock.patch('haxor_news.hacker_news.HackerNews.print_items') 48 | def test_ask(self, mock_print_items): 49 | self.hn.ask(self.limit) 50 | mock_print_items.assert_called_with( 51 | message=self.hn.headlines_message('Ask HN'), 52 | item_ids=self.hn.hacker_news_api.ask_stories(self.limit)) 53 | 54 | @mock.patch('haxor_news.hacker_news.HackerNews.print_items') 55 | def test_best(self, mock_print_items): 56 | self.hn.best(self.limit) 57 | mock_print_items.assert_called_with( 58 | message=self.hn.headlines_message('Best'), 59 | item_ids=self.hn.hacker_news_api.best_stories(self.limit)) 60 | 61 | def test_format_markdown(self): 62 | result = self.hn.web_viewer.format_markdown(raw_markdown) 63 | assert result == formatted_markdown 64 | 65 | def test_headlines_message(self): 66 | message = 'foo' 67 | headlines_message = self.hn.headlines_message(message) 68 | assert message in headlines_message 69 | 70 | @mock.patch('haxor_news.hacker_news.HackerNews.print_comments') 71 | @mock.patch('haxor_news.hacker_news.HackerNews.print_item_not_found') 72 | def test_hiring_and_freelance(self, 73 | mock_print_item_not_found, 74 | mock_print_comments): 75 | self.hn.hiring_and_freelance(self.query, post_id=self.valid_id) 76 | item = self.hn.hacker_news_api.get_item(self.valid_id) 77 | mock_print_comments.assert_called_with( 78 | item, self.query, comments_hide_non_matching=True) 79 | self.hn.hiring_and_freelance(self.query, post_id=self.invalid_id) 80 | mock_print_item_not_found.assert_called_with(self.invalid_id) 81 | 82 | @mock.patch('haxor_news.hacker_news.HackerNews.print_items') 83 | def test_jobs(self, mock_print_items): 84 | self.hn.jobs(self.limit) 85 | mock_print_items.assert_called_with( 86 | message=self.hn.headlines_message('Jobs'), 87 | item_ids=self.hn.hacker_news_api.ask_stories(self.limit)) 88 | 89 | @mock.patch('haxor_news.hacker_news.HackerNews.print_items') 90 | def test_new(self, mock_print_items): 91 | self.hn.new(self.limit) 92 | mock_print_items.assert_called_with( 93 | message=self.hn.headlines_message('Latest'), 94 | item_ids=self.hn.hacker_news_api.new_stories(self.limit)) 95 | 96 | @mock.patch('haxor_news.hacker_news.HackerNews.format_index_title') 97 | @mock.patch('haxor_news.hacker_news.click') 98 | def test_onion(self, mock_click, mock_format_index_title): 99 | self.hn.onion(self.limit) 100 | assert len(mock_format_index_title.mock_calls) == self.limit 101 | assert mock_click.mock_calls 102 | 103 | @mock.patch('haxor_news.hacker_news.HackerNews.print_items') 104 | def test_show(self, mock_print_items): 105 | self.hn.show(self.limit) 106 | mock_print_items.assert_called_with( 107 | message=self.hn.headlines_message('Show HN'), 108 | item_ids=self.hn.hacker_news_api.show_stories(self.limit)) 109 | 110 | @mock.patch('haxor_news.hacker_news.HackerNews.print_items') 111 | def test_top(self, mock_print_items): 112 | self.hn.top(self.limit) 113 | mock_print_items.assert_called_with( 114 | message=self.hn.headlines_message('Top'), 115 | item_ids=self.hn.hacker_news_api.top_stories(self.limit)) 116 | 117 | @mock.patch('haxor_news.hacker_news.HackerNews.print_items') 118 | @mock.patch('haxor_news.hacker_news.click') 119 | @mock.patch('haxor_news.hacker_news.HackerNews.print_item_not_found') 120 | def test_user(self, mock_print_item_not_found, 121 | mock_click, mock_print_items): 122 | user_id = 'foo' 123 | self.hn.user(user_id, self.limit) 124 | user = self.hn.hacker_news_api.get_user(user_id) 125 | mock_print_items.assert_called_with( 126 | 'User submissions:', user.submitted[0:self.limit]) 127 | assert mock_click.mock_calls 128 | self.hn.user(self.invalid_id, self.limit) 129 | mock_print_item_not_found.assert_called_with(self.invalid_id) 130 | 131 | @mock.patch('haxor_news.hacker_news.HackerNews.view') 132 | def test_view_setup_query_recent(self, mock_view): 133 | index = 0 134 | comments = False 135 | comments_recent = True 136 | comments_unseen = False 137 | comments_hide_non_matching = False 138 | comments_clear_cache = False 139 | browser = False 140 | self.hn.view_setup( 141 | index, self.query, comments, comments_recent, 142 | comments_unseen, comments_hide_non_matching, 143 | comments_clear_cache, browser) 144 | comments_expected = True 145 | mock_view.assert_called_with( 146 | index, 'seconds ago|minutes ago', comments_expected, 147 | comments_hide_non_matching, browser) 148 | 149 | @mock.patch('haxor_news.hacker_news.HackerNews.view') 150 | def test_view_setup_query_unseen(self, mock_view): 151 | index = 0 152 | comments = False 153 | comments_recent = False 154 | comments_unseen = True 155 | comments_hide_non_matching = False 156 | comments_clear_cache = False 157 | browser = False 158 | self.hn.view_setup( 159 | index, self.query, comments, comments_recent, 160 | comments_unseen, comments_hide_non_matching, 161 | comments_clear_cache, browser) 162 | comments_expected = True 163 | mock_view.assert_called_with( 164 | index, self.hn.QUERY_UNSEEN, comments_expected, 165 | comments_hide_non_matching, browser) 166 | 167 | def test_format_comment(self): 168 | item = self.hn.hacker_news_api.get_item(self.valid_id) 169 | item.text = raw_comment 170 | heading, comment = self.hn.format_comment( 171 | item, depth=3, header_color='yellow', header_adornment='') 172 | assert heading == formatted_heading 173 | assert comment == formatted_comment 174 | 175 | def test_format_index_title(self): 176 | result = self.hn.format_index_title( 177 | index=1, title=raw_title) 178 | assert result == formatted_title 179 | 180 | def test_format_item(self): 181 | items = self.hn.hacker_news_api.items 182 | for index, item in enumerate(items): 183 | result = self.hn.format_item(items[index], index+1) 184 | assert result == formatted_items[index] 185 | 186 | @mock.patch('haxor_news.hacker_news.click.echo') 187 | def test_print_comments_unseen(self, mock_click_echo): 188 | items = self.hn.hacker_news_api.items 189 | self.hn.print_comments(items[0], 190 | regex_query=self.hn.QUERY_UNSEEN) 191 | mock_click_echo.assert_any_call( 192 | '\x1b[35m\nfoo - just now [!]\x1b[0m', color=True) 193 | mock_click_echo.assert_any_call( 194 | 'text foo', color=True) 195 | mock_click_echo.assert_any_call( 196 | '\x1b[35m\n bar - just now [!]\x1b[0m', color=True) 197 | mock_click_echo.assert_any_call( 198 | ' text bar', color=True) 199 | mock_click_echo.assert_any_call( 200 | '\x1b[35m\n baz - just now [!]\x1b[0m', color=True) 201 | mock_click_echo.assert_any_call( 202 | ' text baz', color=True) 203 | 204 | @mock.patch('haxor_news.hacker_news.click.echo') 205 | @mock.patch('haxor_news.hacker_news.click.secho') 206 | def test_print_comments_unseen_hide_non_matching(self, 207 | mock_click_secho, 208 | mock_click_echo): 209 | items = self.hn.hacker_news_api.items 210 | self.hn.config.item_cache.extend(['0', '1', '2']) 211 | self.hn.print_comments(items[0], 212 | regex_query=self.hn.QUERY_UNSEEN, 213 | comments_hide_non_matching=True) 214 | mock_click_secho.assert_any_call('.', nl=False) 215 | assert mock_click_secho.mock_calls 216 | assert not mock_click_echo.mock_calls 217 | 218 | @mock.patch('haxor_news.hacker_news.click.echo') 219 | def test_print_comments_regex(self, mock_click_echo): 220 | items = self.hn.hacker_news_api.items 221 | regex_query = 'foo' 222 | self.hn.print_comments(items[0], regex_query) 223 | mock_click_echo.assert_any_call( 224 | '\x1b[35m\nfoo - just now [!]\x1b[0m', color=True) 225 | mock_click_echo.assert_any_call( 226 | 'text foo', color=True) 227 | mock_click_echo.assert_any_call( 228 | '\x1b[33m\n bar - just now [!]\x1b[0m', color=True) 229 | mock_click_echo.assert_any_call( 230 | ' text bar [...]', color=True) 231 | mock_click_echo.assert_any_call( 232 | '\x1b[33m\n baz - just now [!]\x1b[0m', color=True) 233 | mock_click_echo.assert_any_call( 234 | ' text baz [...]', color=True) 235 | 236 | @mock.patch('haxor_news.hacker_news.click.echo') 237 | def test_print_comments_regex_hide_non_matching(self, mock_click_echo): 238 | items = self.hn.hacker_news_api.items 239 | regex_query = 'foo' 240 | self.hn.print_comments(items[0], 241 | regex_query, 242 | comments_hide_non_matching=True) 243 | mock_click_echo.assert_any_call( 244 | '\x1b[35m\nfoo - just now [!]\x1b[0m', color=True) 245 | mock_click_echo.assert_any_call( 246 | 'text foo', color=True) 247 | 248 | @mock.patch('haxor_news.hacker_news.click.echo') 249 | def test_print_comments_regex_seen(self, mock_click_echo): 250 | items = self.hn.hacker_news_api.items 251 | item = items[2] 252 | regex_query = 'foo' 253 | self.hn.config.item_cache.append(str(item.item_id)) 254 | self.hn.print_comments(item, regex_query) 255 | mock_click_echo.assert_any_call( 256 | '\x1b[33m\nbaz - just now\x1b[0m', color=True) 257 | mock_click_echo.assert_any_call( 258 | 'text baz [...]', color=True) 259 | 260 | @mock.patch('haxor_news.hacker_news.click') 261 | def test_print_item_not_found(self, mock_click): 262 | self.hn.print_item_not_found(self.invalid_id) 263 | mock_click.secho.assert_called_with( 264 | 'Item with id {0} not found.'.format(self.invalid_id), fg='red') 265 | 266 | @mock.patch('haxor_news.hacker_news.click') 267 | @mock.patch('haxor_news.hacker_news.HackerNews.format_item') 268 | def test_print_items(self, mock_format_item, mock_click): 269 | items = self.hn.hacker_news_api.items 270 | item_ids = [item.item_id for item in items] 271 | self.hn.print_items(self.hn.headlines_message( 272 | 'Top'), item_ids) 273 | for index, item in enumerate(items): 274 | assert mock.call(item, index+1) in mock_format_item.mock_calls 275 | assert mock_click.secho.mock_calls 276 | 277 | def test_print_tip_view(self): 278 | result = self.hn.tip_view(max_index=10) 279 | assert result == formatted_tip 280 | 281 | def test_match_comment_unseen(self): 282 | regex_query = '' 283 | header_adornment = '' 284 | match = self.hn.match_comment_unseen(regex_query, header_adornment) 285 | assert not match 286 | regex_query = self.hn.QUERY_UNSEEN 287 | header_adornment = self.hn.COMMENT_UNSEEN 288 | match = self.hn.match_comment_unseen(regex_query, header_adornment) 289 | assert match 290 | 291 | def test_match_regex(self): 292 | regex_query = '(?i)(Python|JavaScript).*(rockstar)' 293 | item = self.hn.hacker_news_api.get_item(self.valid_id) 294 | assert not self.hn.match_regex(item, regex_query) 295 | item.text = raw_text_for_regex 296 | assert self.hn.match_regex(item, regex_query) 297 | 298 | def test_match_regex_user(self): 299 | regex_query = 'bar' 300 | item = self.hn.hacker_news_api.get_item(self.valid_id) 301 | assert not self.hn.match_regex(item, regex_query) 302 | regex_query = 'fo' 303 | assert self.hn.match_regex(item, regex_query) 304 | 305 | def test_match_regex_item(self): 306 | regex_query = 'just now' 307 | item = self.hn.hacker_news_api.get_item(self.valid_id) 308 | assert self.hn.match_regex(item, regex_query) 309 | regex_query = 'minutes ago' 310 | assert not self.hn.match_regex(item, regex_query) 311 | 312 | @mock.patch('haxor_news.hacker_news.WebViewer.generate_url_contents') 313 | @mock.patch('haxor_news.hacker_news.click') 314 | def test_view(self, mock_click, mock_generate_url_contents): 315 | items = self.hn.hacker_news_api.items 316 | self.hn.config.item_ids = [int(item.item_id) for item in items] 317 | one_based_index = self.valid_id + 1 318 | comments_query = '' 319 | comments = False 320 | comments_hide_non_matching = False 321 | browser = False 322 | self.hn.view(one_based_index, comments_query, comments, 323 | comments_hide_non_matching, browser) 324 | mock_generate_url_contents.assert_called_with( 325 | items[self.valid_id].url) 326 | assert mock_click.secho.mock_calls 327 | assert mock_click.echo_via_pager.mock_calls 328 | 329 | @mock.patch('haxor_news.hacker_news.HackerNews.print_comments') 330 | @mock.patch('haxor_news.hacker_news.click') 331 | def test_view_comments(self, mock_click, mock_print_comments): 332 | items = self.hn.hacker_news_api.items 333 | self.hn.config.item_ids = [int(item.item_id) for item in items] 334 | one_based_index = self.valid_id + 1 335 | comments_query = 'foo' 336 | comments = True 337 | comments_hide_non_matching = False 338 | browser = False 339 | self.hn.view(one_based_index, comments_query, comments, 340 | comments_hide_non_matching, browser) 341 | mock_print_comments.assert_called_with( 342 | items[self.valid_id], 343 | comments_hide_non_matching=False, 344 | regex_query=comments_query) 345 | assert mock_click.mock_calls 346 | 347 | @mock.patch('haxor_news.hacker_news.HackerNews.print_comments') 348 | @mock.patch('haxor_news.hacker_news.click') 349 | def test_view_no_url(self, mock_click, mock_print_comments): 350 | self.hn.hacker_news_api.items[0].url = None 351 | items = self.hn.hacker_news_api.items 352 | self.hn.config.item_ids = [int(item.item_id) for item in items] 353 | one_based_index = self.valid_id + 1 354 | comments_query = 'foo' 355 | comments = False 356 | comments_hide_non_matching = False 357 | browser = False 358 | self.hn.view(one_based_index, comments_query, comments, 359 | comments_hide_non_matching, browser) 360 | mock_print_comments.assert_called_with( 361 | items[self.valid_id], 362 | comments_hide_non_matching=False, 363 | regex_query=comments_query) 364 | assert mock_click.mock_calls 365 | 366 | @mock.patch('haxor_news.hacker_news.webbrowser') 367 | @mock.patch('haxor_news.hacker_news.click') 368 | def test_view_browser_url(self, mock_click, mock_webbrowser): 369 | items = self.hn.hacker_news_api.items 370 | self.hn.config.item_ids = [int(item.item_id) for item in items] 371 | one_based_index = self.valid_id + 1 372 | comments_query = 'foo' 373 | comments = False 374 | comments_hide_non_matching = False 375 | browser = True 376 | self.hn.view(one_based_index, comments_query, comments, 377 | comments_hide_non_matching, browser) 378 | mock_webbrowser.open.assert_called_with(items[self.valid_id].url) 379 | assert mock_click.mock_calls 380 | 381 | @mock.patch('haxor_news.hacker_news.webbrowser') 382 | @mock.patch('haxor_news.hacker_news.click') 383 | def test_view_browser_comments(self, mock_click, mock_webbrowser): 384 | items = self.hn.hacker_news_api.items 385 | self.hn.config.item_ids = [int(item.item_id) for item in items] 386 | one_based_index = self.valid_id + 1 387 | comments_query = 'foo' 388 | comments = True 389 | comments_hide_non_matching = False 390 | browser = True 391 | self.hn.view(one_based_index, comments_query, comments, 392 | comments_hide_non_matching, browser) 393 | item = items[self.valid_id] 394 | comments_url = ('https://news.ycombinator.com/item?id=' + 395 | str(item.item_id)) 396 | mock_webbrowser.open.assert_called_with(comments_url) 397 | assert mock_click.mock_calls 398 | -------------------------------------------------------------------------------- /tests/test_hacker_news_cli.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # Copyright 2015 Donne Martin. All Rights Reserved. 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"). You 6 | # may not use this file except in compliance with the License. A copy of 7 | # the License is located at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # or in the "license" file accompanying this file. This file is 12 | # distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF 13 | # ANY KIND, either express or implied. See the License for the specific 14 | # language governing permissions and limitations under the License. 15 | 16 | from __future__ import print_function 17 | from __future__ import division 18 | 19 | import mock 20 | from tests.compat import unittest 21 | 22 | from click.testing import CliRunner 23 | 24 | from haxor_news.hacker_news_cli import HackerNewsCli 25 | 26 | 27 | class HackerNewsCliTest(unittest.TestCase): 28 | 29 | def setUp(self): 30 | self.runner = CliRunner() 31 | self.hacker_news_cli = HackerNewsCli() 32 | self.limit = 10 33 | self.user = 'foo' 34 | self.dummy = 'foo' 35 | 36 | def test_cli(self): 37 | result = self.runner.invoke(self.hacker_news_cli.cli) 38 | assert result.exit_code == 0 39 | 40 | @mock.patch('haxor_news.hacker_news_cli.HackerNews.ask') 41 | def test_ask(self, mock_hn_call): 42 | result = self.runner.invoke(self.hacker_news_cli.cli, ['ask']) 43 | mock_hn_call.assert_called_with(self.limit) 44 | assert result.exit_code == 0 45 | 46 | @mock.patch('haxor_news.hacker_news_cli.HackerNews.best') 47 | def test_best(self, mock_hn_call): 48 | result = self.runner.invoke(self.hacker_news_cli.cli, ['best']) 49 | mock_hn_call.assert_called_with(self.limit) 50 | assert result.exit_code == 0 51 | 52 | @mock.patch('haxor_news.hacker_news_cli.HackerNews.hiring_and_freelance') 53 | def test_hiring(self, mock_hn_call): 54 | result = self.runner.invoke( 55 | self.hacker_news_cli.cli, ['hiring', self.dummy, '-i', 1]) 56 | mock_hn_call.assert_called_with(self.dummy, 1) 57 | assert result.exit_code == 0 58 | 59 | @mock.patch('haxor_news.hacker_news_cli.HackerNews.hiring_and_freelance') 60 | def test_freelance(self, mock_hn_call): 61 | result = self.runner.invoke( 62 | self.hacker_news_cli.cli, ['freelance', self.dummy, '-i', 1]) 63 | mock_hn_call.assert_called_with(self.dummy, 1) 64 | assert result.exit_code == 0 65 | 66 | @mock.patch('haxor_news.hacker_news_cli.HackerNews.jobs') 67 | def test_jobs(self, mock_hn_call): 68 | result = self.runner.invoke(self.hacker_news_cli.cli, ['jobs']) 69 | mock_hn_call.assert_called_with(self.limit) 70 | assert result.exit_code == 0 71 | 72 | @mock.patch('haxor_news.hacker_news_cli.HackerNews.new') 73 | def test_new(self, mock_hn_call): 74 | result = self.runner.invoke(self.hacker_news_cli.cli, ['new']) 75 | mock_hn_call.assert_called_with(self.limit) 76 | assert result.exit_code == 0 77 | 78 | @mock.patch('haxor_news.hacker_news_cli.HackerNews.onion') 79 | def test_onion(self, mock_hn_call): 80 | result = self.runner.invoke( 81 | self.hacker_news_cli.cli, ['onion', str(self.limit)]) 82 | mock_hn_call.assert_called_with(self.limit) 83 | assert result.exit_code == 0 84 | 85 | @mock.patch('haxor_news.hacker_news_cli.HackerNews.show') 86 | def test_show(self, mock_hn_call): 87 | result = self.runner.invoke(self.hacker_news_cli.cli, ['show']) 88 | mock_hn_call.assert_called_with(self.limit) 89 | assert result.exit_code == 0 90 | 91 | @mock.patch('haxor_news.hacker_news_cli.HackerNews.top') 92 | def test_top(self, mock_hn_call): 93 | result = self.runner.invoke(self.hacker_news_cli.cli, ['top']) 94 | mock_hn_call.assert_called_with(self.limit) 95 | assert result.exit_code == 0 96 | 97 | @mock.patch('haxor_news.hacker_news_cli.HackerNews.user') 98 | def test_user(self, mock_hn_call): 99 | result = self.runner.invoke( 100 | self.hacker_news_cli.cli, ['user', self.user]) 101 | mock_hn_call.assert_called_with(self.user, self.limit) 102 | assert result.exit_code == 0 103 | 104 | @mock.patch('haxor_news.hacker_news_cli.HackerNews.view') 105 | def test_view(self, mock_hn_call): 106 | dummy = False 107 | index = '0' 108 | result = self.runner.invoke( 109 | self.hacker_news_cli.cli, ['view', index]) 110 | mock_hn_call.assert_called_with(int(index), None, dummy, dummy, dummy) 111 | assert result.exit_code == 0 112 | -------------------------------------------------------------------------------- /tests/test_haxor.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # Copyright 2015 Donne Martin. All Rights Reserved. 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"). You 6 | # may not use this file except in compliance with the License. A copy of 7 | # the License is located at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # or in the "license" file accompanying this file. This file is 12 | # distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF 13 | # ANY KIND, either express or implied. See the License for the specific 14 | # language governing permissions and limitations under the License. 15 | 16 | from __future__ import print_function 17 | from __future__ import division 18 | 19 | import mock 20 | import platform 21 | 22 | from tests.compat import unittest 23 | 24 | from haxor_news.haxor import Haxor 25 | 26 | 27 | class HaxorTest(unittest.TestCase): 28 | 29 | def setUp(self): 30 | self.haxor = Haxor() 31 | 32 | def test_add_comment_pagination(self): 33 | text = 'hn view 1' 34 | result = self.haxor._add_comment_pagination(text) 35 | assert result == text 36 | text = 'hn view 1 -c' 37 | result = self.haxor._add_comment_pagination(text) 38 | if platform.system() == 'Windows': 39 | assert result == text + self.haxor.PAGINATE_CMD_WIN 40 | else: 41 | assert result == text + self.haxor.PAGINATE_CMD 42 | text = 'hn view 1 -c -b' 43 | result = self.haxor._add_comment_pagination(text) 44 | assert result == text 45 | 46 | @mock.patch('haxor_news.haxor.subprocess.call') 47 | def test_run_command(self, mock_subprocess_call): 48 | document = mock.Mock() 49 | document.text = 'hn view 1 -c' 50 | self.haxor.run_command(document) 51 | mock_subprocess_call.assert_called_with('hn view 1 -c | less -r', 52 | shell=True) 53 | document.text = 'hn view 1' 54 | self.haxor.run_command(document) 55 | mock_subprocess_call.assert_called_with('hn view 1', 56 | shell=True) 57 | 58 | @mock.patch('haxor_news.haxor.sys.exit') 59 | def test_exit_command(self, mock_sys_exit): 60 | document = mock.Mock() 61 | document.text = 'exit' 62 | self.haxor.handle_exit(document) 63 | mock_sys_exit.assert_called_with() 64 | -------------------------------------------------------------------------------- /tests/test_keys.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # Copyright 2015 Donne Martin. All Rights Reserved. 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"). You 6 | # may not use this file except in compliance with the License. A copy of 7 | # the License is located at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # or in the "license" file accompanying this file. This file is 12 | # distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF 13 | # ANY KIND, either express or implied. See the License for the specific 14 | # language governing permissions and limitations under the License. 15 | 16 | from __future__ import print_function 17 | 18 | from tests.compat import unittest 19 | 20 | from prompt_toolkit.key_binding.input_processor import KeyPress 21 | from prompt_toolkit.keys import Keys 22 | 23 | from haxor_news.haxor import Haxor 24 | 25 | 26 | class KeysTest(unittest.TestCase): 27 | 28 | def setUp(self): 29 | self.haxor = Haxor() 30 | self.registry = self.haxor.key_manager.manager.registry 31 | self.processor = self.haxor.cli.input_processor 32 | 33 | def test_F2(self): 34 | # orig_paginate = self.haxor.paginate_comments 35 | self.processor.feed(KeyPress(Keys.F2, u'')) 36 | self.processor.process_keys() 37 | # assert orig_paginate != self.haxor.paginate_comments 38 | 39 | def test_F10(self): 40 | with self.assertRaises(EOFError): 41 | self.processor.feed(KeyPress(Keys.F10, u'')) 42 | self.processor.process_keys() 43 | -------------------------------------------------------------------------------- /tests/test_toolbar.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # Copyright 2015 Donne Martin. All Rights Reserved. 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"). You 6 | # may not use this file except in compliance with the License. A copy of 7 | # the License is located at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # or in the "license" file accompanying this file. This file is 12 | # distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF 13 | # ANY KIND, either express or implied. See the License for the specific 14 | # language governing permissions and limitations under the License. 15 | 16 | from __future__ import print_function 17 | 18 | from tests.compat import unittest 19 | 20 | from pygments.token import Token 21 | 22 | from haxor_news.haxor import Haxor 23 | from haxor_news.toolbar import Toolbar 24 | 25 | 26 | class ToolbarTest(unittest.TestCase): 27 | 28 | def setUp(self): 29 | self.haxor = Haxor() 30 | self.toolbar = Toolbar(lambda: self.haxor.paginate_comments) 31 | 32 | def test_toolbar_on(self): 33 | self.haxor.paginate_comments = True 34 | expected = [ 35 | # (Token.Toolbar.On, 36 | # ' [F2] Paginate Comments: {0} '.format('ON')), 37 | (Token.Toolbar, ' [F10] Exit ') 38 | ] 39 | assert expected == self.toolbar.handler(None) 40 | 41 | def test_toolbar_off(self): 42 | self.haxor.paginate_comments = False 43 | expected = [ 44 | # (Token.Toolbar.Off, 45 | # ' [F2] Paginate Comments: {0} '.format('OFF')), 46 | (Token.Toolbar, ' [F10] Exit ') 47 | ] 48 | assert expected == self.toolbar.handler(None) 49 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | # Tox (http://tox.testrun.org/) is a tool for running tests 2 | # in multiple virtualenvs. This configuration file will run the 3 | # test suite on all supported python versions. To use it, "pip install tox" 4 | # and then run "tox" from this directory. 5 | 6 | [tox] 7 | envlist = py26, py27, py36, py37 8 | 9 | [testenv] 10 | passenv = TRAVIS TRAVIS_JOB_ID TRAVIS_BRANCH 11 | deps = 12 | coverage 13 | html2text 14 | mock 15 | unittest2 16 | commands = 17 | coverage erase 18 | coverage run {toxinidir}/tests/run_tests.py 19 | coverage report --include={toxinidir}/*haxor_news/* --omit={toxinidir}/*haxor_news/lib/* -m 20 | --------------------------------------------------------------------------------