From 176fc62a249b3e8815de508d15464fb5b5aa9792 Mon Sep 17 00:00:00 2001 From: katamaz Date: Sun, 24 Aug 2025 22:09:37 +0300 Subject: [PATCH] first commit --- .gitignore | 2 + main.py | 235 ++++++++++++++++++++++++++++++++++++ readme.md | 42 +++++++ www/_server_config.py | 3 + www/docs/basics.pyp | 60 +++++++++ www/docs/components/code.py | 166 +++++++++++++++++++++++++ www/docs/components/docs.py | 2 + www/docs/index.pyp | 33 +++++ www/docs/pages.py | 4 + www/docs/queries.pyp | 77 ++++++++++++ www/import_test.py | 2 + www/import_test.pyp | 16 +++ www/index.pyp | 31 +++++ www/redirection_test.pyp | 0 14 files changed, 673 insertions(+) create mode 100644 .gitignore create mode 100644 main.py create mode 100644 readme.md create mode 100644 www/_server_config.py create mode 100644 www/docs/basics.pyp create mode 100644 www/docs/components/code.py create mode 100644 www/docs/components/docs.py create mode 100644 www/docs/index.pyp create mode 100644 www/docs/pages.py create mode 100644 www/docs/queries.pyp create mode 100644 www/import_test.py create mode 100644 www/import_test.pyp create mode 100644 www/index.pyp create mode 100644 www/redirection_test.pyp diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..46f0d58 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +__pycache__ +aval.txt diff --git a/main.py b/main.py new file mode 100644 index 0000000..e735336 --- /dev/null +++ b/main.py @@ -0,0 +1,235 @@ +import socket +import threading +import os +import mimetypes +from urllib.parse import unquote +from html import escape +import io +import sys +from contextlib import redirect_stdout +import re +import traceback +import linecache + + +class pyp_Functions: + @staticmethod + def e(text: str | int | float, ignoreType=False): + if isinstance(text, (str, int, float)): + print(text) + elif ignoreType: + print(escape(str(text))) + else: + raise ValueError( + f"Unsupported type for echo function,(req str or number, got {str(type(text))}) use ignoreType=True to force str conversion") + + @staticmethod + def f_r(path: str): + with open(path, "r", encoding="utf-8", errors="ignore") as f: + return f.read() + + +class pyp_IO: + def __init__(self, path, query) -> None: + self.path = path + self.query = query + + self.server_pipeline: dict[str, bool | + str | None | int] = {'Redirect': False} + + self.GET = {} + if query: + pairs = query.split('&') + for pair in pairs: + if '=' in pair: + key, value = pair.split('=', 1) + self.GET[unquote(key)] = unquote(value) + else: + self.GET[unquote(pair)] = '' + + def redirect(self, url: str): + self.server_pipeline['Redirect'] = url + # TODO: Implement redirect logic in server + + +class SandboxInstance: + def __init__(self, initial_globals=None): + # Базовые globals, плюс твои доп, если кинут + self.globals_dict = {} + if initial_globals: + self.globals_dict.update(initial_globals) + + def run(self, script_str, path, additional_globals=None): + original_dir = os.getcwd() + original_sys_path = sys.path.copy() + if additional_globals: + self.globals_dict.update(additional_globals) + output_buffer = io.StringIO() + fake_filename = "" + original_dont_write = sys.dont_write_bytecode + sys.dont_write_bytecode = True + linecache.cache[fake_filename] = ( + len(script_str), None, script_str.splitlines(True), fake_filename) + with redirect_stdout(output_buffer): + try: + os.chdir(os.path.dirname(path)) # Сброс текущей директории + # Добавление директории скрипта в sys.path + sys.path.insert(0, os.path.dirname(path)) + exec(compile(script_str, fake_filename, 'exec'), self.globals_dict) + except Exception as e: + tb_lines = traceback.format_exception( + type(e), e, e.__traceback__) + tb = [ + line for line in tb_lines if "exec" not in line or fake_filename in line] + return f"
error while running script: {escape(str(e))} 💥
{escape(''.join(tb)).replace('\n', '
').replace(' ', ' ').replace('\t', ' '*4)}


" + finally: + os.chdir(original_dir) + sys.path = original_sys_path + sys.dont_write_bytecode = original_dont_write + linecache.cache.pop(fake_filename, None) + return output_buffer.getvalue().strip() + + +def normalize_indentation(s): + if not s: + return '' + lines = s.splitlines() + n = 0 + first_non_empty = next((line for line in lines if line.lstrip(' ')), None) + if first_non_empty: + n = len(first_non_empty) - len(first_non_empty.lstrip(' ')) + result = [] + for i, line in enumerate(lines): + if line.lstrip(' ') and len(line) - len(line.lstrip(' ')) < n: + # raise in sandbox + return f"raise ValueError(\"Indentation error at line {i}\")" + result.append(line[n:] if line.lstrip(' ') else '') + return '\n'.join(result) + + +class PyWPRServer: + def __init__(self, host='0.0.0.0', port=8000, doc_root='www'): + self.host, self.port = host, port + self.doc_root = os.path.abspath(doc_root) + os.makedirs(self.doc_root, exist_ok=True) + self.sock = None + self._stop = False + + def _response(self, status_line, headers=None, body=b''): + headers = headers or {} + if isinstance(body, str): + body = body.encode('utf-8') + headers.setdefault('Content-Type', 'text/html; charset=utf-8') + headers.setdefault('Content-Length', str(len(body))) + hdrs = '\r\n'.join(f'{k}: {v}' for k, v in headers.items()) + return (f'{status_line}\r\n{hdrs}\r\n\r\n').encode('utf-8') + body + + def _not_found(self): + return self._response('HTTP/1.1 404 Not Found', body=b'

404 Not Found

') + + def _execute_pyp(self, body: str, path: str, query: str): + script_path = self.doc_root + path + sandbox = SandboxInstance( + {'e': pyp_Functions.e, 'f_r': pyp_Functions.f_r, "__file__": script_path, "__name__": "__pyp__", "pyp": pyp_IO(path, query)}) + pattern = re.compile(r'(.*?)', re.DOTALL) + snippets = pattern.findall(body) + for snippet in snippets: + snippet_code = normalize_indentation(snippet) + result = sandbox.run(snippet_code, path=script_path) + body = body.replace(f'{snippet}', result) + eq_pattern = re.compile(r'(.*?)', re.DOTALL) + eq_snippets = eq_pattern.findall(body) + for eq_snippet in eq_snippets: + result = sandbox.run(f'e({eq_snippet})', path=script_path) + body = body.replace(f'{eq_snippet}', result) + return body + + def _serve_path(self, req_path): + # print("Request:", req_path) + path = unquote(req_path.split('?', 1)[0]) + if path.endswith('/'): + path += 'index.pyp' + elif os.path.isdir(self.doc_root + path): + path += '/index.pyp' + + full = os.path.normpath(os.path.join(self.doc_root, path.lstrip('/'))) + if not full.startswith(self.doc_root) or not os.path.isfile(full): + return self._not_found() + ctype = (mimetypes.guess_type(full)[ + 0] or 'application/octet-stream') if not path.endswith(".pyp") else 'text/html' + # if text/* or html, add charset=utf-8 + if ctype.startswith('text/') or ctype == 'application/json' or ctype == 'application/javascript': + ctype = f'{ctype}; charset=utf-8' + try: + # Open text files as UTF-8 when possible, binary otherwise + if ctype.startswith('text/') or ctype.endswith('charset=utf-8'): + with open(full, 'r', encoding='utf-8', errors='replace') as f: + body = f.read() + if path.endswith('.pyp'): + query = req_path.split( + '?', 1)[1] if '?' in req_path else '' + body = self._execute_pyp(body, path, query) + + return self._response('HTTP/1.1 200 OK', headers={'Content-Type': ctype, 'Server': 'PyWRP-server'}, body=body.encode()) + else: + with open(full, 'rb') as f: + body = f.read() + return self._response('HTTP/1.1 200 OK', headers={'Content-Type': ctype}, body=body) + except Exception: + return self._response('HTTP/1.1 500 Internal Server Error', body=b'

500

') + + def _handle(self, client, addr): + try: + data = client.recv(4096).decode('utf-8', errors='replace') + if not data: + return + first = data.splitlines()[0] + parts = first.split() + if len(parts) < 2: + client.sendall(self._response( + 'HTTP/1.1 400 Bad Request', body=b'

400

')) + return + method, path = parts[0], parts[1] + if method != 'GET': + client.sendall(self._response( + 'HTTP/1.1 405 Method Not Allowed', headers={'Allow': 'GET'}, body=b'

405

')) + return + resp = self._serve_path(path) + client.sendall(resp) + finally: + client.close() + + def start(self): + self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + self.sock.bind((self.host, self.port)) + self.sock.listen(5) + try: + while not self._stop: + client, addr = self.sock.accept() + threading.Thread(target=self._handle, args=( + client, addr), daemon=True).start() + finally: + self.sock.close() + + def stop(self): + self._stop = True + try: + with socket.create_connection((self.host, self.port), timeout=1): + pass + except Exception: + pass + + +if __name__ == '__main__': + s = PyWPRServer(host='127.0.0.1', port=8000, doc_root='www') + print('Serving', s.doc_root, 'on', f'{s.host}:{s.port}') + try: + s.start() + except KeyboardInterrupt: + s.stop() + print('Stopped.') diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..bd1edb5 --- /dev/null +++ b/readme.md @@ -0,0 +1,42 @@ +# PyWPR - Python Web Page Render +`.pyp` - **Py**thon **P**age *btw* +## Get Started +1. remove **www/**\* +2. run main.py + +## Syntax +### integration +```html + + # Here is Python Code + +``` +```html +"Text to print" + +foo() + ``` + +### queries +```python +# *.pyp?query +pyp.GET['query'] +#or +pyp.GET.get('query','default_value') +``` +### echos +```python +e(str|int|float) # you can add ignoreType=True +# or +print(object) +``` + +___ +___ + +## _TODO_ ++ ~~todo~~ ++ indetation fix for multile `` ++ redirections ++ cookies ++ files upload \ No newline at end of file diff --git a/www/_server_config.py b/www/_server_config.py new file mode 100644 index 0000000..7e3f69f --- /dev/null +++ b/www/_server_config.py @@ -0,0 +1,3 @@ +SERVER_DOWNLOADPY = False +SERVER_PORT = 8080 +SERVER_HOST = '0.0.0.0' \ No newline at end of file diff --git a/www/docs/basics.pyp b/www/docs/basics.pyp new file mode 100644 index 0000000..198982b --- /dev/null +++ b/www/docs/basics.pyp @@ -0,0 +1,60 @@ + +import components.code as code +import components.docs as docs + + + + + + + pyp | queries + + + + +
+

+ basics +

+
+
+ +

<py>! tag

+

in pywrp we have <py!> and </py!> HTML tags for embedding python server code, +
(almost like in php)
+ for example:

+ + code.code_comp(f"""

this is html element

+{'<'}py!> # This is Python code""",'html') + +
+
+

echo function

+

+ e() is a function that prints text to the page +
(just like echo in php) +

+ code.code_comp(f"""{'<'}py!> +e("hello word") +# also you can use just regular python print +print(object) +# e() supports only str|int|float, but you can ignoreType=True +e({'{'}'test':123{'}'},ignoreType=True) +""") +
+

<p?> tag

+

also there is <p?></p?> tags, they are shortcuts to echo +
(like in php too)
+ for example:

+ + code.code_comp(f"""{'<'}p?>"hello world"{'<'}/p?> + +{'<'}py!>e("hello world"){' + +{'<'}p?>foo(){'""",'html') + +
+ + \ No newline at end of file diff --git a/www/docs/components/code.py b/www/docs/components/code.py new file mode 100644 index 0000000..89ac396 --- /dev/null +++ b/www/docs/components/code.py @@ -0,0 +1,166 @@ +import io +import html +import token +import tokenize +import keyword +import builtins +import re +import sys + +PALETTE = { + 'kw': 'text-pink-400', 'builtin': 'text-violet-300', 'name': 'text-sky-300', 'func': 'text-sky-300', + 'attr': 'text-green-300', 'str': 'text-amber-300', 'num': 'text-cyan-300', 'op': 'text-red-400', + 'punct': 'text-red-400', 'cmt': 'text-gray-500 italic', 'err': 'bg-red-900 text-red-300', + 'ht_tag': 'text-fuchsia-300', 'ht_attr': 'text-green-300', 'ht_eq': 'text-red-400', + 'ht_str': 'text-amber-300', 'ht_comment': 'text-gray-500 italic', 'ht_text': 'text-slate-300' +} + +_builtin_names = set(dir(builtins)) + + +def esc(s): + return html.escape(s).replace(' ', ' ').replace('\t', ' '*4) + + +def py_highlight(src): + out = [] + prev = None + gen = tokenize.generate_tokens(io.StringIO(src).readline) + for ttype, val, *_ in gen: + cls = '' + if ttype == token.NAME: + if keyword.iskeyword(val): + cls = 'kw' + elif prev and prev[0] == token.OP and prev[1] == '.': + cls = 'attr' + elif val in _builtin_names: + cls = 'builtin' + else: + cls = 'name' + elif ttype == token.OP: + cls = 'op' if re.match(r'[+\-*/%=<>!^|&~]', val) else 'punct' + elif ttype == token.STRING: + cls = 'str' + elif ttype == token.NUMBER: + cls = 'num' + elif ttype == token.COMMENT: + cls = 'cmt' + elif ttype == token.ERRORTOKEN: + cls = 'err' + prev = (ttype, val) + piece = esc(val).replace('\n', '\n') # keep newlines + out.append( + f'{piece}' if cls else piece) + return ''.join(out) + + +# HTML regexes +FULL_TAG = r'<\s*/?\s*[A-Za-z0-9:-]+[!?]?(?:\s[^<>]*?)?>' +HT_RE = re.compile(r'(?s)()|(' + FULL_TAG + r')|([^<]+)') +TAG_NAME_RE = re.compile(r'(?s)(<\s*/?\s*)([A-Za-z0-9:-]+[!?]?)(.*?)(>)') +ATTR_RE = re.compile(r'([A-Za-z0-9:-]+)(\s*=\s*)?', re.S) +QSTR_RE = re.compile(r'(".*?"|\'.*?\')', re.S) +PY_OPEN = re.compile(r'(?i)^<\s*([A-Za-z0-9:-]+[!?]?)') # capture tag name + +PY_NAMES = {'py!', 'p?'} + + +def process_fulltag(fulltag): + m = TAG_NAME_RE.match(fulltag) + if not m: + return f'{esc(fulltag)}' + pre, name, rest, gt = m.groups() + out = [esc(pre), f'{esc(name)}'] + if rest: + i = 0 + while i < len(rest): + ma = ATTR_RE.match(rest, i) + if ma: + an, eq = ma.groups() + out.append( + f'{esc(an)}') + if eq: + out.append(esc(eq)) + i = ma.end() + continue + mq = QSTR_RE.match(rest, i) + if mq: + q = mq.group(1) + out.append( + f'{esc(q)}') + i = mq.end() + continue + out.append(esc(rest[i])) + i += 1 + out.append(esc(gt)) + return ''.join(out) + + +def _html_tokens(src): + out = [] + i = 0 + L = len(src) + # iterate through matches but manage consumption manually to support multi-line py containers + for m in HT_RE.finditer(src): + if m.start() < i: + continue + com, fulltag, text = m.groups() + if com: + out.append( + f'{esc(com)}') + i = m.end() + continue + if fulltag: + # detect tag name + mt = TAG_NAME_RE.match(fulltag) + name = mt.group(2) if mt else '' + lname = name.lower() + is_open = not re.match(r'<\s*/', fulltag) + # if opening py-container, find the matching closing tag (first occurrence) + if lname in PY_NAMES and is_open: + out.append(process_fulltag(fulltag)) # opening + # closing tag pattern, case-insensitive + close_re = re.compile(rf'(?i)') + mclose = close_re.search(src, m.end()) + if mclose: + inner = src[m.end():mclose.start()] + out.append(py_highlight(inner)) + out.append(process_fulltag(mclose.group(0))) # closing + i = mclose.end() + # continue scanning after closing + continue + else: + # no closing found: just output opening and continue + i = m.end() + continue + else: + out.append(process_fulltag(fulltag)) + i = m.end() + continue + if text: + out.append( + f'{esc(text)}') + i = m.end() + return ''.join(out) + + +def highlight(src, lang='py'): + return _html_tokens(src) if lang and lang.lower().startswith('h') else py_highlight(src) + + +def code_comp(code, lang='py'): + code = code.split('\n') + lines = '\n'.join( + [f'{highlight(code_line, lang)}' for line, code_line in enumerate(code, 1)]) + return f''' +
+
+ + + +
+
+ {lines} +
+
+ ''' diff --git a/www/docs/components/docs.py b/www/docs/components/docs.py new file mode 100644 index 0000000..2a6575b --- /dev/null +++ b/www/docs/components/docs.py @@ -0,0 +1,2 @@ +def header_comp(url): + pass \ No newline at end of file diff --git a/www/docs/index.pyp b/www/docs/index.pyp new file mode 100644 index 0000000..a1306e1 --- /dev/null +++ b/www/docs/index.pyp @@ -0,0 +1,33 @@ + + + + + + + pywpr docs + + + + +
+
+

+ PyWPR docs +

+ +
+
+ + + +
+ + from pages import pages + for i, (name, url) in enumerate(pages): + e(f'{i+1}. {name}') + +
+ + \ No newline at end of file diff --git a/www/docs/pages.py b/www/docs/pages.py new file mode 100644 index 0000000..6a85831 --- /dev/null +++ b/www/docs/pages.py @@ -0,0 +1,4 @@ +pages = [ + ['basics', './basics.pyp'], + ['pyp | queries', './queries.pyp'], +] \ No newline at end of file diff --git a/www/docs/queries.pyp b/www/docs/queries.pyp new file mode 100644 index 0000000..11f92e0 --- /dev/null +++ b/www/docs/queries.pyp @@ -0,0 +1,77 @@ + +import components.code as code +import components.docs as docs + + + + + + + pyp | queries + + + + +
+

+ pyp | queries +

+

in pywrp we use pyp.GET object for ?get=queries
+ you can access query parameters like dictionary keys
+ for example:

+ + code.code_comp("""pyp.GET['name'] +# or +pyp.GET.get('name', 'default_value')""") + +

try it out:

+
+
+ + + +
+ <form method="get"> +
+
+ + $
+ +
+ </form> + + if 'name' in pyp.GET and pyp.GET['name'].strip() != "": + name = pyp.GET['name'] + money = pyp.GET.get('money', "") + if money != "" and not money.isdigit(): + money = "" + moneyText = f"you have a {'big' if int(money) >= 100 else "little"} money:  {money}$!" if money != "" else "you didn't tell me about your money" + e(f"<!py>
hello, {name}!  {moneyText}
</py!>") +
+
+ +

source

+ + code.code_comp(f"""
+ + + +
+{'<'}py!> +if 'name' in pyp.GET and pyp.GET['name'].strip() != "": + name = pyp.GET['name'] + money = pyp.GET.get('money', '') + if money != "" and not money.isdigit(): + money = "" + moneyText = f"you have a {'{'}'big' if int(money) >= 100 + else "little"{'}'} money: {'{'}money{'}'}$!" \\ + if money != "" else "you didn't tell me about your money" + e(f"
hello, {'{'}name{'}'}! {'{'}moneyText{'}'}
") +""",'html') +
+
+ + + \ No newline at end of file diff --git a/www/import_test.py b/www/import_test.py new file mode 100644 index 0000000..5d4d6d2 --- /dev/null +++ b/www/import_test.py @@ -0,0 +1,2 @@ +def hello(): + return "Hello, world!this was imported from another python script" \ No newline at end of file diff --git a/www/import_test.pyp b/www/import_test.pyp new file mode 100644 index 0000000..8bd1217 --- /dev/null +++ b/www/import_test.pyp @@ -0,0 +1,16 @@ + +from import_test import hello + + + + + + + pyd test + + + +
+ hello()
+ + \ No newline at end of file diff --git a/www/index.pyp b/www/index.pyp new file mode 100644 index 0000000..7a54b6b --- /dev/null +++ b/www/index.pyp @@ -0,0 +1,31 @@ + +import os +a = 0 +FILE = 'aval.txt' +if os.path.exists(FILE): + try: + with open(FILE, "r") as f: + a = int(f.read()) + except Exception: + os.remove(FILE) +with open(FILE, "w") as f: + f.write(str(a + 1)) + + + + + + + + pyd test + + + + +

welcome to PyWPR

+
a php-like framework
get stated by removing www contents and creating index.pyp +
or go to docs
+
+ Renders a+1
+ + \ No newline at end of file diff --git a/www/redirection_test.pyp b/www/redirection_test.pyp new file mode 100644 index 0000000..e69de29