├── .gitignore
├── LICENSE
├── README.md
├── binder
└── requirements.txt
├── docs
└── images
│ └── hello.pyhp.png
├── hi.pyhp
├── pyhp
├── __init__.py
├── __main__.py
├── app.py
└── extension.py
├── setup.py
└── test.html
/.gitignore:
--------------------------------------------------------------------------------
1 | # Byte-compiled / optimized / DLL files
2 | __pycache__/
3 | *.py[cod]
4 | *$py.class
5 |
6 | # C extensions
7 | *.so
8 |
9 | # Distribution / packaging
10 | .Python
11 | build/
12 | develop-eggs/
13 | dist/
14 | downloads/
15 | eggs/
16 | .eggs/
17 | lib/
18 | lib64/
19 | parts/
20 | sdist/
21 | var/
22 | wheels/
23 | pip-wheel-metadata/
24 | share/python-wheels/
25 | *.egg-info/
26 | .installed.cfg
27 | *.egg
28 | MANIFEST
29 |
30 | # PyInstaller
31 | # Usually these files are written by a python script from a template
32 | # before PyInstaller builds the exe, so as to inject date/other infos into it.
33 | *.manifest
34 | *.spec
35 |
36 | # Installer logs
37 | pip-log.txt
38 | pip-delete-this-directory.txt
39 |
40 | # Unit test / coverage reports
41 | htmlcov/
42 | .tox/
43 | .nox/
44 | .coverage
45 | .coverage.*
46 | .cache
47 | nosetests.xml
48 | coverage.xml
49 | *.cover
50 | *.py,cover
51 | .hypothesis/
52 | .pytest_cache/
53 |
54 | # Translations
55 | *.mo
56 | *.pot
57 |
58 | # Django stuff:
59 | *.log
60 | local_settings.py
61 | db.sqlite3
62 | db.sqlite3-journal
63 |
64 | # Flask stuff:
65 | instance/
66 | .webassets-cache
67 |
68 | # Scrapy stuff:
69 | .scrapy
70 |
71 | # Sphinx documentation
72 | docs/_build/
73 |
74 | # PyBuilder
75 | target/
76 |
77 | # Jupyter Notebook
78 | .ipynb_checkpoints
79 |
80 | # IPython
81 | profile_default/
82 | ipython_config.py
83 |
84 | # pyenv
85 | .python-version
86 |
87 | # pipenv
88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies
90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not
91 | # install all needed dependencies.
92 | #Pipfile.lock
93 |
94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow
95 | __pypackages__/
96 |
97 | # Celery stuff
98 | celerybeat-schedule
99 | celerybeat.pid
100 |
101 | # SageMath parsed files
102 | *.sage.py
103 |
104 | # Environments
105 | .env
106 | .venv
107 | env/
108 | venv/
109 | ENV/
110 | env.bak/
111 | venv.bak/
112 |
113 | # Spyder project settings
114 | .spyderproject
115 | .spyproject
116 |
117 | # Rope project settings
118 | .ropeproject
119 |
120 | # mkdocs documentation
121 | /site
122 |
123 | # mypy
124 | .mypy_cache/
125 | .dmypy.json
126 | dmypy.json
127 |
128 | # Pyre type checker
129 | .pyre/
130 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | BSD 3-Clause License
2 |
3 | Copyright (c) 2020, Yuvi Panda
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 | 1. Redistributions of source code must retain the above copyright notice, this
10 | list of conditions and the following disclaimer.
11 |
12 | 2. 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 | 3. Neither the name of the copyright holder nor the names of its
17 | contributors may be used to endorse or promote products derived from
18 | this software without specific prior written permission.
19 |
20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
30 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # (Py)HP (H)ypertext (P)reprocessor
2 |
3 | I want PHP style 'quick' template execution, but with Python instead.
4 |
5 | 
6 |
7 | **Warning**: Currently *very* insecure. Do not use.
8 |
9 |
10 | ## Credit
11 |
12 | I stole a lot of code [from stackoverflow](https://stackoverflow.com/a/55545295). Thanks
13 | to [@aarondewindt](https://github.com/aarondewindt) for helping make this a reality.
14 |
15 | ## Installation
16 |
17 | PyHP is installable with `pip`.
18 |
19 |
20 | ``` bash
21 | pip install pyhp-hypertext-preprocessor
22 | ```
23 |
24 | You can then serve `.pyhp` files fom your current directory with:
25 |
26 | ``` bash
27 | pyhp
28 | ```
29 |
30 | It is accessible at `http://localhost:5000`. If you have an `index.pyhp` file,
31 | that will be served. If not, you'll have to explicitly go to `http://localhost:5000/myfile.pyhp`
32 | to execute `myfile.pyhp`. Static files with common extensions (like css, js, jpg, etc)
33 | will automatically be served as static files.
34 |
35 | ## Production install
36 |
37 | We suggest using [gunicorn](https://gunicorn.org/) to deploy to production.
38 | `gunicorn pyhp` should start serving files from the current working directory
39 | of `gunicorn`.
40 |
41 | ## Setup
42 |
43 | You can set it up for local development with:
44 |
45 | 1. Fork this repository & clone your fork to your local machine.
46 | 2. Setup a virtual environment in your clone:
47 |
48 | ```bash
49 | python3 -m venv venv
50 | source venv/bin/activate
51 | ```
52 |
53 | 3. Install the `pyhp` package
54 |
55 | ```bash
56 | pip install -e .
57 | ```
58 |
59 | 4. Run `pyhp`!
60 |
61 | ```bash
62 | python3 -m pyhp.app
63 | ```
64 |
65 | This should serve the contents of the current directory. You can
66 | try going to `http://localhost:5000/hi.pyhp` to test it out.
67 |
--------------------------------------------------------------------------------
/binder/requirements.txt:
--------------------------------------------------------------------------------
1 | jupyter-server-proxy>=1.5.2
2 | flask
3 |
--------------------------------------------------------------------------------
/docs/images/hello.pyhp.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/yuvipanda/pyhp/dab2e6991265bb897bf4e96a38ccfa616a2fac56/docs/images/hello.pyhp.png
--------------------------------------------------------------------------------
/hi.pyhp:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
7 |
8 |
9 | {% py %}
10 | import os
11 | import random
12 |
13 | user_name = os.environ['USER']
14 | {% endpy %}
15 |
16 |
17 | Access variable set earlier: {{ user_name }}
18 |
19 |
20 |
21 | Inline call python imports from earlier: {{ random.randint(0, 10) }}
22 |
23 |
24 |
25 |
26 |
--------------------------------------------------------------------------------
/pyhp/__init__.py:
--------------------------------------------------------------------------------
1 | # Import application here so we can run `gunicorn pyhp`
2 | from .app import application
--------------------------------------------------------------------------------
/pyhp/__main__.py:
--------------------------------------------------------------------------------
1 | from .app import application
2 |
3 |
4 | def main():
5 | application.run(debug=True)
6 |
7 | if __name__ == '__main__':
8 | main()
--------------------------------------------------------------------------------
/pyhp/app.py:
--------------------------------------------------------------------------------
1 | import logging
2 | from flask import Flask, make_response, send_from_directory, request, session
3 | from werkzeug.middleware.proxy_fix import ProxyFix
4 | from jinja2 import Environment, FileSystemLoader, TemplateNotFound
5 | import os
6 | from .extension import PythonExtension
7 |
8 | # FIXME: Make this configurable
9 | BASE_DIR = os.getcwd()
10 |
11 | # File extensions we serve unchanged by default
12 | # This prevents the common PHP mistake of exposing your credentials file
13 | # as a common static file.
14 | STATIC_EXTENSIONS = [
15 | # Base web stuff
16 | '.css', '.js', '.html',
17 | # Image formats
18 | '.png', '.jpeg', '.svg', '.ico',
19 | # webfonts
20 | '.ttf', '.woff', '.woff2'
21 | ]
22 |
23 | # File to serve when / is hit
24 | INDEX_FILE = 'index.pyhp'
25 |
26 | env = Environment(
27 | loader=FileSystemLoader(BASE_DIR),
28 | extensions=[PythonExtension]
29 | )
30 |
31 | application = Flask(__name__)
32 |
33 | # FIXME: Should this be unconditionally trusted?!
34 | # Trust X-Forwarded-Prefix, so this can run behind jupyter-server-proxy
35 | # This lets you use request.url_root in your pyhp files
36 | application.wsgi_app = ProxyFix(application.wsgi_app, x_prefix=1)
37 |
38 | logger = application.logger
39 | logger.setLevel(logging.INFO)
40 |
41 |
42 | @application.route('/')
43 | @application.route('/', defaults={'path': INDEX_FILE})
44 | def render(path):
45 | full_path = os.path.realpath(path)
46 | # Guard against directory traversal
47 | if os.path.commonpath([BASE_DIR, full_path]) != BASE_DIR:
48 | return make_response(('Access denied', 403))
49 | _, ext = os.path.splitext(path)
50 | if ext != '.pyhp':
51 | if ext in STATIC_EXTENSIONS:
52 | # FIXME: send_from_directory doesn't send same path as os.path.join(BASE_DIR, path)
53 | # send_from_directory is more secure & protects against path traversal attacks
54 | logger.info("Serving {path} from {BASE_DIR} as a static file")
55 | return send_from_directory(BASE_DIR, path)
56 | else:
57 | return make_response(('Access denied', 403))
58 | try:
59 | template = env.get_template(path)
60 | except TemplateNotFound:
61 | return make_response((f"No such file {path} found", 404))
62 |
63 | return template.render()
64 |
65 | if __name__ == '__main__':
66 | application.run(debug=True)
67 |
--------------------------------------------------------------------------------
/pyhp/extension.py:
--------------------------------------------------------------------------------
1 | from jinja2.ext import Extension
2 | from textwrap import dedent
3 | from io import StringIO
4 | import os
5 | import sys
6 | import re
7 | import ctypes
8 | from jinja2 import nodes, Environment
9 | import contextlib
10 |
11 | #
12 | # From https://stackoverflow.com/a/55545295
13 | class PythonExtension(Extension):
14 | # a set of names that trigger the extension.
15 | tags = {'py'}
16 |
17 | def __init__(self, environment: Environment):
18 | super().__init__(environment)
19 |
20 | def parse(self, parser):
21 | """
22 | Parse {% py %} blocks in templates.
23 |
24 | Inserts an appropriate CallBlock into the parse tree where a {% py %}
25 | block is found, so it can be executed when the template is rendered.
26 |
27 | No actual code execution happens here.
28 | """
29 | lineno = next(parser.stream).lineno
30 | # Get contents until an {% endpy %} declaration
31 | # drop_needle drops the {% endpy %} at the end
32 | body = parser.parse_statements(['name:endpy'], drop_needle=True)
33 |
34 | # Insert a CallBlock that'll call our `_exec_python` method with
35 | # the body of {% py %} when rendering
36 | return nodes.CallBlock(self.call_method('_exec_python',
37 | [nodes.ContextReference(), nodes.Const(lineno), nodes.Const(parser.filename)]),
38 | [], [], body).set_lineno(lineno)
39 |
40 | def _exec_python(self, ctx, lineno, filename, caller):
41 | """
42 | Execute python code from inside a parsed {% py %} block.
43 |
44 | Anything printed to stdout from the code in the block will be substituted
45 | in the template output. Locals & imports persist between different {% py %}
46 | blocks in the same template.
47 | """
48 |
49 | # Remove excess indentation
50 | code = dedent(caller())
51 |
52 | # Compile the code in this block so it can be executed. We prepend
53 | # enough newlines that when the code is compiled, the line numbers of
54 | # the code *just* in this compiled block match the line numbers of the
55 | # code in the template itself. This provides us with useful error
56 | # messages.
57 | compiled_code = compile("\n" * (lineno - 1) + code, filename, "exec")
58 |
59 | # when a pyhp file is executed, the cwd should be set to the directory the
60 | # file is in. Similarly, when it tries to import python files, we should
61 | # look for it in the directory the pyhp file is in, *not* the directory
62 | # where the server started. So we save the current values and restore them
63 | # when done.
64 | # FIXME: If the {% py %} block calls os.chdir, that will not persist across
65 | # other blocks in the file. Same for `sys.path` manipulation
66 | cur_cwd = os.getcwd()
67 | cur_sys_path = sys.path
68 |
69 | # Capture stdout from this code block
70 | sout = StringIO()
71 | try:
72 | # FIXME: Make sure this isn't allowing for path traversal attacks?
73 | file_dir = os.path.dirname(os.path.abspath(filename))
74 | os.chdir(file_dir)
75 | new_sys_path = sys.path.copy()
76 | # The first entry in sys.path will always (haha) be the current directory.
77 | # By setting it to the directory of our pyhp file, our pyhp file can import
78 | # other .py files there!
79 | new_sys_path[0] = file_dir
80 | sys.path = new_sys_path
81 | with contextlib.redirect_stdout(sout):
82 | # Execute the code, with globals & locals from our jinja2 context
83 | exec(compiled_code, ctx.parent, ctx.vars)
84 | finally:
85 | sys.path = cur_sys_path
86 | os.chdir(cur_cwd)
87 |
88 |
89 | # WARNING: Everything from below here is Yuvi's guess of what's actually
90 | # happening.
91 |
92 | # jinja2 generates python code for each template, and executes it.
93 | # This generated python code calls this method to execute our
94 | # {% py %} block. Once the code block executes, the following must
95 | # be true:
96 | #
97 | # 1. Any new top-level locals defined in our code block must be available
98 | # for any new jinja2 blocks
99 | # 2. Any pre-existing top level locals modified in our code block must have
100 | # their new values reflected in any further jinja2 blocks
101 | #
102 | # This is pretty messy!
103 | #
104 | # We will:
105 | #
106 | # 1. Peer into this generated code that is calling us, by looking
107 | # two frames in the call stack below our current frame. This is
108 | # probably very brittle, but it works for now.
109 | # 2. Find local variables declared there, by making use of the fact that
110 | # local variables are defined in the jinja2 generated code of the
111 | # form `l_\d+_`. Again, very brittle.
112 | # 3. If our block overrides any of those top level locals, we explicitly
113 | # *modify this generated code frame* so their values point to our
114 | # new values. This is pretty nuts.
115 |
116 | # Get list of all local variable names defined in the top level in
117 | # our code.
118 | code_names = set(compiled_code.co_names)
119 |
120 | #
121 | generated_code_frame = sys._getframe(2)
122 |
123 | for local_var_name in generated_code_frame.f_locals:
124 | # Look for variables that are jinja2 generated top-level locals
125 | match = re.match(r"l_(\d+)_(?P.+)", local_var_name)
126 |
127 | if match:
128 | var_name = match.group('var_name')
129 |
130 | # If the variable name appears in our code block, and is also a top-level local,
131 | # we update it to match its new value
132 | if (var_name in code_names) and (var_name in ctx.vars):
133 | # Copy the value to the frame's locals.
134 | generated_code_frame.f_locals[local_var_name] = ctx.vars[var_name]
135 | # Do some ctypes vodo to make sure the frame locals are actually updated.
136 | ctx.exported_vars.add(var_name)
137 | # https://pydev.blogspot.com/2014/02/changing-locals-of-frame-frameflocals.html
138 | ctypes.pythonapi.PyFrame_LocalsToFast(
139 | ctypes.py_object(generated_code_frame),
140 | ctypes.c_int(1))
141 |
142 | # Return the captured text.
143 | return sout.getvalue()
144 |
--------------------------------------------------------------------------------
/setup.py:
--------------------------------------------------------------------------------
1 | from setuptools import find_packages, setup
2 |
3 | # Imports __version__, reference: https://stackoverflow.com/a/24517154/2220152
4 |
5 | setup(
6 | name='pyhp-hypertext-preprocessor',
7 | version='0.1',
8 | url='https://github.com/yuvipanda/pyhp',
9 | license='3-clause BSD',
10 | author='YuviPanda',
11 | author_email='yuvipanda@gmail.com',
12 | description='Python Hyptertext Preprocessor - like PHP, but for Python',
13 | long_description=open('README.md').read(),
14 | long_description_content_type='text/markdown',
15 | packages=find_packages(),
16 | include_package_data=True,
17 | platforms='any',
18 | install_requires=['jinja2', 'flask'],
19 | zip_safe=False,
20 | entry_points={
21 | "console_scripts": [
22 | "pyhp = pyhp.__main__:main",
23 | ],
24 | },
25 | )
26 |
--------------------------------------------------------------------------------
/test.html:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/yuvipanda/pyhp/dab2e6991265bb897bf4e96a38ccfa616a2fac56/test.html
--------------------------------------------------------------------------------