├── .github └── workflows │ └── pythonpublish.yml ├── .gitignore ├── .idea ├── encodings.xml ├── git.iml ├── misc.xml ├── modules.xml └── vcs.xml ├── LICENSE ├── README.md ├── TreeGopher.py ├── build └── lib │ └── pituophis │ └── __init__.py ├── examples ├── catenifer.py ├── server.py ├── server_cgi.py ├── server_custom.py ├── server_custom_comments.py ├── server_custom_reroute.py ├── tests_client.py └── tests_client_ipv6.py ├── make_docs.sh ├── pituophis ├── __init__.py └── cli.py ├── requirements.txt ├── server.png ├── server_def.png ├── setup.py ├── sphinx ├── Makefile ├── make.bat ├── requirements.txt └── source │ ├── conf.py │ ├── index.rst │ └── modules.rst └── treegopher.png /.github/workflows/pythonpublish.yml: -------------------------------------------------------------------------------- 1 | # This workflows will upload a Python Package using Twine when a release is created 2 | # For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries 3 | 4 | name: Upload Python Package 5 | 6 | on: 7 | release: 8 | types: [created] 9 | 10 | jobs: 11 | deploy: 12 | 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v2 17 | - name: Set up Python 18 | uses: actions/setup-python@v2 19 | with: 20 | python-version: '3.x' 21 | - name: Install dependencies 22 | run: | 23 | python -m pip install --upgrade pip 24 | pip install setuptools wheel twine 25 | - name: Build and publish 26 | env: 27 | TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} 28 | TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} 29 | run: | 30 | python setup.py sdist bdist_wheel 31 | twine upload dist/* 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | pituophis/__pycache__/ 2 | dist/ 3 | build/ 4 | examples/pub/ 5 | Pituophis.egg-info/ -------------------------------------------------------------------------------- /.idea/encodings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /.idea/git.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 14 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 2-Clause License 2 | 3 | Copyright (c) 2020, dotcomboom and contributors 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | * Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | * Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 17 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 18 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 19 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 20 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 21 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 22 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 23 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 24 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 25 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Pituophis 2 | [![Documentation Status](https://readthedocs.org/projects/pituophis/badge/?version=latest)](https://pituophis.readthedocs.io/en/latest/?badge=latest) 3 | [![PyPI version](https://img.shields.io/pypi/v/Pituophis.svg)](https://pypi.python.org/pypi/Pituophis/) 4 | [![PyPI license](https://img.shields.io/pypi/l/Pituophis.svg)](https://pypi.python.org/pypi/Pituophis/) 5 | 6 | Python 3 library for building Gopher clients and servers 7 | 8 | ## Installation 9 | At a prompt, run `pip3 install pituophis` or `pip install pituophis` depending on your setup. You'll be able to import the package with `import pituophis`. 10 | 11 | ## Features 12 | 13 | - Make and send Gopher requests with the `Request` class 14 | - URL parsing with `pituophis.parse_url()` 15 | - Parse and iterate through Gopher menus with `Response.menu()` 16 | - Host Gopher servers on Python 3.7+, accepting requests asynchronously (using the same `Request` class) 17 | - Serve directories, files, and gophermaps out of the box from a publish directory ('pub/' by default) with the default handler 18 | - Use either a custom handler altogether or a handler to use when the default handler encounters a 404 for dynamic functionality 19 | 20 | ## Server 21 | 22 | Pituophis can act as a powerful Gopher server, with full Bucktooth-style gophermap and globbing support. Scripting is also supported through alt handlers (used in the event of a 404) or fully custom handlers (replaces Pituophis' handler entirely). 23 | 24 | The simplest method of getting a server up and running is with the `pituophis.serve()` function. See the [examples](https://github.com/dotcomboom/Pituophis/tree/master/examples) and [docs](https://pituophis.readthedocs.io/en/latest/#pituophis.serve) for more information. If you'd like to see a server built with Pituophis that can search an index, try [Gophew](https://github.com/dotcomboom/Gophew). 25 | 26 | ![server_def](https://github.com/dotcomboom/Pituophis/blob/master/server_def.png?raw=true) 27 | 28 | ### Quick Start 29 | A simple quick-start snippet is the following: 30 | ```py 31 | import pituophis 32 | pituophis.serve('127.0.0.1', 7070, pub_dir='pub/') # typical Gopher port is 70 33 | ``` 34 | 35 | Here's a basic alt handler, if you're familiar with Python scripting and would like to add more interactivity to your server: 36 | 37 | ```py 38 | def alt(request): 39 | if request.path == '/test': 40 | return [pituophis.Item(text='test!')] 41 | ``` 42 | 43 | You can return a list of Item objects, bytes, or text. To use your alt handler, add the argument `alt_handler=alt` to your serve() like this: 44 | 45 | ```py 46 | pituophis.serve("127.0.0.1", 7070, pub_dir='pub/', alt_handler=alt) 47 | ``` 48 | 49 | ## Client 50 | Pituophis can also grab files and parse menus from Gopher servers. Simple fetching is done with `Request().get()` and `get()`, and `Request().stream()` can be used for lower-level access as a BufferedReader. The `get` functions return a Response type. [See the docs](https://pituophis.readthedocs.io/en/latest/index.html) for more information. 51 | 52 | ### TreeGopher 53 | An interactive demo of Pituophis' client features is provided in the form of [TreeGopher](https://github.com/dotcomboom/Pituophis/blob/master/TreeGopher.py), a graphical Gopher client in <250 lines of code. It uses Pituophis, [PySimpleGUI](https://github.com/PySimpleGUI/PySimpleGUI), and [Pyperclip](https://pypi.org/project/pyperclip). It can browse Gopher in a hierarchical structure (similarly to WSGopher32, Cyberdog, and [Little Gopher Client](http://runtimeterror.com/tools/gopher/)), cache menus, read text files, download and save binary files (writing in chunks using `Request().stream()`, and running on another thread), recognize URL: links and use search services. 54 | 55 | ![](https://github.com/dotcomboom/Pituophis/blob/master/treegopher.png?raw=true) 56 | 57 | ### Examples 58 | Getting menus and files as plain text: 59 | ```python 60 | pituophis.get('gopher.floodgap.com').text() 61 | pituophis.get('gopher://gopher.floodgap.com/1/').text() 62 | pituophis.get('gopher://gopher.floodgap.com:70/0/gopher/proxy').text() 63 | ``` 64 | Getting a menu, parsed: 65 | ```python 66 | menu = pituophis.get('gopher.floodgap.com').menu() 67 | for item in menu: 68 | print(item.type) 69 | print(item.text) 70 | print(item.path) 71 | print(item.host) 72 | print(item.port) 73 | ``` 74 | Using search services: 75 | ```python 76 | pituophis.get('gopher://gopher.floodgap.com:70/7/v2/vs%09toast').text() 77 | ``` 78 | Downloading a binary: 79 | ```python 80 | pituophis.get('gopher://gopher.floodgap.com:70/9/gopher/clients/win/hgopher2_3.zip').binary 81 | ``` 82 | Requests can also be created from a URL: 83 | ```python 84 | import pituophis 85 | req = pituophis.parse_url('gopher://gopher.floodgap.com/7/v2/vs%09food') 86 | print('Getting', req.url()) 87 | rsp = req.get() 88 | print(rsp.text()) 89 | ``` 90 | -------------------------------------------------------------------------------- /TreeGopher.py: -------------------------------------------------------------------------------- 1 | import queue 2 | import threading 3 | import pituophis 4 | import PySimpleGUI as sg 5 | import pyperclip 6 | import os 7 | 8 | # This is a graphical Gopher client in under 250 lines of code, implemented with Pituophis and PySimpleGUI for an interface. Pyperclip is used for the "Copy URL" feature. 9 | # A tree is used for loading in menus, similar to the likes of WSGopher32 and Cyberdog. Backlinks are cut out, and menus are trimmed of blank selectors. Threaded binary downloads are supported as well. 10 | # Based on the Chat interface example here: https://pysimplegui.trinket.io/demo-programs#/demo-programs/chat-instant-message-front-end 11 | 12 | icons = {'1': b'iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAACXBIWXMAAAsSAAALEgHS3X78AAABnUlEQVQ4y8WSv2rUQRSFv7vZgJFFsQg2EkWb4AvEJ8hqKVilSmFn3iNvIAp21oIW9haihBRKiqwElMVsIJjNrprsOr/5dyzml3UhEQIWHhjmcpn7zblw4B9lJ8Xag9mlmQb3AJzX3tOX8Tngzg349q7t5xcfzpKGhOFHnjx+9qLTzW8wsmFTL2Gzk7Y2O/k9kCbtwUZbV+Zvo8Md3PALrjoiqsKSR9ljpAJpwOsNtlfXfRvoNU8Arr/NsVo0ry5z4dZN5hoGqEzYDChBOoKwS/vSq0XW3y5NAI/uN1cvLqzQur4MCpBGEEd1PQDfQ74HYR+LfeQOAOYAmgAmbly+dgfid5CHPIKqC74L8RDyGPIYy7+QQjFWa7ICsQ8SpB/IfcJSDVMAJUwJkYDMNOEPIBxA/gnuMyYPijXAI3lMse7FGnIKsIuqrxgRSeXOoYZUCI8pIKW/OHA7kD2YYcpAKgM5ABXk4qSsdJaDOMCsgTIYAlL5TQFTyUIZDmev0N/bnwqnylEBQS45UKnHx/lUlFvA3fo+jwR8ALb47/oNma38cuqiJ9AAAAAASUVORK5CYII=', 13 | '0': b'iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAACRklEQVQ4jX3TvU8iQRgGcGorG5PNJlzOHImFxXXmSgu1pLYkFhbGFmta6Cn8U6ysbAzgfgwwM6y7sx8z67IgMuoq1XPNSSTovclTvr95JpMplf5No9HYbLfbRrvdNlqtltFqtYxGo2HU63WjXq8bZ2dnRq1WM2q1mnF6erpR+jyXl5cbhJDb0WgkP4dzLhljklIqh8OhJIRI27blzc3N7f7+/uYSaDabJudcBUGAKIoQRRHiOEaSJJBSrsVxnLRarZqlT/VNzrkKwxBxHMN1XXQ6HXS73bWkaQpCyNdAFEWQUsLzPDDGwDnHaDRaSZZl68DFxYXJOVdCCKRpin6/j16vB8uy1pLnOQgh6eHh4SrAGFNCCDw8PEAIAc/zcH9/D9/34fs+giBAEASYTqfrwPn5uUkpVUopZFkGSiksy4Jt23AcB67rghACQghms9n3QJqmGI/HCMMQvu9DCIE8zzGfz6G1htYa8/kcg8FgFTg5OTGHw6EKggB5nq8AYRiuPOvz8zP6/f4qcHx8bBJCVJIkmEwmiOMYQojlopQSSikopfD6+vo9kGUZJpMJOOfLOw8GA1BKwRgDYwxFUawD1WrVdF1XZVmG6XSKJEmW1T9OXywWWCwWXwMHBwc/er3e+KPB4+Mjnp6eoLXGy8sLiqLA+/s73t7eUBQFbNvO9/b2tpeAYRg/r66uPMuyZp1OR3e7XX13d6cty9KO42jXdZehlI6vr6+9ra2tysqP3NnZ2d7d3f1dqVT+/C9HR0flcrn862PvLwGSkDy9SoL4AAAAAElFTkSuQmCC', 14 | '7': b'iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAACqUlEQVQ4jY2SXUhTYRjHn4pQtEJDGUU3dSV4U0hEV43uRlcl1oVd2Ae7KD+mJZUEnhzuhB+JLXaQdTbzuCMu54VG0XHfx2Q723Eed+bcmdOsdCOn6NRFmjvdSIjm6g/Pzfvw+/Hw5wXYlcbGRoler5d2d3dfRVH0zO592lAURbJj/vVP3kDK7vanRseDG3a7/XNbW1vOP2Gapm20J7CFGlyBN0PC5OuPQf6pbsTTRzFrHMetdXR05O0LDw4O1g97+C1VD8vNLawvRBeT8fB84isjxIVHuJvuG2JXaZqO7StwOOilZ7gtyghxYSa2+mX2e2I6HF3h2ak4qzTybxVqi9Pnn/yFouj5PTCCIHnOESZZrbFy3qlF/8TsMhuaTwxPfFs2U6NzpELHNt9usbx3jYVEg8FQt0eAomiuy+1NVr2yBCkuavEKC++4mSXSNh5TPiZ8d6txtu5Oi8Pk4UKiTqcr3yMQRfGAw+GIvyCd8XKt96UCZxUKLXvtPu6+WImz16twtvaJxuL0jQd+VlRUnPtrB11dXWVCOJKq1ph99zB3faWWvVWlZW9Uall5WbO5x8cLmwRBTO1bIgAARVF6IRxJYSZXrFZjZh5iFstzwhka9QspnudTnZ2dolKptKWVkCT5gGGYlYnJ0AYfDG1yHLdOEMQHkiSTJpNJVKvVokqlmk4rQRAkE0GQgoaGhgtyufwEABwsKSnJxzDsR29vr4hhmNjU1JQoKio6vJM7BACZAHAUAHIpiroUiURqwuFwTX9//2UAkGRlZZ1sb29fIklSHBgYEI1G45+PdXAHfBwAJMXFxQU4jss0Gs0VqVR6FgBOA8ApAJC0traGgsGgaLVaVwoLC4/sviIDALIB4BgA5ABA7vbkbL9lA0BGaWnpTZlMlp+2i//Nb4XAbVOmOUFgAAAAAElFTkSuQmCC', 15 | 'h': b'iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAC60lEQVQ4jYWTa0jTARTFb2grQijsW0kULfqW9sIia1ZkWVSmWRFhWUJqrexhoLMaFOTUMgktH/goclqW23y0NfO5Tc1tOififC7XKKnUzE3d+v9PX2JYjTyfz/lxufdcojkVvGBuz1+KFxbsyyySS4pK6npyimu7EkRSKf/Gs4srVwYu/G+Qy+UvKBLXNNU2GxiVthszMzOofqeCrNWCPJkeoqzX1qDTGRvdhgMDAz1lco21obULGm0njOYvqGvrhqrLCoV+GMrG9+jtG4SyrunnzmMJ2/4B5D1XvmIYBlNTU9C1G9BtHcOHcRYGix1KTTsmbTYwDAOr1Yr0zMIfXO6s3fj78326TQNOlmVRp2qF6fM0zOOAeRzosNjRqjeiuLIJFUo1+voHoNXqcDRSmOQCCO6Kjw8OWSBRNKGxdwL9o8DgGNAz4oTKaMGMwwGbzYbhj5+gbTfgtawaUXxhpwsgTHuR0qLvwlN5B6oMo2joncR7sx2a/gk064xgWRYsy8Jut+NVhQLil+U4fO6eiiicQ0REMQnFcQ9KtciXatDTb0bp2zaINZ9Q1GBBgUyDD8Mf8X3iB0ZGRqDV6XBB8BAhEaJ61wRHIlK3CvMbmTxpC1iWhcPhQJlCg5SyTgjFBlzNbUZW8RuYTCZUVb/BgeiHCD52+7EL4Od3ZsmlZJk+KVuJ0bExOJ1OfPk6irisesRmqhGRVovr919ArVYj80kuDkamTvP2Xtr5xxm3H0k8ESuqdCRnl2FoaAjZT8twUlSDsDtyBAsqcCoxFxKJBGf4Quw+GCdx16XVG4LO5ySlP2eq5Qrsu/YMu+LLwbtSiu2xheBFPUK84A627DlrIs+FPCLy/huwjIh2rPENyg6NFHwLu5rLHrqch/0xjxESnYHgEzcn/fxDaz08PMKJyJeI3D6ZNxGt53C8w3xWbL61btPhEr+AsPJVawMyvLyWRxMRj4jWENF8d+HZmkdEi34DlxLRYiLiuDP+AiIvzWJ84dNQAAAAAElFTkSuQmCC', 16 | 'i': b'iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAACXBIWXMAAA7EAAAOxAGVKw4bAAAAJUlEQVQ4jWNgGAWjYBQME8D4//9/TkZGxu+kavz//z83AwODEQAPzAc8kOdqJQAAAABJRU5ErkJggg==', 17 | '3': b'iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAACuklEQVQ4jX3T20pUcRQG8A8cpPZpcnSHmmZmhGVjBzTNiMxy//fM1OigXfUARVc9QvQMkQTdRFAg1E0lIlu0k5SlNZmZZzPMpnL27BkPeZi+LppMwVrw3a3fWjdrARtUl8tVNZSdfWVk5847IwUFtwezsi4/crmqNupdVy+A3eNeb6fj960sNTYwWV/HZKiei40NtE1jebSkpL0L2LUhHpVl75ejVZGfoRB/+nxMmiaTpsll0+SSaXJJCC7UBfmpony6Nz197zrcAuhTZQcnk2dOc+UPEoKLQvCHEFwQgvNCcE4Izgb8HCvdN2YBmasDhgsLbvwI+FfRHzAvBGeFYMIwGDcMOobBWG0to8JgOD+nCQBwE8icKjs0tWCaf7cIwYQQjAvxGwlBWwhGDYMzQvC7z8chb8nHZsCDTqD8W/VxzgYCTDQ1MW5ZjFsWnTWJtbUxZlmMWRaj164xEghw4shh3gf2o2vz5rMzp2roBIOMt7czkUisSywWo23btG2bjuMw2trKz8EgxyvL2ZGWVo9nLlfNtHGSM6EQnY6OVRiPx1fh2kzfusXR4mIOFBfRAo6hGdg2VFqSiBgGI34/pyoqOJGTwzG3myOaxmFN45Cm8YOqckBV+V5V+U5V2FuYG70L5KAZSO/Zkdc6rmdxVNM4kgKDqsoPKdCvKHynKOxTFIZlmWHPFj7epj+oBlwAAAs40leUPze4Zkt/CrxNodeyzF5ZZo8k8fn27MRDoGzdMT3V5Evh7bnLfZrGsCzzTQr1SBJfSRJfShJfyjK78rcudyrSxQ3P+bFbudBdkPv1te5hr2cLuxWF3bLM7gw3n+sePsnTI21u6fy/fmkTgKITQMP1DO3Bva2eiZY83b6fpzvNesbkVY/cWgmcA1AKQP/fU0oAcgHsOQBUlwI1ALwAdgDIAJC2tvkXFRyzrLKhFPAAAAAASUVORK5CYII=', 18 | '9': b'iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAC+0lEQVQ4jXVSX0gbBxz+tm7UIt2cL33pU0tf7MPc9jAGUgajb3kb+NI+CC10o+QhQ7YnEXTii4IpZEnUwD0UhRj1vJx3OfLv4sXsDAQ1eGpcjH849e6yIxcNadfBfn1ZS632e/6+D74/wAfg8XjaBUFQ4/H4kc/n6/wQ7xxCodADt9v9GQB4vd5v8vn8i62tLYpEIh4A6OnpaYlEIj9dEIbD4Svz8/OPFEX5JxqN/smy7G/pdHr14OCATNOkzc3NciaT8aqqmrJtm1Kp1HO32331nAnDMD9ns9lXGxsbpGka6bpOlmXR6ekpNZtNsm2bHMch0zRJFMVnl0YQBCGxt7dHx8fHZNs2NRoNchyH6vU62bZN1WqV8vn8Xm9vb+sF8cDAwN1CoVA3DIMcxyHDMHRZlseTyeTvOzs7a4Zh0NHREZVKJWIY5qnH42kHAIyPj3dyHMepqlqwLIvOzs6oWq3+t7Cw8Osb80AgcH91dXV/d3eXNE2jXC5XEwRhPRAI/IKpqaknlUqFarUaNZtNajQaVKlUzgYHB2+/M+k1SZJSxWKRVlZWaGlpiRKJBPn9/ikEg8HbsVjsD1VVs/V6nWq1GpmmSSzLDgWDwU8BwO/3/yjL8kkul6NMJkPhcHglFAr1jY6OfvW2A6/Xe2d7e/vUsiw6OTmhcrn8tyzLYVEUJwuFwl+KolA8HqdoNEoTExMPL11BUZS4rut0eHhIpVKJ1tfXSVVVkmWZYrEYsSxLHMe9GBsbu3dBPDc397RcLr/a39+nYrFIa2trtLy8TMlkkgRBIJ7naXFxkRKJBHEcVxgaGrpx7onpdPqJpmkveZ5PTU5ODs7MzGxIkvQvz/M0Ozur+3y+0MjIyANJknanp6fnLkvQ2tfX97itra0TwHcdHR0PGYbRRVGk/v7+GQD3AHS6XK7vAXwB4KP3Da4CuAHgDoCvW1pafhgeHs4yDGN1d3f3AvgWQAeAmwCuX1ri//gYwDUAn3d1dd1yuVxfAmgH0Argk/fJrwEaXuWjl/RWWwAAAABJRU5ErkJggg==', 19 | 'I': b'iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAABNklEQVQ4jWNQtnE47NvUdYAcrGLjcJzBr7VrX9W56//Jwb5NXQeINiB314HTwgpKV0VVVG/n7z1ykWQD+GVlTzJAgYiS2j2KDBBWVnlAsgF5Ow+fEZJXuiiipHwzb+/RCyQbQFIghkyYsb/46Nm7ZBkQOWv+XgYGBncRJdUZ5ScvPUaWKzxw4lLlmas/cBqQsmbTEQYGhghoWLFJaOstrDh15WXVuev/wyfO3MPAxJRjkZC2qeLM1b8YBuTtPHyGiZ09hwEVcMmbWS6zTEzdzMDAYAwV0/CsbtyGYkDRoZPXuISEGhiwAz4GBgYRNDHL8Glzd/s2dR1gMAmPOSIgIzeJgYGBEYcBuICvpIbOdQYGBoZdDAwMLCRqhoGJDAwMDC1kamZgYGBoYGBgYFjLwMBQQyZeAwCR3MS3bOe39AAAAABJRU5ErkJggg==', 20 | 'g': b'iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAABsUlEQVQ4ja2TTUsbURSG/SEttMY2m7rQKli6sIIrv9qJSZxMMr2TScebmBhc+EFLoBXLCMai1tAPbSkUuhKz6aZQiiLqQqR04cKlSUBwVcj8gKcLsWWSSRHaF57Nuee8vJd7blPT/5IwdXQzhmEJN6MGUVNDmDoNh+OPBAufbF4fLDG7NcPjr1kXohhAjY94G8QtgydrU4higN71TnoKt7m32u6ie7mNoBHwNtDMCN0v27nzvJWu2VsNURNhbwNrbJz9isNOqcpuucp+xWG37LBdctgqOXw7qVI8/ok1Me1tIJIZPvw4Y+37GbmVd0gpXcwsv0dKiWElXfVcLnduqMs0+b0yk4tvkVJSq4ta7ZmU8tzASKZZP6y4GmupNXIlSKQybByd1jVcOkEilWbzqMyzlTeXukJdAmGZFL5ssPj5I9P207r40ZSClJKHRsw9+HsPjBCThRDjS71kVu+SfdWBmPNzf+Iag1kfD6auo9ktKOEB72cMxvrQbB/qXDPq/BW0F1dR8z6G528wbPsJL/gJ5W+iRPob7IGpo4z0EdQGCUYvGPpDbAgl0v/3z/Qv+gW72bfPiU6yowAAAABJRU5ErkJggg==', 21 | 'M': b'iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAACgElEQVQ4jY2TO0/bYBSGrbZbh6oLXZEYUlUZarAipAoJib3/gaVTGaqOXRg6IDFWghE1HVARU5cCQSStycVOsENNnNi52LG/OL7ks3MRioQEb5cWkSJEX+noLOd5zlkOw9yRnZ2dl/lsdiWb/blyfJx+u7q6+uiu2VtRVfUdpfQyiiIEQQDf99Go1zvJZHLqXliv1T6EYXjlui46nQ4sy4JhGDAMA7qu93ief3onXFGU977vX90EG40GNE2DqqqoVqsol2VnbW3tyS1YKhZf+75/ads2TNOcABVFQblcBs/zODk5QaGQq0zA6+vrU47jRKZpotlsQtf1P9vKyOVy0HUdrutiPB5DkiTIsoxUav/jtWB7a2u62yX9mlZDqVSCoihwHAfD4RAXFxc4Pz/HaDTCcDgEIQSqquLoKPV5QuC6TmTZFvr9PsbjMUajEQaDAfr9PsIwBKUUvV4PkiTBdV2kUv8IHKcTDQYDyKcymq0mPM+D67rXvdvtolgsot1uw3EcHBzsJW8JoihCGIY4TB8im88i/SONDJ9Bhs8gnUnj7OwM3W4XhBDs7d0QbG1uTtu2FfV6PQRBAK2uodVuwQ98EELQbDWRF/PQ6hoIIbAsC/v7379MCEzTjDzPg9E2EIYhPM+D4zjodDoghMC2bVSqFWi6dlvwZnl5tl7Xh57vgYZ0ArIsa6JaRgsto4Xd3a/fGIZ5yDAMwywuvuJEUSC/TuWeLJeoJJVoqVSkpaJIRVGkoihQQRCoIBSoUChQQRCCjY1P2wzDPP57xIOlpYUXC/Pzs4lEgkskWC7BshzLshzLxjk2Hufi8TgXj8e4WCzGzc3NPZ+ZeXb/Y/1PfgOxF0ZCQvJSWwAAAABJRU5ErkJggg==', # mail/newsletter type, example here: gopher://rawtext.club/M/~cmccabe/pubnixhist/nixpub/1991-01-11 22 | 'cache': b'iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAACu0lEQVQ4ja2SSUwTUBCGB8FYQZTEAydMuHjRi8STB2PEcJIWJAgCgikURYJsrUrYqoSKLJaWrexBgQIa6EJBoC0CLWCAllpaKIiRRRRZIxe98HuwGIV68yVfXl4y/5d5mSH6n4d/6xIjh+vnvUcB1/8XGXsEexels7z+KdDJC6MmxgaxYFFj0aqBuTcP4+0pmOhIhUHGg1HOg7Ez60cB18/NoWBcxW+wdD2Atfshpl+nYaY3Hba+DMyqMzGnycZ7LR8fBnKgV+b7ORTM64VLX8afYXWiGGsGEdaNYmyYSrH5rgxb5nJsT0mwbZFgfrRccCBcJYg8/WnyOVZM9Vgx1eOz/T7AZD2sg2XDBwSvJMnne6vCoCoJRKc4AEoRC8piJhTF/lAI/SEXXv1NpyTS/GfWiYgYubxrPrqmO9DWhkNTHQZ11Q28LGShNZ8JuSgQ3WVB6CoJhEocgK5K9l8dHCIiL78L3hcNMh5GpLEYbuJA3xiNtqJgyMQhUJSG4k19JPprI6CtCYe6IU67/wdePmc8L1t6sjEpT4axIwmG9ntQVkRBUR6BwRexeNsah1HpbQw3x0LXltq5X3DylOeJK7P9Atj6MjDTk46umlioqmPQXRMDZSUbQ83xMClSYJQlY6Q9TbpfcMiN4eI7P1S4u6h/goUhAab7sqFtTICuJRED0kTMabIxq86ErTcDYyp+naM1OPtxVLS7ZhTjq0GE1XEhrOrHGGjhYnk0H8sjT7E0nIdFnQBT6vxSRwKPZUPl9x1bHXZmavFtugbblipsmSXYNFdgw1SG9ckSrBnEsPYL8+zTIyIiFyJyJSKPR/dDfbOSw1i8u8EhiTGsyIRoJjuBw+QkcgI5KfFB7PSksJu5mezrCdHMc0R0jIgO7+2Bs/1xhIiOEpEbEbkT0XE77vaAKxEx7LXOROT0E4+/rF25GHCBAAAAAElFTkSuQmCC' 23 | } 24 | icons['p'] = icons['I'] # pngs 25 | 26 | texttypes = ['0', '1', '7', 'h', 'M'] 27 | 28 | gophertree = sg.TreeData() 29 | 30 | sg.theme('DarkTeal1') # Add a touch of color 31 | 32 | context_menu = ['', '&Copy URL'] 33 | text_menu = ['', ['&Save...', '&Copy File URL']] 34 | 35 | gophermenu_layout = sg.Tree(data=gophertree, headings=[], change_submits=True, 36 | auto_size_columns=True, num_rows=26, col0_width=80, max_col_width=200, key='_TREE_', show_expanded=True, enable_events=True, right_click_menu=context_menu, font='Consolas 10', background_color='#fff', text_color='#000') 37 | 38 | plaintext_layout = sg.Multiline(key='-OUTPUT-', size=(80, 35), font=('Consolas 10'), background_color='#fff', right_click_menu=text_menu, autoscroll=False, disabled=True, metadata='') 39 | 40 | layout = [[gophermenu_layout, plaintext_layout], 41 | [sg.Button('<'), sg.Input(size=(84, 5), key='-QUERY-', do_not_clear=True, default_text="gopher://gopherproject.org/1/", enable_events=True), sg.Button('Go'), sg.Button('Clear Cache'), sg.Checkbox('Use hierarchy', key='-USETREE-', default=True), sg.Text('...', key='-LOADING-', visible=False)], 42 | [sg.StatusBar(text='0 menus in cache.', key='-CACHE-'), sg.Text('', key='-DOWNLOADS-', visible=True, size=(60, 1))]] 43 | 44 | window = sg.Window('TreeGopher', layout, font=('Segoe UI', ' 13'), default_button_element_size=(8, 1)) 45 | 46 | openNodes = [] 47 | 48 | cache = {} 49 | loadedTextURL = '' 50 | 51 | def trim_menu(menu): 52 | try: 53 | while menu[-1].text == '': 54 | del menu[-1] 55 | except: 56 | pass 57 | try: 58 | while menu[0].text == '': 59 | del menu[0] 60 | except: 61 | pass 62 | return menu 63 | 64 | 65 | def populate(parentNode, request): 66 | global gophertree, openNodes 67 | 68 | window.FindElement('-QUERY-').update(request.url()) 69 | window.FindElement('-LOADING-').update(visible=True) 70 | 71 | if not parentNode in openNodes: 72 | passes = 0 73 | from_cache = False 74 | try: 75 | if request.url() in cache: 76 | from_cache = True 77 | resp = cache[request.url()] 78 | else: 79 | resp = request.get() 80 | cache[request.url()] = resp 81 | passes += 1 82 | except: 83 | sg.popup("We're sorry!", request.url() + ' could not be fetched. Try again later.') 84 | if passes == 1: 85 | try: 86 | menu = trim_menu(resp.menu()) 87 | passes += 1 88 | except: 89 | sg.popup("We're sorry!", request.url() + ' could not be parsed as a menu for one reason or another.') 90 | if passes == 2: 91 | if from_cache: 92 | gophertree.insert(parentNode, request.url() + ' ', text='- This is a cached menu, double click to go to the live version -', values=[], icon=icons['cache']) 93 | for item in menu: 94 | if not item.request().url() in openNodes: 95 | sub_url = item.request().url() 96 | if item.path.startswith("URL:"): 97 | sub_url = item.path[4:] 98 | if item.type in icons: 99 | icon = icons[item.type] 100 | else: 101 | icon = icons['9'] 102 | if item.type == 'i': 103 | gophertree.insert(parentNode, sub_url, 104 | text=item.text, values=[], icon=icon) 105 | else: 106 | gophertree.insert(parentNode, sub_url, text=item.text, values=[ 107 | sub_url], icon=icon) 108 | 109 | openNodes.append(parentNode) 110 | 111 | window.FindElement('_TREE_').Update(gophertree) 112 | 113 | window.FindElement('-LOADING-').update(visible=False) 114 | 115 | gui_queue = queue.Queue() 116 | 117 | def download_thread(req, dlpath, gui_queue): # This uses Pituophis' Request().stream() function to download a file chunks at a time (instead of all in one shot like with .get()) 118 | with open(dlpath, "wb") as dl: 119 | remote_file = req.stream().makefile('rb') 120 | while True: 121 | piece = remote_file.read(1024) 122 | if not piece: 123 | break 124 | dl.write(piece) 125 | gui_queue.put(dlpath) # put a message into queue for GUI 126 | 127 | history = [] 128 | 129 | def dlPopup(url): 130 | return sg.popup_get_file('Where to save this file?', 'Download {}'.format( 131 | url), default_path=url.split('/')[-1], save_as=True) 132 | 133 | def go(url): 134 | global gophertree, openNodes, loadedTextURL 135 | 136 | window.FindElement('-LOADING-').update(visible=True) 137 | 138 | req = pituophis.parse_url(url) 139 | window.FindElement('-QUERY-').update(req.url()) 140 | if req.type in texttypes: 141 | if req.type in ['1', '7']: 142 | gophertree = sg.TreeData() 143 | gophertree.insert('', key=req.url(), text=req.url(), 144 | values=[req.url()], icon=icons[req.type]) 145 | parentNode = req.url() 146 | history.append(req.url()) 147 | openNodes = [] 148 | populate(parentNode, req) 149 | else: 150 | try: 151 | resp = req.get() 152 | loadedTextURL = req.url() 153 | window.FindElement('-OUTPUT-').update(resp.text()) 154 | except: 155 | sg.popup("We're sorry!", req.url() + ' could not be fetched. Try again later.') 156 | else: 157 | dlpath = dlPopup(req.url()) 158 | if not dlpath is None: 159 | window.FindElement('-DOWNLOADS-').update(value='Downloading {}'.format(dlpath)) 160 | threading.Thread(target=download_thread, args=(req, dlpath, gui_queue), daemon=True).start() 161 | 162 | window.FindElement('-LOADING-').update(visible=False) 163 | 164 | def plural(x): 165 | if x > 1 or x < 1: 166 | return 's' 167 | return '' 168 | 169 | previousvalue = None 170 | 171 | while True: # The Event Loop 172 | event, value = window.read() 173 | if event in (None, 'Exit'): # quit if exit button or X 174 | break 175 | elif event == '_TREE_': 176 | if value == previousvalue: 177 | previousevent = None 178 | # DOUBLE CLICK 179 | # TODO: cooldown 180 | window.FindElement('-LOADING-').update(visible=True) 181 | 182 | url = value['_TREE_'][0] 183 | 184 | if url.endswith(' '): 185 | url = url[:-9] 186 | del cache[url] 187 | go(url) 188 | else: 189 | if url.startswith('gopher'): 190 | req = pituophis.parse_url(url) 191 | if req.type == '1': 192 | parentNode = url 193 | if value['-USETREE-']: 194 | populate(parentNode, req) 195 | else: 196 | go(parentNode) 197 | elif req.type == '7': 198 | q = sg.popup_get_text('Search on ' + req.host, '') 199 | if not q is None: 200 | req.query = q 201 | go(req.url()) 202 | elif req.type != 'i': 203 | go(req.url()) 204 | 205 | window.FindElement('-LOADING-').update(visible=False) 206 | else: 207 | os.startfile(url) 208 | previousvalue = value 209 | elif event == 'Go': 210 | go(value['-QUERY-'].rstrip()) 211 | elif event == '<': 212 | if len(history) > 1: 213 | h = history[-2] 214 | history.remove(h) 215 | history.remove(history[-1]) 216 | go(h) 217 | elif event == 'Copy URL': 218 | url = value['_TREE_'][0] 219 | if url.endswith(' '): 220 | url = url[:-9] 221 | pyperclip.copy(url) 222 | elif event == 'Copy File URL': 223 | pyperclip.copy(loadedTextURL) 224 | elif event == 'Save...': 225 | dlpath = dlPopup(loadedTextURL) 226 | if not dlpath is None: 227 | with open(dlpath, 'w') as f: 228 | f.write(value['-OUTPUT-']) 229 | 230 | elif event == 'Clear Cache': 231 | cache = {} 232 | try: 233 | message = gui_queue.get_nowait() 234 | except queue.Empty: # get_nowait() will get exception when Queue is empty 235 | message = None # break from the loop if no more messages are queued up 236 | # if message received from queue, display the message in the Window 237 | if message: 238 | window.FindElement('-DOWNLOADS-').update(value='') 239 | if sg.popup_yes_no('Finished downloading {}. Would you like to open the downloaded file?'.format(message)): 240 | os.startfile(message) 241 | window.FindElement('-CACHE-').update(value='{} menu{} in cache.'.format(len(cache), plural(len(cache)))) 242 | window.close() -------------------------------------------------------------------------------- /build/lib/pituophis/__init__.py: -------------------------------------------------------------------------------- 1 | # BSD 2-Clause License 2 | # 3 | # Copyright (c) 2020, dotcomboom and contributors 4 | # All rights reserved. 5 | # 6 | # Redistribution and use in source and binary forms, with or without 7 | # modification, are permitted provided that the following conditions are met: 8 | # 9 | # * Redistributions of source code must retain the above copyright notice, this 10 | # List of conditions and the following disclaimer. 11 | # 12 | # * Redistributions in binary form must reproduce the above copyright notice, 13 | # this List of conditions and the following disclaimer in the documentation 14 | # and/or other materials provided with the distribution. 15 | # 16 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 17 | # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 18 | # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 19 | # DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 20 | # FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 21 | # DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 22 | # SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 23 | # CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 24 | # OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 25 | # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 26 | 27 | import asyncio 28 | import glob 29 | import mimetypes 30 | import os 31 | import re 32 | import socket 33 | from operator import itemgetter 34 | from os.path import realpath 35 | from urllib.parse import urlparse 36 | 37 | from natsort import natsorted 38 | 39 | # Quick note: 40 | # item types are not sent to the server, just the selector/path of the resource 41 | 42 | 43 | class Response: 44 | """ 45 | *Client.* Returned by Request.get() and get(). Represents a received binary object from a Gopher server. 46 | """ 47 | 48 | def __init__(self, stream): 49 | """ 50 | Reads a BufferedReader to the object's binary property and initializes a new Response object. 51 | """ 52 | self.binary = stream.read() 53 | """ 54 | The data received from the server as a Bytes binary object. 55 | """ 56 | 57 | def text(self): 58 | """ 59 | Returns the binary decoded as a UTF-8 String. 60 | """ 61 | return self.binary.decode('utf-8') 62 | 63 | def menu(self): 64 | """ 65 | Decodes the binary as UTF-8 text and parses it as a Gopher menu. Returns a List of Gopher menu items parsed as the Item type. 66 | """ 67 | return parse_menu(self.binary.decode('utf-8')) 68 | 69 | 70 | class Request: 71 | """ 72 | *Client/Server.* Represents a request to be sent to a Gopher server, or received from a client. 73 | """ 74 | 75 | def __init__(self, host='127.0.0.1', port=70, 76 | advertised_port=None, path='/', query='', 77 | itype='9', client='', 78 | pub_dir='pub/', alt_handler=False): 79 | """ 80 | Initializes a new Request object. 81 | """ 82 | self.host = str(host) 83 | """ 84 | *Client/Server.* The hostname of the server. 85 | """ 86 | self.port = int(port) 87 | """ 88 | *Client/Server.* The port of the server. For regular Gopher servers, this is most commonly 70, 89 | and for S/Gopher servers it is typically 105. 90 | """ 91 | if advertised_port is None: 92 | advertised_port = self.port 93 | 94 | self.advertised_port = int(advertised_port) 95 | """ 96 | *Server.* Used by the default handler. Set this if the server itself 97 | is being hosted on another port than the advertised port (like port 70), with 98 | a firewall or some other software rerouting that port to the server's real port. 99 | """ 100 | self.path = str(path) 101 | """ 102 | *Client/Server.* The selector string to request, or being requested. 103 | """ 104 | self.query = str(query) 105 | """ 106 | *Client/Server.* Search query for the server to process. Omitted when blank. 107 | """ 108 | self.type = str(itype) 109 | """ 110 | *Client.* Item type of the request. Purely for client-side usage, not used when sending or receiving requests. 111 | """ 112 | self.client = str(client) # only used in server 113 | """ 114 | *Server.* The IP address of the connected client. 115 | """ 116 | self.pub_dir = str(pub_dir) # only used in server 117 | """ 118 | *Server.* The default handler uses this as which directory to serve. Default is 'pub/'. 119 | """ 120 | self.alt_handler = alt_handler 121 | 122 | def stream(self): 123 | """ 124 | *Client.* Lower-level fetching. Sends the request and returns a BufferedReader. 125 | """ 126 | s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 127 | if self.host.count(':') > 1: 128 | # ipv6 129 | s = socket.socket(socket.AF_INET6, socket.SOCK_STREAM) 130 | s.settimeout(10.0) 131 | s.connect((self.host.replace('[', '').replace(']', ''), 132 | int(self.port))) 133 | if self.query == '': 134 | msg = self.path + '\r\n' 135 | else: 136 | msg = self.path + '\t' + self.query + '\r\n' 137 | s.sendall(msg.encode('utf-8')) 138 | return s 139 | 140 | def get(self): 141 | """ 142 | *Client.* Sends the request and returns a Response object. 143 | """ 144 | return Response(self.stream().makefile('rb')) 145 | 146 | def url(self): 147 | """ 148 | Returns a URL equivalent to the Request's properties. 149 | """ 150 | protocol = 'gopher' 151 | path = self.path 152 | query = '' 153 | if not (self.query == ''): 154 | query = '%09' + self.query 155 | hst = self.host 156 | if not self.port == 70: 157 | hst += ':{}'.format(self.port) 158 | return '{}://{}/{}{}{}'.format(protocol, hst, self.type, path, query) 159 | 160 | 161 | class Item: 162 | """ 163 | *Server/Client.* Represents an item in a Gopher menu. 164 | """ 165 | 166 | def __init__(self, itype='i', text='', path='/', host='', port=0): 167 | """ 168 | Initializes a new Item object. 169 | """ 170 | self.type = itype 171 | """ 172 | The type of item. 173 | """ 174 | self.text = text 175 | """ 176 | The name, or text that is displayed when the item is in a menu. 177 | """ 178 | self.path = path 179 | """ 180 | Where the item links to on the target server. 181 | """ 182 | self.host = host 183 | """ 184 | The hostname of the target server. 185 | """ 186 | self.port = port 187 | """ 188 | The port of the target server; most commonly 70. 189 | """ 190 | 191 | def source(self): 192 | """ 193 | Returns the item as a line in a Gopher menu. 194 | """ 195 | return str(self.type) + str(self.text) + '\t' + str(self.path) + '\t' + str(self.host) + '\t' + str( 196 | self.port) + '\r\n' 197 | 198 | def request(self): 199 | """ 200 | Returns a Request to where the item leads. 201 | """ 202 | req = Request() 203 | req.type = self.type 204 | req.host = self.host 205 | req.port = self.port 206 | req.path = self.path 207 | return req 208 | 209 | 210 | def parse_menu(source): 211 | """ 212 | *Client.* Parses a String as a Gopher menu. Returns a List of Items. 213 | """ 214 | parsed_menu = [] 215 | menu = source.replace('\r\n', '\n').replace('\n', '\r\n').split('\r\n') 216 | for line in menu: 217 | item = Item() 218 | if line.startswith('i'): 219 | item.type = 'i' 220 | item.text = line[1:].split('\t')[0] 221 | item.path = '/' 222 | item.host = '' 223 | item.port = 0 224 | else: 225 | line = line.split('\t') 226 | while len( 227 | line) > 4: # discard Gopher+ and other naughty stuff 228 | line = line[:-1] 229 | line = '\t'.join(line) 230 | matches = re.match(r'^(.)(.*)\t(.*)\t(.*)\t(.*)', line) 231 | if matches: 232 | item.type = matches[1] 233 | item.text = matches[2] 234 | item.path = matches[3] 235 | item.host = matches[4] 236 | item.port = matches[5] 237 | try: 238 | item.port = int(item.port) 239 | except: 240 | item.port = 70 241 | parsed_menu.append(item) 242 | return parsed_menu 243 | 244 | 245 | def parse_url(url): 246 | """ 247 | *Client.* Parses a Gopher URL and returns an equivalent Request. 248 | """ 249 | req = Request(host='', port=70, path='/', query='') 250 | 251 | up = urlparse(url) 252 | 253 | if up.scheme == '': 254 | up = urlparse('gopher://' + url) 255 | 256 | req.path = up.path 257 | if up.query: 258 | req.path += '?{}'.format(up.query) # NOT to be confused with actual gopher queries, which use %09 259 | # this just combines them back into one string 260 | req.host = up.hostname 261 | req.port = up.port 262 | if up.port is None: 263 | req.port = 70 264 | if req.path: 265 | if req.path.endswith('/'): 266 | req.type = '1' 267 | if len(req.path) > 1: 268 | req.type = req.path[1] 269 | req.path = req.path[2:] 270 | else: 271 | req.type = '1' 272 | 273 | if '%09' in req.path: # handle gopher queries 274 | req.query = ''.join(req.path.split('%09')[1:]) 275 | req.path = req.path.split('%09')[0] 276 | 277 | return req 278 | 279 | 280 | def get(host, port=70, path='/', query=''): 281 | """ 282 | *Client.* Quickly creates and sends a Request. Returns a Response object. 283 | """ 284 | req = Request(host=host, port=port, path=path, 285 | query=query) 286 | if '/' in host or ':' in host: 287 | req = parse_url(host) 288 | return req.get() 289 | 290 | 291 | # Server stuff 292 | mime_starts_with = { 293 | 'image': 'I', 294 | 'text': '0', 295 | 'audio/x-wav': 's', 296 | 'image/gif': 'g', 297 | 'text/html': 'h' 298 | } 299 | 300 | errors = { 301 | '404': Item(itype='3', text='404: {} does not exist.'), 302 | '403': Item(itype='3', text='403: Resource outside of publish directory.'), 303 | '403_glob': Item(itype='3', text='403: Gopher glob is out of scope.') 304 | } 305 | 306 | 307 | def parse_gophermap(source, def_host='127.0.0.1', def_port='70', 308 | gophermap_dir='/', pub_dir='pub/'): 309 | """ 310 | *Server.* Converts a Bucktooth-style Gophermap (as a String or List) into a Gopher menu as a List of Items to send. 311 | """ 312 | if not gophermap_dir.endswith('/'): 313 | gophermap_dir += '/' 314 | if not pub_dir.endswith('/'): 315 | pub_dir += '/' 316 | 317 | if type(source) == str: 318 | source = source.replace('\r\n', '\n').split('\n') 319 | new_menu = [] 320 | for item in source: 321 | if '\t' in item: 322 | # this is not information 323 | item = item.split('\t') 324 | expanded = False 325 | # 1Text pictures/ host.host port 326 | # ^ ^ ^ ^ 327 | itype = item[0][0] 328 | text = item[0][1:] 329 | path = gophermap_dir + text 330 | if itype == '1': 331 | path += '/' 332 | host = def_host 333 | port = def_port 334 | 335 | if len(item) > 1: 336 | path = item[1] 337 | if len(item) > 2: 338 | host = item[2] 339 | if len(item) > 3: 340 | port = item[3] 341 | 342 | if path == '': 343 | path = gophermap_dir + text 344 | if itype == '1': 345 | path += '/' 346 | 347 | if not path.startswith('URL:'): 348 | # fix relative path 349 | if not path.startswith('/'): 350 | path = realpath(gophermap_dir + '/' + path) 351 | 352 | # globbing 353 | if '*' in path: 354 | expanded = True 355 | if os.path.abspath(pub_dir) in os.path.abspath( 356 | pub_dir + path): 357 | g = natsorted(glob.glob(pub_dir + path)) 358 | 359 | listing = [] 360 | 361 | for file in g: 362 | file = re.sub( 363 | r'/{2}', r'/', file).replace('\\', '/') 364 | s = Item() 365 | s.type = itype 366 | if s.type == '?': 367 | s.type = '9' 368 | if path.startswith('URL:'): 369 | s.type = 'h' 370 | elif os.path.exists(file): 371 | mime = \ 372 | mimetypes.guess_type(file)[0] 373 | if mime is None: # is directory or binary 374 | if os.path.isdir(file): 375 | s.type = '1' 376 | else: 377 | s.type = '9' 378 | if file.endswith('.md'): 379 | s.type = 1 380 | else: 381 | for sw in mime_starts_with.keys(): 382 | if mime.startswith(sw): 383 | s.type = \ 384 | mime_starts_with[ 385 | sw] 386 | splt = file.split('/') 387 | while '' in splt: 388 | splt.remove('') 389 | s.text = splt[len(splt) - 1] 390 | if os.path.exists(file + '/gophertag'): 391 | s.text = ''.join(list(open( 392 | file + '/gophertag'))).replace( 393 | '\r\n', '').replace('\n', '') 394 | s.path = file.replace(pub_dir, '/', 1) 395 | s.path = re.sub(r'/{2}', r'/', s.path) 396 | s.host = host 397 | s.port = port 398 | if s.type == 'i': 399 | s.path = '' 400 | s.host = '' 401 | s.port = '0' 402 | if s.type == '1': 403 | d = 0 404 | else: 405 | d = 1 406 | if not s.path.endswith('gophermap'): 407 | if not s.path.endswith( 408 | 'gophertag'): 409 | listing.append( 410 | [file, s, s.text, d]) 411 | 412 | listing = natsorted(listing, 413 | key=itemgetter(0)) 414 | listing = natsorted(listing, 415 | key=itemgetter(2)) 416 | listing = natsorted(listing, 417 | key=itemgetter(3)) 418 | 419 | for item in listing: 420 | new_menu.append(item[1]) 421 | else: 422 | new_menu.append(errors['403_glob']) 423 | 424 | if not expanded: 425 | item = Item() 426 | item.type = itype 427 | item.text = text 428 | item.path = path 429 | item.host = host 430 | item.port = port 431 | 432 | if item.type == '?': 433 | item.type = '9' 434 | if path.startswith('URL:'): 435 | item.type = 'h' 436 | elif os.path.exists( 437 | pub_dir + path): 438 | mime = mimetypes.guess_type( 439 | pub_dir + path)[0] 440 | if mime is None: # is directory or binary 441 | if os.path.isdir(file): 442 | s.type = '1' 443 | else: 444 | s.type = '9' 445 | else: 446 | for sw in mime_starts_with.keys(): 447 | if mime.startswith(sw): 448 | item.type = \ 449 | mime_starts_with[sw] 450 | 451 | new_menu.append(item.source()) 452 | else: 453 | item = 'i' + item + '\t\t\t0' 454 | new_menu.append(item) 455 | return new_menu 456 | 457 | 458 | def handle(request): 459 | """ 460 | *Server.* Default handler function for Gopher requests while hosting a server. 461 | Serves files and directories from the pub/ directory by default, but the path can 462 | be changed in serve's pub_dir argument or changing the Request's pub_dir directory. 463 | """ 464 | ##### 465 | pub_dir = request.pub_dir 466 | ##### 467 | 468 | if request.advertised_port is None: 469 | request.advertised_port = request.port 470 | if request.path.startswith('URL:'): 471 | return """ 472 | 473 | 474 | 475 | Gopher Redirect 476 | 477 | 478 | 479 |

Gopher Redirect

480 |

You will be redirected to {0} shortly.

481 | 482 | """.format(request.path.split('URL:')[1]) 483 | 484 | menu = [] 485 | if request.path == '': 486 | request.path = '/' 487 | res_path = os.path.abspath( 488 | (pub_dir + request.path).replace('\\', '/').replace('//', '/')) 489 | if not res_path.startswith(os.path.abspath(pub_dir)): 490 | # Reject connections that try to break out of the publish directory 491 | return [errors['403']] 492 | if os.path.isdir(res_path): 493 | # is directory 494 | if os.path.exists(res_path): 495 | if os.path.isfile(res_path + '/gophermap'): 496 | in_file = open(res_path + '/gophermap', "r+") 497 | gmap = in_file.read() 498 | in_file.close() 499 | menu = parse_gophermap(source=gmap, 500 | def_host=request.host, 501 | def_port=request.advertised_port, 502 | gophermap_dir=request.path, 503 | pub_dir=pub_dir) 504 | else: 505 | gmap = '?*\t\r\n' 506 | menu = parse_gophermap(source=gmap, 507 | def_host=request.host, 508 | def_port=request.advertised_port, 509 | gophermap_dir=request.path, 510 | pub_dir=pub_dir) 511 | return menu 512 | elif os.path.isfile(res_path): 513 | in_file = open(res_path, "rb") 514 | data = in_file.read() 515 | in_file.close() 516 | return data 517 | 518 | if request.alt_handler: 519 | alt = request.alt_handler(request) 520 | if alt: 521 | return alt 522 | 523 | e = errors['404'] 524 | e.text = e.text.format(request.path) 525 | return [e] 526 | 527 | 528 | def serve(host="127.0.0.1", port=70, advertised_port=None, 529 | handler=handle, pub_dir='pub/', alt_handler=False, 530 | send_period=False, debug=True): 531 | """ 532 | *Server.* Starts serving Gopher requests. Allows for using a custom handler that will return a Bytes, String, or List 533 | object (which can contain either Strings or Items) to send to the client, or the default handler which can serve 534 | a directory. Along with the default handler, you can set an alternate handler to use if a 404 error is generated for 535 | dynamic applications. 536 | """ 537 | if pub_dir is None or pub_dir == '': 538 | pub_dir = '.' 539 | print('Gopher server is now running on', host + ':' + str(port) + '.') 540 | 541 | class GopherProtocol(asyncio.Protocol): 542 | def connection_made(self, transport): 543 | self.transport = transport 544 | print('Connected by', transport.get_extra_info('peername')) 545 | 546 | def data_received(self, data): 547 | request = data.decode('utf-8').replace('\r\n', '').split('\t') 548 | path = request[0] 549 | query = '' 550 | if len(request) > 1: 551 | query = request[1] 552 | if debug: 553 | print('Client requests: {}'.format(request)) 554 | 555 | resp = handler( 556 | Request(path=path, query=query, host=host, 557 | port=port, advertised_port=advertised_port, 558 | client=self.transport.get_extra_info( 559 | 'peername')[0], pub_dir=pub_dir, 560 | alt_handler=alt_handler)) 561 | 562 | if type(resp) == str: 563 | resp = bytes(resp, 'utf-8') 564 | elif type(resp) == list: 565 | out = "" 566 | for line in resp: 567 | if type(line) == Item: 568 | out += line.source() 569 | elif type(line) == str: 570 | line = line.replace('\r\n', '\n') 571 | line = line.replace('\n', '\r\n') 572 | if not line.endswith('\r\n'): 573 | line += '\r\n' 574 | out += line 575 | resp = bytes(out, 'utf-8') 576 | elif type(resp) == Item: 577 | resp = bytes(resp.source(), 'utf-8') 578 | 579 | self.transport.write(resp) 580 | if send_period: 581 | self.transport.write(b'.') 582 | 583 | self.transport.close() 584 | if debug: 585 | print('Connection closed') 586 | 587 | async def main(h, p): 588 | loop = asyncio.get_running_loop() 589 | server = await loop.create_server(GopherProtocol, h, p) 590 | await server.serve_forever() 591 | 592 | asyncio.run(main('0.0.0.0', port)) 593 | -------------------------------------------------------------------------------- /examples/catenifer.py: -------------------------------------------------------------------------------- 1 | import pituophis 2 | 3 | home = 'gopher://gopher.floodgap.com/1/' 4 | typeIcons = { 5 | "i": "ℹ️", 6 | "3": "⚠️", 7 | "1": "🚪", 8 | "0": "📝", 9 | "h": "🌎", 10 | "7": "🔍", 11 | "9": "⚙️" 12 | } 13 | noLinkTypes = {"i", "h", "3"} 14 | compatibleTypes = {'0', '1', '7'} 15 | menuTypes = {'1', '7'} 16 | lastType = '1' 17 | 18 | 19 | def bold(txt): 20 | return "\033[1m" + txt + "\033[0;0m" 21 | 22 | 23 | requests = {} 24 | 25 | 26 | def go(url, itype=''): 27 | req = pituophis.parse_url(url) 28 | # if req.url().endswith('/'): 29 | # req.type = '1' parse_url() now does this in Pituophis 1.0 30 | if itype == '7': 31 | req.type = itype 32 | print(bold('URL: ' + req.url())) 33 | if req.type == '7': 34 | if req.query == '': 35 | req.query = input(bold('Search term: ')) 36 | if req.type in compatibleTypes: 37 | resp = req.get() 38 | if req.type in menuTypes: 39 | menu = resp.menu() 40 | items = 0 41 | for selector in menu: 42 | text = typeIcons['9'] 43 | if selector.type in typeIcons: 44 | text = typeIcons[selector.type] 45 | text = text + ' ' + selector.text 46 | if selector.type not in noLinkTypes: 47 | items += 1 48 | requests[items] = selector.request() 49 | text = text + ' (' + requests[items].url() + ') ' + bold('[#' + str(items) + ']') 50 | if selector.path.startswith('URL:'): 51 | text = text + ' (' + selector.path.split('URL:')[1] + ')' 52 | print(text) 53 | elif req.type == '0': 54 | print(resp.text()) 55 | else: 56 | print("nyi binaries") 57 | 58 | 59 | go(home) 60 | while True: 61 | cmd = input(bold('# or URL: ')) 62 | try: 63 | cmd = int(cmd) 64 | go(requests[cmd].url(), requests[cmd].type) 65 | except Exception: 66 | go(cmd) 67 | -------------------------------------------------------------------------------- /examples/server.py: -------------------------------------------------------------------------------- 1 | import pituophis 2 | pituophis.serve("127.0.0.1", 70, pub_dir='pub/') # typical Gopher port is 70 3 | -------------------------------------------------------------------------------- /examples/server_cgi.py: -------------------------------------------------------------------------------- 1 | import pituophis 2 | 3 | def alt(request): 4 | if request.path == '/': 5 | return [pituophis.Item(text='root')] 6 | if request.path == '/test': 7 | return [pituophis.Item(text='test!')] 8 | 9 | pituophis.serve("127.0.0.1", 70, pub_dir='pub/', alt_handler=alt) # typical Gopher port is 70 10 | -------------------------------------------------------------------------------- /examples/server_custom.py: -------------------------------------------------------------------------------- 1 | import pituophis 2 | from pituophis import Item 3 | 4 | 5 | def handle(request): 6 | if request.path == '/txt': 7 | text = """ 8 | This is plain text. 9 | Nothing fancy. 10 | """ 11 | return text 12 | elif request.path == '/server.png': 13 | in_file = open("server.png", "rb") # you'd need a file with the name server.png in the working directory, naturally 14 | data = in_file.read() 15 | in_file.close() 16 | return data 17 | else: 18 | # Note that clients may send '.' or '' when they want the root of the server; 19 | # the . behavior has been observed in Gophpup (an early Windows client) and may be the case for others. 20 | menu = [ 21 | Item(text="Path: " + request.path), 22 | Item(text="Query: " + request.query), 23 | Item(text="Host: " + request.host), 24 | Item(text="Port: " + str(request.port)), 25 | Item(text="Client: " + request.client), 26 | Item(), 27 | Item(itype="I", text="View server.png", path="/server.png", host=request.host, port=request.port), 28 | Item(itype="0", text="View some text", path="/txt", host=request.host, port=request.port) 29 | ] 30 | return menu 31 | 32 | 33 | # serve with custom handler 34 | pituophis.serve("127.0.0.1", 70, handler=handle) 35 | -------------------------------------------------------------------------------- /examples/server_custom_comments.py: -------------------------------------------------------------------------------- 1 | import pituophis 2 | from pituophis import Item 3 | 4 | # TODO: Save comment entries to a file. 5 | 6 | comments = [] 7 | 8 | 9 | def handle(request): 10 | if request.path == '/add': 11 | menu = [Item(text="Comment added."), 12 | Item(itype='1', text="View comments", path="/", host=request.host, port=request.port)] 13 | comments.append(request.query) 14 | return menu 15 | 16 | menu = [Item(text='Welcome!'), 17 | Item(), 18 | Item(itype='7', text="Add a comment.", path="/add", host=request.host, port=request.port), 19 | Item()] 20 | if len(comments) == 0: 21 | menu.append(Item(text="There are no messages yet.. be the first!")) 22 | for entry in comments: 23 | menu.append(Item(text=str(entry))) 24 | return menu 25 | 26 | 27 | # serve with custom handler 28 | pituophis.serve("127.0.0.1", 70, handler=handle) 29 | -------------------------------------------------------------------------------- /examples/server_custom_reroute.py: -------------------------------------------------------------------------------- 1 | import pituophis 2 | 3 | # This example directly reroutes the client's request to another server and port. 4 | # It then fetches the request, then sends the received binary data. 5 | 6 | def handle(request): 7 | request.host = 'gopher.floodgap.com' 8 | request.port = 70 9 | resp = request.get() 10 | return resp.binary 11 | 12 | # serve with custom handler 13 | pituophis.serve("127.0.0.1", 70, handler=handle) 14 | -------------------------------------------------------------------------------- /examples/tests_client.py: -------------------------------------------------------------------------------- 1 | import pituophis 2 | 3 | # this is an antique already! 4 | 5 | yes = ['y', 'yes', 'yeah', 'yep', 'yup', 'ye', 'sure'] 6 | 7 | print(""" 8 | pituophis testing grounds 9 | would you like to... 10 | 1. view a gopher menu, parsed 11 | 2. view a gopher menu, unparsed 12 | 3. run a search for "test" with veronica 2 13 | 4. download a file 14 | 5. try it yourself 15 | 6. enter a host or URL 16 | """) 17 | 18 | choices = ['1', '2', '3', '4', '5', '6'] 19 | 20 | choice = '' 21 | while not choice in choices: 22 | choice = input('> ') 23 | 24 | host = 'gopher.floodgap.com' 25 | port = 70 26 | path = '/' 27 | query = '' 28 | binary = False 29 | menu = False 30 | 31 | if choice == '1': 32 | menu = True 33 | if choice == '2': 34 | pass 35 | if choice == '3': 36 | path = '/v2/vs' 37 | query = 'test' 38 | if choice == '4': 39 | binary = True 40 | #path = '/archive/info-mac/edu/yng/kid-pix.hqx' 41 | path = '/gopher/clients/win/hgopher2_3.zip' 42 | if choice == '5': 43 | host = input('host: ') 44 | port = int(input('port: ')) 45 | path = input('path: ') 46 | query = input('query: ') 47 | binary = False 48 | if input('binary? (y/n): ') in yes: 49 | binary = True 50 | menu = False 51 | if not binary: 52 | if input('menu? (y/n): ') in yes: 53 | menu = True 54 | if choice == '6': 55 | if input('binary? (y/n): ') in yes: 56 | binary = True 57 | host = input('host/url: ') 58 | 59 | response = pituophis.get(host, port=port, path=path, query=query) 60 | if binary: 61 | print(""" 62 | what to do with this binary? 63 | 1. decode utf-8 64 | 2. save to disk 65 | """) 66 | choices = ['1', '2'] 67 | choice = '' 68 | while not choice in choices: 69 | choice = input('> ') 70 | if choice == '1': 71 | print(response.text()) 72 | else: 73 | if choice == '2': 74 | suggested_filename = path.split('/')[len(path.split('/')) - 1] 75 | filename = input('filename (' + suggested_filename + ')? ') 76 | if filename == '': 77 | filename = suggested_filename 78 | with open(filename, "wb") as f: 79 | f.write(response.binary) 80 | else: 81 | if menu: 82 | print(response.menu()) 83 | else: 84 | print(response.text()) -------------------------------------------------------------------------------- /examples/tests_client_ipv6.py: -------------------------------------------------------------------------------- 1 | import pituophis 2 | 3 | # Run a server on localhost, port 70 4 | 5 | req = pituophis.Request() 6 | req.host = '[::1]' # or [0:0:0:0:0:0:0:1] (make sure you have []) 7 | resp = req.get() 8 | 9 | menu = resp.menu() 10 | 11 | for item in menu: 12 | print('--') 13 | print(item.type) 14 | print(item.text) 15 | print(item.path) 16 | print(item.host) 17 | print(item.port) 18 | 19 | print('--') 20 | -------------------------------------------------------------------------------- /make_docs.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # pdoc 4 | pdoc --html pituophis --html-dir docs/ --overwrite 5 | 6 | # sphinx 7 | cd sphinx 8 | make html 9 | make text -------------------------------------------------------------------------------- /pituophis/__init__.py: -------------------------------------------------------------------------------- 1 | # BSD 2-Clause License 2 | # 3 | # Copyright (c) 2020, dotcomboom and contributors 4 | # All rights reserved. 5 | # 6 | # Redistribution and use in source and binary forms, with or without 7 | # modification, are permitted provided that the following conditions are met: 8 | # 9 | # * Redistributions of source code must retain the above copyright notice, this 10 | # List of conditions and the following disclaimer. 11 | # 12 | # * Redistributions in binary form must reproduce the above copyright notice, 13 | # this List of conditions and the following disclaimer in the documentation 14 | # and/or other materials provided with the distribution. 15 | # 16 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 17 | # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 18 | # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 19 | # DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 20 | # FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 21 | # DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 22 | # SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 23 | # CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 24 | # OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 25 | # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 26 | # 27 | # Portions copyright solderpunk & VF-1 contributors, licensed under the BSD 2-Clause License above. 28 | 29 | import asyncio 30 | import glob 31 | import mimetypes 32 | import os 33 | import re 34 | import socket 35 | import ssl 36 | from operator import itemgetter 37 | from os.path import realpath 38 | from urllib.parse import urlparse 39 | 40 | from natsort import natsorted 41 | 42 | # Quick note: 43 | # item types are not sent to the server, just the selector/path of the resource 44 | 45 | 46 | class Response: 47 | """ 48 | *Client.* Returned by Request.get() and get(). Represents a received binary object from a Gopher server. 49 | """ 50 | 51 | def __init__(self, stream): 52 | """ 53 | Reads a BufferedReader to the object's binary property and initializes a new Response object. 54 | """ 55 | self.binary = stream.read() 56 | """ 57 | The data received from the server as a Bytes binary object. 58 | """ 59 | 60 | def text(self): 61 | """ 62 | Returns the binary decoded as a UTF-8 String. 63 | """ 64 | return self.binary.decode('utf-8') 65 | 66 | def menu(self): 67 | """ 68 | Decodes the binary as UTF-8 text and parses it as a Gopher menu. Returns a List of Gopher menu items parsed as the Item type. 69 | """ 70 | return parse_menu(self.binary.decode('utf-8')) 71 | 72 | 73 | class Request: 74 | """ 75 | *Client/Server.* Represents a request to be sent to a Gopher server, or received from a client. 76 | """ 77 | 78 | def __init__(self, host='127.0.0.1', port=70, 79 | advertised_port=None, path='/', query='', 80 | itype='9', client='', 81 | pub_dir='pub/', alt_handler=False): 82 | """ 83 | Initializes a new Request object. 84 | """ 85 | self.host = str(host) 86 | """ 87 | *Client/Server.* The hostname of the server. 88 | """ 89 | self.port = int(port) 90 | """ 91 | *Client/Server.* The port of the server. For regular Gopher servers, this is most commonly 70, 92 | and for S/Gopher servers it is typically 105. 93 | """ 94 | if advertised_port is None: 95 | advertised_port = self.port 96 | 97 | self.advertised_port = int(advertised_port) 98 | """ 99 | *Server.* Used by the default handler. Set this if the server itself 100 | is being hosted on another port than the advertised port (like port 70), with 101 | a firewall or some other software rerouting that port to the server's real port. 102 | """ 103 | self.path = str(path) 104 | """ 105 | *Client/Server.* The selector string to request, or being requested. 106 | """ 107 | self.query = str(query) 108 | """ 109 | *Client/Server.* Search query for the server to process. Omitted when blank. 110 | """ 111 | self.type = str(itype) 112 | """ 113 | *Client.* Item type of the request. Purely for client-side usage, not used when sending or receiving requests. 114 | """ 115 | self.client = str(client) # only used in server 116 | """ 117 | *Server.* The IP address of the connected client. 118 | """ 119 | self.pub_dir = str(pub_dir) # only used in server 120 | """ 121 | *Server.* The default handler uses this as which directory to serve. Default is 'pub/'. 122 | """ 123 | self.alt_handler = alt_handler 124 | 125 | def stream(self): 126 | """ 127 | *Client.* Lower-level fetching. Sends the request and returns a BufferedReader. 128 | """ 129 | s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 130 | if self.host.count(':') > 1: 131 | # ipv6 132 | s = socket.socket(socket.AF_INET6, socket.SOCK_STREAM) 133 | s.settimeout(10.0) 134 | s.connect((self.host.replace('[', '').replace(']', ''), 135 | int(self.port))) 136 | if self.query == '': 137 | msg = self.path + '\r\n' 138 | else: 139 | msg = self.path + '\t' + self.query + '\r\n' 140 | s.sendall(msg.encode('utf-8')) 141 | return s 142 | 143 | def get(self): 144 | """ 145 | *Client.* Sends the request and returns a Response object. 146 | """ 147 | return Response(self.stream().makefile('rb')) 148 | 149 | def url(self): 150 | """ 151 | Returns a URL equivalent to the Request's properties. 152 | """ 153 | protocol = 'gopher' 154 | path = self.path 155 | query = '' 156 | if not (self.query == ''): 157 | query = '%09' + self.query 158 | hst = self.host 159 | if not self.port == 70: 160 | hst += ':{}'.format(self.port) 161 | return '{}://{}/{}{}{}'.format(protocol, hst, self.type, path, query) 162 | 163 | 164 | class Item: 165 | """ 166 | *Server/Client.* Represents an item in a Gopher menu. 167 | """ 168 | 169 | def __init__(self, itype='i', text='', path='/', host='', port=0): 170 | """ 171 | Initializes a new Item object. 172 | """ 173 | self.type = itype 174 | """ 175 | The type of item. 176 | """ 177 | self.text = text 178 | """ 179 | The name, or text that is displayed when the item is in a menu. 180 | """ 181 | self.path = path 182 | """ 183 | Where the item links to on the target server. 184 | """ 185 | self.host = host 186 | """ 187 | The hostname of the target server. 188 | """ 189 | self.port = port 190 | """ 191 | The port of the target server; most commonly 70. 192 | """ 193 | 194 | def source(self): 195 | """ 196 | Returns the item as a line in a Gopher menu. 197 | """ 198 | return str(self.type) + str(self.text) + '\t' + str(self.path) + '\t' + str(self.host) + '\t' + str( 199 | self.port) + '\r\n' 200 | 201 | def request(self): 202 | """ 203 | Returns a Request to where the item leads. 204 | """ 205 | req = Request() 206 | req.type = self.type 207 | req.host = self.host 208 | req.port = self.port 209 | req.path = self.path 210 | return req 211 | 212 | 213 | def parse_menu(source): 214 | """ 215 | *Client.* Parses a String as a Gopher menu. Returns a List of Items. 216 | """ 217 | parsed_menu = [] 218 | menu = source.replace('\r\n', '\n').replace('\n', '\r\n').split('\r\n') 219 | for line in menu: 220 | item = Item() 221 | if line.startswith('i'): 222 | item.type = 'i' 223 | item.text = line[1:].split('\t')[0] 224 | item.path = '/' 225 | item.host = '' 226 | item.port = 0 227 | else: 228 | line = line.split('\t') 229 | while len( 230 | line) > 4: # discard Gopher+ and other naughty stuff 231 | line = line[:-1] 232 | line = '\t'.join(line) 233 | matches = re.match(r'^(.)(.*)\t(.*)\t(.*)\t(.*)', line) 234 | if matches: 235 | item.type = matches[1] 236 | item.text = matches[2] 237 | item.path = matches[3] 238 | item.host = matches[4] 239 | item.port = matches[5] 240 | try: 241 | item.port = int(item.port) 242 | except: 243 | item.port = 70 244 | parsed_menu.append(item) 245 | return parsed_menu 246 | 247 | 248 | def parse_url(url): 249 | """ 250 | *Client.* Parses a Gopher URL and returns an equivalent Request. 251 | """ 252 | req = Request(host='', port=70, path='/', query='') 253 | 254 | up = urlparse(url) 255 | 256 | if up.scheme == '': 257 | up = urlparse('gopher://' + url) 258 | 259 | req.path = up.path 260 | if up.query: 261 | req.path += '?{}'.format(up.query) # NOT to be confused with actual gopher queries, which use %09 262 | # this just combines them back into one string 263 | req.host = up.hostname 264 | req.port = up.port 265 | if up.port is None: 266 | req.port = 70 267 | if req.path: 268 | if req.path.endswith('/'): 269 | req.type = '1' 270 | if len(req.path) > 1: 271 | req.type = req.path[1] 272 | req.path = req.path[2:] 273 | else: 274 | req.type = '1' 275 | 276 | if '%09' in req.path: # handle gopher queries 277 | req.query = ''.join(req.path.split('%09')[1:]) 278 | req.path = req.path.split('%09')[0] 279 | 280 | return req 281 | 282 | 283 | def get(host, port=70, path='/', query=''): 284 | """ 285 | *Client.* Quickly creates and sends a Request. Returns a Response object. 286 | """ 287 | req = Request(host=host, port=port, path=path, 288 | query=query) 289 | if '/' in host or ':' in host: 290 | req = parse_url(host) 291 | return req.get() 292 | 293 | 294 | # Server stuff 295 | mime_starts_with = { 296 | 'image': 'I', 297 | 'text': '0', 298 | 'audio/x-wav': 's', 299 | 'image/gif': 'g', 300 | 'text/html': 'h' 301 | } 302 | 303 | errors = { 304 | '404': Item(itype='3', text='404: {} cannot be found.'), 305 | '403': Item(itype='3', text='403: Resource outside of publish directory.'), 306 | '403_glob': Item(itype='3', text='403: Gophermap glob is out of scope.') 307 | } 308 | 309 | 310 | def parse_gophermap(source, def_host='127.0.0.1', def_port='70', 311 | gophermap_dir='/', pub_dir='pub/'): 312 | """ 313 | *Server.* Converts a Bucktooth-style Gophermap (as a String or List) into a Gopher menu as a List of Items to send. 314 | """ 315 | if not gophermap_dir.endswith('/'): 316 | gophermap_dir += '/' 317 | if not pub_dir.endswith('/'): 318 | pub_dir += '/' 319 | 320 | if type(source) == str: 321 | source = source.replace('\r\n', '\n').split('\n') 322 | new_menu = [] 323 | for item in source: 324 | if '\t' in item: 325 | # this is not information 326 | item = item.split('\t') 327 | expanded = False 328 | # 1Text pictures/ host.host port 329 | # ^ ^ ^ ^ 330 | itype = item[0][0] 331 | text = item[0][1:] 332 | path = gophermap_dir + text 333 | if itype == '1': 334 | path += '/' 335 | host = def_host 336 | port = def_port 337 | 338 | if len(item) > 1: 339 | path = item[1] 340 | if len(item) > 2: 341 | host = item[2] 342 | if len(item) > 3: 343 | port = item[3] 344 | 345 | if path == '': 346 | path = gophermap_dir + text 347 | if itype == '1': 348 | path += '/' 349 | 350 | if not path.startswith('URL:'): 351 | # fix relative path 352 | if not path.startswith('/'): 353 | path = realpath(gophermap_dir + '/' + path) 354 | 355 | # globbing 356 | if '*' in path: 357 | expanded = True 358 | if os.path.abspath(pub_dir) in os.path.abspath( 359 | pub_dir + path): 360 | g = natsorted(glob.glob(pub_dir + path)) 361 | 362 | listing = [] 363 | 364 | for file in g: 365 | file = re.sub( 366 | r'/{2}', r'/', file).replace('\\', '/') 367 | s = Item() 368 | s.type = itype 369 | if s.type == '?': 370 | s.type = '9' 371 | if path.startswith('URL:'): 372 | s.type = 'h' 373 | elif os.path.exists(file): 374 | mime = \ 375 | mimetypes.guess_type(file)[0] 376 | if mime is None: # is directory or binary 377 | if os.path.isdir(file): 378 | s.type = '1' 379 | else: 380 | s.type = '9' 381 | if file.endswith('.md'): 382 | s.type = 1 383 | else: 384 | for sw in mime_starts_with.keys(): 385 | if mime.startswith(sw): 386 | s.type = \ 387 | mime_starts_with[ 388 | sw] 389 | splt = file.split('/') 390 | while '' in splt: 391 | splt.remove('') 392 | s.text = splt[len(splt) - 1] 393 | if os.path.exists(file + '/gophertag'): 394 | s.text = ''.join(list(open( 395 | file + '/gophertag'))).replace( 396 | '\r\n', '').replace('\n', '') 397 | s.path = file.replace(pub_dir, '/', 1) 398 | s.path = re.sub(r'/{2}', r'/', s.path) 399 | s.host = host 400 | s.port = port 401 | if s.type == 'i': 402 | s.path = '' 403 | s.host = '' 404 | s.port = '0' 405 | if s.type == '1': 406 | d = 0 407 | else: 408 | d = 1 409 | if not s.path.endswith('gophermap'): 410 | if not s.path.endswith( 411 | 'gophertag'): 412 | listing.append( 413 | [file, s, s.text, d]) 414 | 415 | listing = natsorted(listing, 416 | key=itemgetter(0)) 417 | listing = natsorted(listing, 418 | key=itemgetter(2)) 419 | listing = natsorted(listing, 420 | key=itemgetter(3)) 421 | 422 | for item in listing: 423 | new_menu.append(item[1]) 424 | else: 425 | new_menu.append(errors['403_glob']) 426 | 427 | if not expanded: 428 | item = Item() 429 | item.type = itype 430 | item.text = text 431 | item.path = path 432 | item.host = host 433 | item.port = port 434 | 435 | if item.type == '?': 436 | item.type = '9' 437 | if path.startswith('URL:'): 438 | item.type = 'h' 439 | elif os.path.exists( 440 | pub_dir + path): 441 | mime = mimetypes.guess_type( 442 | pub_dir + path)[0] 443 | if mime is None: # is directory or binary 444 | if os.path.isdir(file): 445 | s.type = '1' 446 | else: 447 | s.type = '9' 448 | else: 449 | for sw in mime_starts_with.keys(): 450 | if mime.startswith(sw): 451 | item.type = \ 452 | mime_starts_with[sw] 453 | 454 | new_menu.append(item.source()) 455 | else: 456 | item = 'i' + item + '\t\t\t0' 457 | new_menu.append(item) 458 | return new_menu 459 | 460 | 461 | def handle(request): 462 | """ 463 | *Server.* Default handler function for Gopher requests while hosting a server. 464 | Serves files and directories from the pub/ directory by default, but the path can 465 | be changed in serve's pub_dir argument or changing the Request's pub_dir directory. 466 | """ 467 | ##### 468 | pub_dir = request.pub_dir 469 | ##### 470 | 471 | if request.advertised_port is None: 472 | request.advertised_port = request.port 473 | if request.path.startswith('URL:'): 474 | return """ 475 | 476 | 477 | 478 | Gopher Redirect 479 | 480 | 481 | 482 |

Gopher Redirect

483 |

You will be redirected to {0} shortly.

484 | 485 | """.format(request.path.split('URL:')[1]) 486 | 487 | menu = [] 488 | if request.path in ['', '.']: 489 | request.path = '/' 490 | res_path = os.path.abspath( 491 | (pub_dir + request.path) 492 | .replace('\\', '/').replace('//', '/')) 493 | print(res_path) 494 | if not res_path.startswith(os.path.abspath(pub_dir)): 495 | # Reject connections that try to break out of the publish directory 496 | return [errors['403']] 497 | if os.path.isdir(res_path): 498 | # is directory 499 | if os.path.exists(res_path): 500 | if os.path.isfile(res_path + '/gophermap'): 501 | in_file = open(res_path + '/gophermap', "r+") 502 | gmap = in_file.read() 503 | in_file.close() 504 | menu = parse_gophermap(source=gmap, 505 | def_host=request.host, 506 | def_port=request.advertised_port, 507 | gophermap_dir=request.path, 508 | pub_dir=pub_dir) 509 | else: 510 | gmap = '?*\t\r\n' 511 | menu = parse_gophermap(source=gmap, 512 | def_host=request.host, 513 | def_port=request.advertised_port, 514 | gophermap_dir=request.path, 515 | pub_dir=pub_dir) 516 | return menu 517 | elif os.path.isfile(res_path): 518 | in_file = open(res_path, "rb") 519 | data = in_file.read() 520 | in_file.close() 521 | return data 522 | 523 | if request.alt_handler: 524 | alt = request.alt_handler(request) 525 | if alt: 526 | return alt 527 | 528 | e = errors['404'] 529 | e.text = e.text.format(request.path) 530 | return [e] 531 | 532 | 533 | def serve(host="127.0.0.1", port=70, advertised_port=None, 534 | handler=handle, pub_dir='pub/', alt_handler=False, debug=True): 535 | """ 536 | *Server.* Starts serving Gopher requests. Allows for using a custom handler that will return a Bytes, String, or List 537 | object (which can contain either Strings or Items) to send to the client, or the default handler which can serve 538 | a directory. Along with the default handler, you can set an alternate handler to use if a 404 error is generated for 539 | dynamic applications. send_period is good practice to the RFC and required for some clients to work. 540 | """ 541 | if pub_dir is None or pub_dir == '': 542 | pub_dir = '.' 543 | print('Gopher server is now running on', host + ':' + str(port) + '.') 544 | 545 | class GopherProtocol(asyncio.Protocol): 546 | def connection_made(self, transport): 547 | self.transport = transport 548 | print('Connected by', transport.get_extra_info('peername')) 549 | 550 | def data_received(self, data): 551 | request = data.decode('utf-8').replace('\r\n', '').replace('\n', '').split('\t') # \n is used at the end of Gopher Client (iOS)'s requests 552 | path = request[0] 553 | query = '' 554 | if len(request) > 1: 555 | query = request[1] 556 | if debug: 557 | print('Client requests: {}'.format(request)) 558 | 559 | resp = handler( 560 | Request(path=path, query=query, host=host, 561 | port=port, advertised_port=advertised_port, 562 | client=self.transport.get_extra_info( 563 | 'peername')[0], pub_dir=pub_dir, 564 | alt_handler=alt_handler)) 565 | 566 | if type(resp) == str: 567 | resp = bytes(resp, 'utf-8') 568 | elif type(resp) == list: 569 | out = "" 570 | for line in resp: 571 | if type(line) == Item: 572 | out += line.source() 573 | elif type(line) == str: 574 | line = line.replace('\r\n', '\n') 575 | line = line.replace('\n', '\r\n') 576 | if not line.endswith('\r\n'): 577 | line += '\r\n' 578 | out += line 579 | resp = bytes(out + '.\r\n', 'utf-8') # Menus are sent with the Lastline at the end; see below 580 | elif type(resp) == Item: 581 | resp = bytes(resp.source(), 'utf-8') 582 | 583 | self.transport.write(resp) 584 | 585 | # According to RFC 1436, the Lastline is '.'CR-LF ('.\r\n'), and it is preceded by 'a block' of ASCII text. 586 | # Lastline is now put in after menus; this functionality replaces the former send_period option. 587 | 588 | # Lastline is not put in after text file contents at this time, because that's kinda tricky: not all other servers do it, 589 | # most clients seem to show the Lastline when present, and the RFC suggested that clients be prepared for the server to send it without 590 | # for TextFile entities anyways (suggested that the client could then also use fingerd servers). 591 | 592 | self.transport.close() 593 | if debug: 594 | print('Connection closed') 595 | 596 | async def main(h, p): 597 | loop = asyncio.get_running_loop() 598 | server = await loop.create_server(GopherProtocol, h, p) 599 | await server.serve_forever() 600 | 601 | asyncio.run(main('0.0.0.0', port)) 602 | -------------------------------------------------------------------------------- /pituophis/cli.py: -------------------------------------------------------------------------------- 1 | import importlib 2 | import sys 3 | import pituophis 4 | 5 | # check if the user is running the script with the correct number of arguments 6 | if len(sys.argv) < 2: 7 | # if not, print the usage 8 | print('usage: pituophis [command] cd [options]') 9 | print('Commands:') 10 | print(' serve [options]') 11 | print(' fetch [url] [options]') 12 | print('Server Options:') 13 | print(' -H, --host=HOST\t\tAdvertised host (default: 127.0.0.1)') 14 | print(' -p, --port=PORT\t\tPort to bind to (default: 70)') 15 | print(' -a, --advertised-port=PORT\tPort to advertise') 16 | print(' -d, --directory=DIR\t\tDirectory to serve (default: pub/)') 17 | print(' -A, --alt-handler=HANDLER\tAlternate handler to use if 404 error is generated (python file with it defined as "def alt(request):")') 18 | print(' -s, --send-period\t\tSend a period at the end of each response (default: False)') 19 | print(' -D, --debug\t\t\tPrint requests as they are received (default: False)') 20 | print(' -v, --version\t\t\tPrint version') 21 | print('Fetch Options:') 22 | print(' -o, --output=FILE\t\tFile to write to (default: stdout)') 23 | else: 24 | # check if the user is serving or fetching 25 | if sys.argv[1] == 'serve': 26 | # check for arguments 27 | # host 28 | host = '127.0.0.1' 29 | if '-H' in sys.argv or '--host' in sys.argv: 30 | host = sys.argv[sys.argv.index('-H') + 1] 31 | # port 32 | port = 70 33 | if '-p' in sys.argv or '--port' in sys.argv: 34 | port = int(sys.argv[sys.argv.index('-p') + 1]) 35 | # advertised port 36 | advertised_port = None 37 | if '-a' in sys.argv or '--advertised-port' in sys.argv: 38 | advertised_port = int(sys.argv[sys.argv.index('-a') + 1]) 39 | # directory 40 | pub_dir = 'pub/' 41 | if '-d' in sys.argv or '--directory' in sys.argv: 42 | pub_dir = sys.argv[sys.argv.index('-d') + 1] 43 | # alternate handler 44 | alt_handler = False 45 | if '-A' in sys.argv or '--alt-handler' in sys.argv: 46 | alt_handler = sys.argv[sys.argv.index('-A') + 1] 47 | # get the function from the file 48 | alt_handler = getattr( 49 | importlib.import_module(alt_handler), 'handler') 50 | 51 | # send period 52 | send_period = False 53 | if '-s' in sys.argv or '--send-period' in sys.argv: 54 | send_period = True 55 | # debug 56 | debug = False 57 | if '-D' in sys.argv or '--debug' in sys.argv: 58 | debug = True 59 | # start the server 60 | pituophis.serve(host=host, port=port, advertised_port=advertised_port, 61 | handler=pituophis.handle, pub_dir=pub_dir, alt_handler=alt_handler, 62 | send_period=send_period, debug=debug) 63 | elif sys.argv[1] == 'fetch': 64 | # check for arguments 65 | # url 66 | url = sys.argv[2] 67 | # output file 68 | output = 'stdout' 69 | if '-o' in sys.argv or '--output' in sys.argv: 70 | output = sys.argv[sys.argv.index('-o') + 1] 71 | # start the fetch 72 | o = pituophis.get(url) 73 | if output == 'stdout': 74 | sys.stdout.buffer.write(o.binary) 75 | else: 76 | with open(output, 'wb') as f: 77 | f.write(o.binary) 78 | f.close() -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | natsort -------------------------------------------------------------------------------- /server.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dotcomboom/Pituophis/85464ad281c84dc68043ebe06a467b24dfa1f747/server.png -------------------------------------------------------------------------------- /server_def.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dotcomboom/Pituophis/85464ad281c84dc68043ebe06a467b24dfa1f747/server_def.png -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | with open("README.md", "r") as fh: 4 | long_description = fh.read() 5 | 6 | setup( 7 | name='Pituophis', 8 | version='1.2', 9 | install_requires=['natsort'], 10 | packages=['pituophis'], 11 | url='https://github.com/dotcomboom/Pituophis', 12 | license='BSD 2-Clause License', 13 | author='dotcomboom', 14 | author_email='dotcomboom@somnolescent.net', 15 | description='Gopher client and server module for Python 3', 16 | long_description=long_description, 17 | long_description_content_type="text/markdown", 18 | classifiers=[ 19 | "Development Status :: 5 - Production/Stable", 20 | "Intended Audience :: Developers", 21 | "Intended Audience :: Information Technology", 22 | "Natural Language :: English", 23 | "Programming Language :: Python :: 3", 24 | "License :: OSI Approved :: BSD License", 25 | "Operating System :: OS Independent" 26 | ], 27 | ) 28 | -------------------------------------------------------------------------------- /sphinx/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | SOURCEDIR = source 8 | # BUILDDIR = build 9 | BUILDDIR = ../docs 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) -------------------------------------------------------------------------------- /sphinx/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=source 11 | set BUILDDIR=build 12 | 13 | if "%1" == "" goto help 14 | 15 | %SPHINXBUILD% >NUL 2>NUL 16 | if errorlevel 9009 ( 17 | echo. 18 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 19 | echo.installed, then set the SPHINXBUILD environment variable to point 20 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 21 | echo.may add the Sphinx directory to PATH. 22 | echo. 23 | echo.If you don't have Sphinx installed, grab it from 24 | echo.http://sphinx-doc.org/ 25 | exit /b 1 26 | ) 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /sphinx/requirements.txt: -------------------------------------------------------------------------------- 1 | sphinx_rtd_theme 2 | recommonmark 3 | sphinx == 1.8.4 4 | natsort 5 | -------------------------------------------------------------------------------- /sphinx/source/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Configuration file for the Sphinx documentation builder. 4 | # 5 | # This file does only contain a selection of the most common options. For a 6 | # full list see the documentation: 7 | # http://www.sphinx-doc.org/en/master/config 8 | 9 | # -- Path setup -------------------------------------------------------------- 10 | 11 | # If extensions (or modules to document with autodoc) are in another directory, 12 | # add these directories to sys.path here. If the directory is relative to the 13 | # documentation root, use os.path.abspath to make it absolute, like shown here. 14 | # 15 | import os 16 | import sys 17 | 18 | sys.path.insert(0, os.path.abspath('../../')) 19 | 20 | # -- Project information ----------------------------------------------------- 21 | 22 | project = 'Pituophis' 23 | copyright = '2019, dotcomboom' 24 | author = 'dotcomboom' 25 | 26 | # The short X.Y version 27 | version = '' 28 | # The full version, including alpha/beta/rc tags 29 | release = '' 30 | 31 | # -- General configuration --------------------------------------------------- 32 | 33 | # If your documentation needs a minimal Sphinx version, state it here. 34 | # 35 | # needs_sphinx = '1.0' 36 | 37 | # Add any Sphinx extension module names here, as strings. They can be 38 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 39 | # ones. 40 | extensions = [ 41 | 'sphinx.ext.autodoc', 42 | 'sphinx.ext.napoleon', 43 | 'sphinx.ext.todo', 44 | 'sphinx.ext.coverage', 45 | 'sphinx.ext.githubpages', 46 | 'recommonmark' 47 | ] 48 | 49 | # Add any paths that contain templates here, relative to this directory. 50 | templates_path = ['_templates'] 51 | 52 | # The suffix(es) of source filenames. 53 | # You can specify multiple suffix as a list of string: 54 | # 55 | # source_suffix = ['.rst', '.md'] 56 | source_suffix = ['.rst', '.md'] 57 | 58 | # The master toctree document. 59 | master_doc = 'index' 60 | 61 | # The language for content autogenerated by Sphinx. Refer to documentation 62 | # for a list of supported languages. 63 | # 64 | # This is also used if you do content translation via gettext catalogs. 65 | # Usually you set "language" from the command line for these cases. 66 | language = None 67 | 68 | # List of patterns, relative to source directory, that match files and 69 | # directories to ignore when looking for source files. 70 | # This pattern also affects html_static_path and html_extra_path. 71 | exclude_patterns = [] 72 | 73 | # The name of the Pygments (syntax highlighting) style to use. 74 | pygments_style = None 75 | 76 | # -- Options for HTML output ------------------------------------------------- 77 | 78 | # The theme to use for HTML and HTML Help pages. See the documentation for 79 | # a list of builtin themes. 80 | # 81 | html_theme = 'sphinx_rtd_theme' 82 | 83 | # Theme options are theme-specific and customize the look and feel of a theme 84 | # further. For a list of options available for each theme, see the 85 | # documentation. 86 | # 87 | # html_theme_options = {} 88 | 89 | # Add any paths that contain custom static files (such as style sheets) here, 90 | # relative to this directory. They are copied after the builtin static files, 91 | # so a file named "default.css" will overwrite the builtin "default.css". 92 | html_static_path = ['_static'] 93 | 94 | # Custom sidebar templates, must be a dictionary that maps document names 95 | # to template names. 96 | # 97 | # The default sidebars (for documents that don't match any pattern) are 98 | # defined by theme itself. Builtin themes are using these templates by 99 | # default: ``['localtoc.html', 'relations.html', 'sourcelink.html', 100 | # 'searchbox.html']``. 101 | # 102 | # html_sidebars = {} 103 | 104 | 105 | # -- Options for HTMLHelp output --------------------------------------------- 106 | 107 | # Output file base name for HTML help builder. 108 | htmlhelp_basename = 'Pituophisdoc' 109 | 110 | # -- Options for LaTeX output ------------------------------------------------ 111 | 112 | latex_elements = { 113 | # The paper size ('letterpaper' or 'a4paper'). 114 | # 115 | # 'papersize': 'letterpaper', 116 | 117 | # The font size ('10pt', '11pt' or '12pt'). 118 | # 119 | # 'pointsize': '10pt', 120 | 121 | # Additional stuff for the LaTeX preamble. 122 | # 123 | # 'preamble': '', 124 | 125 | # Latex figure (float) alignment 126 | # 127 | # 'figure_align': 'htbp', 128 | } 129 | 130 | # Grouping the document tree into LaTeX files. List of tuples 131 | # (source start file, target name, title, 132 | # author, documentclass [howto, manual, or own class]). 133 | latex_documents = [ 134 | (master_doc, 'Pituophis.tex', 'Pituophis Documentation', 135 | 'dotcomboom', 'manual'), 136 | ] 137 | 138 | # -- Options for manual page output ------------------------------------------ 139 | 140 | # One entry per manual page. List of tuples 141 | # (source start file, name, description, authors, manual section). 142 | man_pages = [ 143 | (master_doc, 'pituophis', 'Pituophis Documentation', 144 | [author], 1) 145 | ] 146 | 147 | # -- Options for Texinfo output ---------------------------------------------- 148 | 149 | # Grouping the document tree into Texinfo files. List of tuples 150 | # (source start file, target name, title, author, 151 | # dir menu entry, description, category) 152 | texinfo_documents = [ 153 | (master_doc, 'Pituophis', 'Pituophis Documentation', 154 | author, 'Pituophis', 'One line description of project.', 155 | 'Miscellaneous'), 156 | ] 157 | 158 | # -- Options for Epub output ------------------------------------------------- 159 | 160 | # Bibliographic Dublin Core info. 161 | epub_title = project 162 | 163 | # The unique identifier of the text. This can be a ISBN number 164 | # or the project homepage. 165 | # 166 | # epub_identifier = '' 167 | 168 | # A unique identification for the text. 169 | # 170 | # epub_uid = '' 171 | 172 | # A list of files that should not be packed into the epub file. 173 | epub_exclude_files = ['search.html'] 174 | 175 | # -- Extension configuration ------------------------------------------------- 176 | 177 | # -- Options for todo extension ---------------------------------------------- 178 | 179 | # If true, `todo` and `todoList` produce output, else they produce nothing. 180 | todo_include_todos = True 181 | -------------------------------------------------------------------------------- /sphinx/source/index.rst: -------------------------------------------------------------------------------- 1 | Pituophis API 2 | ================= 3 | 4 | * :ref:`genindex` 5 | 6 | .. toctree:: 7 | :maxdepth: 2 8 | :caption: Contents: 9 | 10 | .. automodule:: pituophis 11 | :members: 12 | :undoc-members: 13 | :show-inheritance: -------------------------------------------------------------------------------- /sphinx/source/modules.rst: -------------------------------------------------------------------------------- 1 | pituophis 2 | ========= 3 | 4 | .. toctree:: 5 | :maxdepth: 4 6 | 7 | pituophis 8 | -------------------------------------------------------------------------------- /treegopher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dotcomboom/Pituophis/85464ad281c84dc68043ebe06a467b24dfa1f747/treegopher.png --------------------------------------------------------------------------------