├── docs ├── README.md ├── htag.png ├── js_bidirectionnal.md ├── tag_update.md ├── query_params.md ├── htag.svg ├── index.md ├── concepts.md ├── hrenderer.md └── runners.md ├── old_runners ├── README.md ├── browserstarlettehttp.py ├── browsertornadohttp.py ├── browserstarlettews.py ├── browserhttp.py └── winapp.py ├── examples ├── 7guis │ ├── README.md │ ├── gui1.py │ ├── gui3.py │ ├── gui2.py │ ├── gui4.py │ └── gui5.py ├── hello_world.py ├── autoreload.py ├── pyscript.html ├── README.md ├── matplot.py ├── pyscript_htagui.html ├── leaflet.py ├── new_timer.py ├── pyscript_matplotlib.html ├── camshot.py ├── calc.py ├── pyscript_with_hashchange.html ├── navigate_with_hashchange.py ├── ace_editor.py ├── stream.py ├── todomvc.py ├── demo.py ├── pyscript_htbulma.html ├── app.py └── htag_with_state_manager.py ├── htag ├── ui.py ├── __init__.py ├── runners │ ├── pywebview.py │ ├── pyscript.py │ ├── commons │ │ └── __init__.py │ └── chromeappmode.py ├── attrs.py └── __main__.py ├── .vscode ├── settings.json └── tasks.json ├── .github └── workflows │ ├── on_commit_do_deploy_mkdocs.yml │ ├── test_platforms.yml │ ├── on_commit_do_all_unittests.yml │ ├── update_version.py │ ├── selenium.yaml │ └── on_tag_do_release_and_deploy_pypi.yml ├── manual_tests_base.py ├── selenium ├── run.py ├── app1.py ├── app3.py ├── app2.py ├── tests.py ├── app4.py └── hclient.py ├── test_session_persitent.py ├── mkdocs.yml ├── manual_tests_new_InternalCall.py ├── manual_tests_expose.py ├── LICENSE ├── test_update.py ├── manual_tests_persitent.py ├── pyproject.toml ├── manual_tests_remove.py ├── test_ui.py ├── manual_tests_htbulma.py ├── .gitignore ├── test_init_render.py ├── test_new_events.py ├── brython ├── example1.html ├── example2.html └── README.md ├── manual_tests_events.py ├── test_constructors.py ├── manual_tests_qp.py ├── test_states_guesser.py ├── test_pyscript_runner.py ├── test_placeholder.py ├── test_chromeappmode_runner.py ├── test_statics.py ├── _pyscript_dev.html └── test_dom.py /docs/README.md: -------------------------------------------------------------------------------- 1 | See docs here : 2 | https://manatlan.github.io/htag/ 3 | -------------------------------------------------------------------------------- /docs/htag.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/manatlan/htag/HEAD/docs/htag.png -------------------------------------------------------------------------------- /old_runners/README.md: -------------------------------------------------------------------------------- 1 | Theses are the good old runners (htag < 0.90.0). 2 | 3 | And are here, just for the posterity (there will not be maintained anymore) 4 | 5 | There were in "htag/runners" -------------------------------------------------------------------------------- /examples/7guis/README.md: -------------------------------------------------------------------------------- 1 | This is attempts to do the https://eugenkiss.github.io/7guis/ with HTAG. 2 | 3 | There are 7 tasks : 4 | - 1) counter : done 5 | - 2) Temp Converter : done 6 | - 3) Flight Booker : done 7 | - 4) Timer : **IN PROGRESS** 8 | - 5) CRUD : done 9 | - 6) Circle Drawer : **TODO** 10 | - 7) Cells : **TODO** 11 | 12 | It's not complete yet. 13 | -------------------------------------------------------------------------------- /htag/ui.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # # ############################################################################# 3 | # Copyright (C) 2024 manatlan manatlan[at]gmail(dot)com 4 | # 5 | # MIT licence 6 | # 7 | # https://github.com/manatlan/htag 8 | # ############################################################################# 9 | try: 10 | from htagui.basics import * # htagui>=0.3 11 | except ImportError: 12 | from htagui import * # htagui<0.3 13 | -------------------------------------------------------------------------------- /examples/hello_world.py: -------------------------------------------------------------------------------- 1 | import os,sys; sys.path.insert(0,os.path.dirname(os.path.dirname(__file__))) 2 | 3 | # the simplest htag'app, in the best env to start development (hot reload/refresh) 4 | from htag import Tag 5 | 6 | class App(Tag.body): 7 | def init(self): 8 | self += "Hello World" 9 | 10 | from htag.runners import DevApp as Runner # need starlette+uvicorn !!! 11 | #from htag.runners import BrowserHTTP as Runner 12 | app=Runner(App) 13 | if __name__=="__main__": 14 | app.run() 15 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.testing.pytestArgs": [ 3 | ".","-s","-v" 4 | ], 5 | "python.testing.unittestEnabled": false, 6 | "python.testing.pytestEnabled": true, 7 | "editor.wordWrap": "off", 8 | "python.linting.pylintEnabled": true, 9 | "python.linting.enabled": true, 10 | 11 | "python.analysis.diagnosticSeverityOverrides": { 12 | "reportUnusedExpression": "none", 13 | }, 14 | "IDX.aI.enableInlineCompletion": true, 15 | "IDX.aI.enableCodebaseIndexing": true, 16 | } -------------------------------------------------------------------------------- /htag/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # ############################################################################# 3 | # Copyright (C) 2022 manatlan manatlan[at]gmail(dot)com 4 | # 5 | # MIT licence 6 | # 7 | # https://github.com/manatlan/htag 8 | # ############################################################################# 9 | 10 | __version__ = "0.0.0" # auto-updated 11 | 12 | from .tag import Tag,HTagException,expose 13 | from .runners import Runner 14 | 15 | __all__= ["Tag","HTagException","expose","Runner"] 16 | 17 | -------------------------------------------------------------------------------- /.github/workflows/on_commit_do_deploy_mkdocs.yml: -------------------------------------------------------------------------------- 1 | name: Publish mkdocs pages 2 | on: 3 | push: 4 | branches: 5 | - main 6 | 7 | jobs: 8 | build: 9 | name: Deploy docs 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout main 13 | uses: actions/checkout@v4 14 | 15 | - name: Deploy docs 16 | uses: mhausenblas/mkdocs-deploy-gh-pages@master 17 | env: 18 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 19 | CONFIG_FILE: mkdocs.yml 20 | EXTRA_PACKAGES: build-base 21 | -------------------------------------------------------------------------------- /manual_tests_base.py: -------------------------------------------------------------------------------- 1 | #!./venv/bin/python3 2 | from htag import Tag,Runner 3 | 4 | class App(Tag.body): 5 | def init(self): 6 | self.ph=Tag.div() #placeholder 7 | 8 | self <= Tag.button("nimp", _onclick=self.print) 9 | self <= Tag.input( _onkeyup=self.print) 10 | self <= self.ph 11 | 12 | def print(self,o): 13 | self.ph.clear(str(o.event)) 14 | 15 | 16 | if __name__ == "__main__": 17 | app=Runner( App ) 18 | 19 | import logging 20 | logging.basicConfig(format='[%(levelname)-5s] %(name)s: %(message)s',level=logging.INFO) 21 | logging.getLogger("htag.tag").setLevel( logging.INFO ) 22 | app.run() 23 | -------------------------------------------------------------------------------- /examples/7guis/gui1.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from htag import Tag # the only thing you'll need ;-) 3 | 4 | 5 | class Gui1(Tag.body): 6 | 7 | def init(self): 8 | self.value=0 9 | 10 | def render(self): 11 | self.clear() 12 | self <= Tag.Span( self.value ) 13 | self <= Tag.Button( "+", _onclick=self.bind.inc() ) 14 | 15 | def inc(self): 16 | self.value+=1 17 | 18 | App=Gui1 19 | if __name__=="__main__": 20 | # and execute it in a pywebview instance 21 | from htag.runners import * 22 | PyWebWiew( Gui1 ).run() 23 | 24 | # here is another runner, in a simple browser (thru ajax calls) 25 | # BrowserHTTP( Page ).run() 26 | -------------------------------------------------------------------------------- /selenium/run.py: -------------------------------------------------------------------------------- 1 | ################################################################################################## 2 | ## Run the runner 'sys.argv[1]' with the App 'sys.argv[2]' 3 | ## used by github action : .github/workflows/selenium.yaml 4 | ################################################################################################## 5 | 6 | import sys,importlib 7 | ####################################################### 8 | runner = sys.argv[1] 9 | App=importlib.import_module(sys.argv[2]).App 10 | port=int(sys.argv[3]) if len(sys.argv)>3 else 8000 11 | ####################################################### 12 | import hclient 13 | hclient.run( App, runner, openBrowser=False, port=port) 14 | -------------------------------------------------------------------------------- /test_session_persitent.py: -------------------------------------------------------------------------------- 1 | from htag.runners.commons import SessionFile 2 | import os,pytest 3 | 4 | def test_SessionFile(): 5 | f="aeff.json" 6 | try: 7 | assert not os.path.isfile(f) 8 | 9 | s=SessionFile(f) 10 | s["hello"]=42 11 | s["hello"]+=1 12 | assert s["hello"]==43 13 | 14 | assert len(s)==1 15 | 16 | assert "jo" not in s 17 | 18 | with pytest.raises(Exception): 19 | s["jo"] 20 | 21 | with pytest.raises(Exception): 22 | del s["jo"] 23 | 24 | del s["hello"] 25 | 26 | assert not os.path.isfile(f) 27 | finally: 28 | if os.path.isfile(f): 29 | os.unlink(f) -------------------------------------------------------------------------------- /.github/workflows/test_platforms.yml: -------------------------------------------------------------------------------- 1 | name: test platforms 2 | 3 | on: 4 | workflow_dispatch: 5 | 6 | jobs: 7 | ci: 8 | strategy: 9 | fail-fast: false 10 | matrix: 11 | # python-version: ["3.7", "3.8", "3.9", "3.10", "3.11", "3.12"] 12 | # os: [ubuntu-latest, macos-latest, windows-latest] 13 | python-version: ["3.9"] 14 | os: [windows-latest] 15 | runs-on: ${{ matrix.os }} 16 | steps: 17 | - uses: actions/checkout@v4 18 | - uses: actions/setup-python@v5 19 | with: 20 | python-version: ${{ matrix.python-version }} 21 | - name: run pytest 22 | run: | 23 | python -m pip install uv 24 | uv sync --dev 25 | uv run pytest . 26 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: HTag's Docs 2 | site_description: GUI toolkit for building GUI toolikit (for building applications for desktop, web, and mobile from a single python3 codebase) 3 | 4 | theme: 5 | logo: htag.png 6 | name: 'material' 7 | palette: 8 | primary: 'black' 9 | accent: 'black' 10 | 11 | repo_name: manatlan/htag 12 | repo_url: https://github.com/manatlan/htag 13 | edit_uri: "" 14 | 15 | nav: 16 | - Introduction: 'index.md' 17 | - Tutorial: 'tutorial.md' 18 | - Concepts: concepts.md 19 | - Runners: 'runners.md' 20 | 21 | markdown_extensions: 22 | - markdown.extensions.codehilite: 23 | guess_lang: false 24 | - admonition 25 | - pymdownx.highlight: 26 | anchor_linenums: true 27 | - pymdownx.inlinehilite 28 | - pymdownx.snippets 29 | - pymdownx.superfences -------------------------------------------------------------------------------- /manual_tests_new_InternalCall.py: -------------------------------------------------------------------------------- 1 | #!./venv/bin/python3 2 | from htag import Tag # the only thing you'll need ;-) 3 | 4 | 5 | 6 | class Page(Tag.body): 7 | def init(self): 8 | # self.call( self.bind.doit("heelo") ) 9 | self.call.doit("heelo") 10 | 11 | def doit(self,msg): 12 | self+=msg 13 | 14 | App=Page 15 | # and execute it in a pywebview instance 16 | from htag.runners import * 17 | # PyWebWiew( Page ).run() 18 | 19 | # here is another runner, in a simple browser (thru ajax calls) 20 | # ChromeApp( Page ).run() 21 | # BrowserHTTP( Page ).run() 22 | app=DevApp( Page ) 23 | if __name__ == "__main__": 24 | # BrowserTornadoHTTP( Page ).run() 25 | 26 | import logging 27 | logging.basicConfig(format='[%(levelname)-5s] %(name)s: %(message)s',level=logging.DEBUG) 28 | logging.getLogger("htag.tag").setLevel( logging.INFO ) 29 | 30 | 31 | app.run() 32 | -------------------------------------------------------------------------------- /examples/7guis/gui3.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from htag import Tag # the only thing you'll need ;-) 3 | 4 | 5 | class Gui3(Tag.body): 6 | 7 | def init(self): 8 | self.selected = "One Way" 9 | 10 | def render(self): 11 | options = [Tag.option(i,_selected=(self.selected==i)) for i in ["One Way","Return Flight"]] 12 | 13 | self.clear() 14 | self <= Tag.Select( options, _onchange=self.bind.setSelected(b"this.value") ) 15 | self <= Tag.input(_type="date") 16 | self <= Tag.input(_type="date",_disabled=self.selected=="One Way") 17 | 18 | def setSelected(self,v): 19 | self.selected = v 20 | 21 | 22 | 23 | 24 | App=Gui3 25 | if __name__=="__main__": 26 | # and execute it in a pywebview instance 27 | from htag.runners import * 28 | PyWebWiew( Gui3 ).run() 29 | 30 | # here is another runner, in a simple browser (thru ajax calls) 31 | # BrowserHTTP( Page ).run() 32 | -------------------------------------------------------------------------------- /.github/workflows/on_commit_do_all_unittests.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a variety of Python versions 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions 3 | 4 | name: Run tests 5 | 6 | on: 7 | push: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | build: 13 | 14 | runs-on: ubuntu-latest 15 | strategy: 16 | matrix: 17 | python-version: [ 3.8, 3.9, "3.10","3.11","3.12" ] 18 | 19 | steps: 20 | - uses: actions/checkout@v4 21 | - name: Set up Python ${{ matrix.python-version }} 22 | uses: actions/setup-python@v5 23 | with: 24 | python-version: ${{ matrix.python-version }} 25 | - name: Install dependencies 26 | run: | 27 | python -m pip install uv 28 | uv sync --dev 29 | - name: Test with pytest 30 | run: | 31 | uv run pytest . -s 32 | -------------------------------------------------------------------------------- /.github/workflows/update_version.py: -------------------------------------------------------------------------------- 1 | import sys,re 2 | 3 | 4 | def patch_init(v): 5 | file="htag/__init__.py" 6 | content = re.sub(r'__version__ = [^#]*',f'__version__ = "{v}" ',open(file,'r+').read(),1) 7 | assert v in content 8 | with open(file,'w+') as fid: 9 | fid.write( content ) 10 | return file 11 | 12 | def patch_pyproject(v): 13 | file="pyproject.toml" 14 | content = re.sub(r'version = [^#]*',f'version = "{v}" ',open(file,'r+').read(),1) 15 | assert v in content 16 | with open(file,'w+') as fid: 17 | fid.write( content ) 18 | return file 19 | 20 | if __name__=="__main__": 21 | v=sys.argv[1] 22 | assert v.lower().startswith("v"), "version should start with 'v' (was '%s')" %v 23 | assert v.count(".")==2, "version is not semver (was '%s')" %v 24 | version=v[1:] # remove 'v' 25 | f1=patch_init(version) 26 | f2=patch_pyproject(version) 27 | print(f"Files '{f1}' & '{f2}' updated to version '{version}' !") -------------------------------------------------------------------------------- /examples/7guis/gui2.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from htag import Tag # the only thing you'll need ;-) 3 | 4 | 5 | class Gui2(Tag.body): 6 | 7 | def init(self): 8 | self.valueC=0 9 | self.valueF=0 10 | 11 | def render(self): 12 | self.clear() 13 | self <= Tag.div( Tag.Input( _value=self.valueC, _onchange=self.bind.changeC(b"this.value") ) +"C" ) 14 | self <= Tag.div( Tag.Input( _value=self.valueF, _onchange=self.bind.changeF(b"this.value") ) +"F") 15 | 16 | def changeC(self,v): 17 | self.valueC=float(v) 18 | self.valueF=(self.valueC*9/5) - 32 19 | 20 | def changeF(self,v): 21 | self.valueF=float(v) 22 | self.valueC=(self.valueF-32) *(5/9) 23 | 24 | 25 | 26 | App=Gui2 27 | if __name__=="__main__": 28 | # and execute it in a pywebview instance 29 | from htag.runners import * 30 | PyWebWiew( Gui2 ).run() 31 | 32 | # here is another runner, in a simple browser (thru ajax calls) 33 | # BrowserHTTP( Page ).run() 34 | -------------------------------------------------------------------------------- /examples/autoreload.py: -------------------------------------------------------------------------------- 1 | import os,sys; sys.path.insert(0,os.path.dirname(os.path.dirname(__file__))) 2 | 3 | from htag import Tag 4 | 5 | """ 6 | AUTORELOAD is possible with this 2 runners : BrowserStarletteHTTP & BrowserStarletteWS 7 | (could be very handy in development phase) 8 | 9 | See this example, on how to instanciate the runner and use uvicorn/autoreload 10 | """ 11 | 12 | class Demo(Tag.div): 13 | statics=[ Tag.style("""body {background:#EEE}""") ] 14 | 15 | def init(self): 16 | for c in range(2000000,16581375,10000): 17 | self <= Tag.button("Hi",_style=f"background:#{hex(c)[2:]}") 18 | 19 | ############################################################################################# 20 | App=Demo 21 | from htag.runners import * 22 | 23 | app = BrowserStarletteHTTP( Demo ) 24 | # app = BrowserStarletteWS( Demo ) 25 | 26 | if __name__=="__main__": 27 | import uvicorn 28 | uvicorn.run("autoreload:app",host="127.0.0.1",port=8000,reload=True) 29 | 30 | ## or the classic : 31 | #app.run() -------------------------------------------------------------------------------- /manual_tests_expose.py: -------------------------------------------------------------------------------- 1 | #!./venv/bin/python3 2 | from htag import Tag,expose 3 | Tag.STRICT_MODE=True 4 | 5 | class App(Tag.div): 6 | def init(self): 7 | # the old bind (for js interact) 8 | self+=Tag.button("test1",_onclick=self.bind.python(3,"[1]")) 9 | 10 | # the new bind (for js interact) 11 | self+=Tag.button("test2",_onclick=self.bind(self.python,4,b"[2]")) 12 | 13 | # create a js caller (only old bind) 14 | #~ self.js = "self.python=function(_) {%s}" % self.bind.python(b"...arguments") 15 | #~ self+=Tag.button("test",_onclick='self.python(5,"x")') 16 | 17 | # ^^ REPLACE something like that ^^ 18 | self+=Tag.button("test3",_onclick='this.parentNode.python(6,"[3]")') 19 | 20 | @expose # only needed for button 'test3' 21 | def python(self,nb,data): 22 | self+= nb * str(data) 23 | 24 | from htag.runners import BrowserHTTP as Runner 25 | 26 | app=Runner( App ) 27 | if __name__=="__main__": 28 | app.run() 29 | 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 manatlan 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /examples/pyscript.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Test htag 7 | 8 | 9 | 10 | 11 | loading... 12 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /selenium/app1.py: -------------------------------------------------------------------------------- 1 | #!./venv/bin/python3 2 | import sys,os; sys.path.insert(0,os.path.join( os.path.dirname(__file__),"..")) 3 | import hclient 4 | ################################################################################# 5 | # 6 | from htag import Tag 7 | 8 | class App(Tag.body): 9 | """ the base """ 10 | def init(self): 11 | def say_hello(o): 12 | self <= Tag.li("hello") 13 | self<= Tag.button("click",_onclick = say_hello) 14 | self<= Tag.button("exit",_onclick = lambda o: self.exit()) 15 | 16 | # 17 | ################################################################################# 18 | 19 | def tests(client:hclient.HClient): 20 | assert "App" in client.title 21 | 22 | client.click('//button[text()="click"]') 23 | client.click('//button[text()="click"]') 24 | client.click('//button[text()="click"]') 25 | 26 | assert len(client.find('//li'))==3 27 | 28 | client.click('//button[text()="exit"]') 29 | return True 30 | 31 | if __name__=="__main__": 32 | # hclient.normalRun(App) 33 | hclient.test( App, "WS", tests) 34 | # hclient.test( App, "HTTP", tests) 35 | # hclient.test( App, "PyScript", tests) #NEED a "uv build" before !!!! 36 | -------------------------------------------------------------------------------- /docs/js_bidirectionnal.md: -------------------------------------------------------------------------------- 1 | # JS bidirectionnal (@expose) 2 | 3 | Sometimes, when using a JS lib, with heavy/bidirectionnal interactions : you 'll need to call js and receive events from JS. You can do that by using a `@expose` decorator. 4 | 5 | Here is a "audio player" tag, which expose a python "play' method, and a "event" method (whil will be decorated) to receive event from the js side. Here is the best way to do it : 6 | 7 | ```python 8 | from htag import Tag, expose 9 | 10 | class APlayer(Tag.div): 11 | statics=Tag.script(_src="https://cdnjs.cloudflare.com/ajax/libs/howler/2.2.4/howler.min.js") 12 | 13 | def init(self): 14 | self.js=""" 15 | 16 | self.play= function(url) { 17 | if(this._hp) { 18 | this._hp.stop(); 19 | this._hp.unload(); 20 | } 21 | 22 | this._hp = new Howl({ 23 | src: [url], 24 | autoplay: true, 25 | loop: false, 26 | volume: 1, 27 | onend: function() { 28 | self.event("end",url); 29 | } 30 | }); 31 | 32 | } 33 | """ % self.bind.event(b"args") 34 | 35 | def play(self,url): 36 | self.call( f"self.play(`{url}`)" ) 37 | 38 | @expose 39 | def event(self,name,url): 40 | self+=f"EVENT: {name} {url}" 41 | ``` 42 | -------------------------------------------------------------------------------- /selenium/app3.py: -------------------------------------------------------------------------------- 1 | #!./venv/bin/python3 2 | import sys,os; sys.path.insert(0,os.path.join( os.path.dirname(__file__),"..")) 3 | import hclient 4 | ################################################################################# 5 | # 6 | from htag import Tag 7 | 8 | class App(Tag.div): 9 | """ Stream UI """ 10 | 11 | imports=[] 12 | 13 | def init(self): 14 | self.call.streamui() 15 | 16 | def streamui(self): 17 | for i in range(3): 18 | yield Tag.my_tag(f"content{i}") 19 | self.call.check( b"self.innerHTML" ) 20 | yield 21 | self.clear() 22 | self <= Tag.button("exit",_onclick = lambda o: self.exit()) 23 | 24 | def check(self,innerhtml): 25 | assert self.innerHTML == innerhtml 26 | 27 | # 28 | # ################################################################################# 29 | 30 | def tests(client:hclient.HClient): 31 | assert "App" in client.title 32 | client.wait(2) 33 | client.click('//button[text()="exit"]') 34 | return True 35 | 36 | 37 | if __name__=="__main__": 38 | # hclient.normalRun(App) 39 | hclient.test( App, "WS", tests) 40 | # hclient.test( App, "HTTP", tests) 41 | # hclient.test( App, "PyScript", tests) #NEED a "uv build" before !!!! 42 | -------------------------------------------------------------------------------- /examples/README.md: -------------------------------------------------------------------------------- 1 | Since **htag** can run in **pyscript**, you can execute this 5 examples, in your browser ;-) 2 | 3 | - [pyscript.html](https://raw.githack.com/manatlan/htag/main/examples/pyscript.html), a simple and minimal example 4 | - [pyscript_demo.html](https://raw.githack.com/manatlan/htag/main/examples/pyscript_demo.html), a simple editor to test its own htag's Tags, in a pyscript context. 5 | - [pyscript_with_hashchange.html](https://raw.githack.com/manatlan/htag/main/examples/pyscript_with_hashchange.html), an example using a component which listening hash event, and react on hashchange (can use navigation history) 6 | - [pyscript_htbulma.html](https://raw.githack.com/manatlan/htag/main/examples/pyscript_htbulma.html), which use [htbulma](https://github.com/manatlan/htbulma) **DEPRECATED**, a lib of pre-made htag's components. 7 | - [pyscript_htagui.html](https://raw.githack.com/manatlan/htag/main/examples/pyscript_htagui.html), which use [htagui](https://github.com/manatlan/htagui), a lib of pre-made htag's components. 8 | - [pyscript_matplotlib.html](https://raw.githack.com/manatlan/htag/main/examples/pyscript_matplotlib.html), an example using **matplotlib** (which works in pyscript too) ;-) 9 | 10 | All theses things, will work in a simple html page (no (real) python required) 11 | -------------------------------------------------------------------------------- /selenium/app2.py: -------------------------------------------------------------------------------- 1 | #!./venv/bin/python3 2 | import sys,os; sys.path.insert(0,os.path.join( os.path.dirname(__file__),"..")) 3 | import hclient 4 | ################################################################################# 5 | # 6 | from htag import Tag 7 | 8 | class App(Tag.div): 9 | """ Yield UI """ 10 | 11 | imports=[] 12 | 13 | def init(self): 14 | self.call.drawui() 15 | 16 | def drawui(self): 17 | for i in range(3): 18 | yield 19 | self <= Tag.my_tag(f"content{i}") 20 | self.call.check( b"self.innerHTML" ) 21 | yield 22 | self.clear() 23 | self <= Tag.button("exit",_onclick = lambda o: self.exit()) 24 | 25 | def check(self,innerhtml): 26 | assert self.innerHTML == innerhtml 27 | 28 | # 29 | ################################################################################# 30 | 31 | def tests(client:hclient.HClient): 32 | assert "App" in client.title 33 | client.wait(2) 34 | client.click('//button[text()="exit"]') 35 | return True 36 | 37 | if __name__=="__main__": 38 | # hclient.normalRun(App) 39 | hclient.test( App, "WS", tests) 40 | # hclient.test( App, "HTTP", tests) 41 | # hclient.test( App, "PyScript", tests) #NEED a "uv build" before !!!! 42 | -------------------------------------------------------------------------------- /examples/matplot.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import os,sys; sys.path.insert(0,os.path.dirname(os.path.dirname(__file__))) 3 | 4 | from htag import Tag 5 | import io,base64,random 6 | import matplotlib.pyplot as plt 7 | 8 | class TagPlot(Tag.span): 9 | def init(self,plt): 10 | self["style"]="display:inline-block" 11 | with io.StringIO() as fid: 12 | plt.savefig(fid,format='svg',bbox_inches='tight') 13 | self <= fid.getvalue() 14 | 15 | class App(Tag.body): 16 | def init(self): 17 | self.content = Tag.div() 18 | 19 | # create the layout 20 | self += Tag.h2("MatPlotLib " + Tag.button("Random",_onclick=self.redraw_plt)) 21 | self += self.content 22 | 23 | self.redraw_plt() 24 | 25 | def redraw_plt(self,obj=None): 26 | plt.clf() 27 | plt.ylabel('Some numbers') 28 | plt.xlabel('Size of my list') 29 | my_list=[random.randint(1,10) for i in range(random.randint(20,50))] 30 | plt.plot( my_list ) 31 | 32 | self.content.clear() 33 | self.content += Tag.div(f"My list: {my_list}") 34 | self.content += TagPlot(plt) 35 | 36 | from htag.runners import BrowserHTTP as Runner 37 | app=Runner(App) 38 | if __name__=="__main__": 39 | app.run() 40 | -------------------------------------------------------------------------------- /docs/tag_update.md: -------------------------------------------------------------------------------- 1 | # tag.update() 2 | 3 | This feature add a "send from backend to frontend" capacity, which can only work with 4 | [Runners](runners.md) which have a "permanent connexion" with front end (think websocket) 5 | 6 | Note that this feature will not work, in this cases: 7 | 8 | - When the Runner is in `http_only` mode (because this feature use websocket only) ! 9 | - With [PyWebView](runners.md#PyWebView) runner, because [pywebview](https://pywebview.flowrl.com) doesn't support async things. 10 | 11 | It opens a lot of powers, and real time exchanges (see `examples/new_*.py`) ! and works for [htagweb](https://github.com/manatlan/htagweb) & [htagapk](https://github.com/manatlan/htagapk) too. 12 | 13 | ## Use cases 14 | 15 | `tag.update()` is a coroutine, so you should use it in a coroutine only. It returns True/False depending 16 | on the capacity to update in realtime. (with http's runners : it will always return False) 17 | 18 | Here is an async method of an htag component (runned with a `asyncio.ensure_future( self.do_something() )`): 19 | ```python 20 | 21 | async def do_something(self): 22 | self += "hello" 23 | isUpdatePossible = await self.update() 24 | 25 | ``` 26 | 27 | Note that, the "first updates" can return False, while the websocket is not really connected. And will return 28 | True when it's done. 29 | -------------------------------------------------------------------------------- /examples/pyscript_htagui.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Test htag 7 | 8 | 9 | 10 | 11 | loading... 12 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /examples/leaflet.py: -------------------------------------------------------------------------------- 1 | import os,sys; sys.path.insert(0,os.path.dirname(os.path.dirname(__file__))) 2 | from htag import Tag 3 | 4 | class LeafLet(Tag.div): 5 | statics =[ 6 | Tag.link(_href="https://unpkg.com/leaflet@1.8.0/dist/leaflet.css",_rel="stylesheet"), 7 | Tag.script(_src="https://unpkg.com/leaflet@1.8.0/dist/leaflet.js"), 8 | ] 9 | 10 | def init(self,lat:float,long:float,zoom:int=13): 11 | self["style"]="height:300px;width:300px;border:2px solid black;display:inline-block;margin:2px;" 12 | self.js = f""" 13 | var map = L.map('{id(self)}').setView([{lat}, {long}], {zoom}); 14 | 15 | L.tileLayer('https://{{s}}.tile.openstreetmap.org/{{z}}/{{x}}/{{y}}.png', 16 | {{ 17 | attribution: 'LeafLet', 18 | maxZoom: 17, 19 | minZoom: 9 20 | }}).addTo(map); 21 | """ 22 | 23 | class App(Tag.body): 24 | 25 | def init(self): 26 | self <= LeafLet(51.505, -0.09) #london, uk 27 | self <= LeafLet(42.35,-71.08) #Boston, usa 28 | 29 | self.call( """navigator.geolocation.getCurrentPosition((position)=>{%s});""" 30 | % self.bind.add_me(b"position.coords.latitude",b"position.coords.longitude") 31 | ) 32 | 33 | def add_me(self,lat,long): 34 | self <= Tag.div(f"And you could be near here: ({lat},{long}):") 35 | self <= LeafLet(lat,long) 36 | 37 | 38 | from htag.runners import DevApp 39 | 40 | app=DevApp(App) 41 | 42 | if __name__=="__main__": 43 | app.run() 44 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | // See https://go.microsoft.com/fwlink/?LinkId=733558 3 | // for the documentation about the tasks.json format 4 | "version": "2.0.0", 5 | "tasks": [ 6 | { 7 | "label": "Test Coverage", 8 | "type": "shell", 9 | "command": "uv run pytest --cov-report html --cov=htag . && google-chrome htmlcov/index.html", 10 | "problemMatcher": [], 11 | "presentation": { 12 | "panel": "new", 13 | "focus": true 14 | }, 15 | "group": { 16 | "kind": "build", 17 | "isDefault": true 18 | } 19 | }, 20 | { 21 | "label": "clean repo", 22 | "type": "shell", 23 | "command": "rm -rf build __pycache__ htag/__pycache__ htag/runners/__pycache__ .pytest_cache .coverage htmlcov", 24 | "problemMatcher": [], 25 | "presentation": { 26 | "panel": "new", 27 | "focus": true 28 | } 29 | }, 30 | { 31 | "label": "Build zip with all", 32 | "type": "shell", 33 | "command": "rm -rf build __pycache__ htag/__pycache__ htag/runners/__pycache__ .pytest_cache .coverage htmlcov; zip -r src.zip htag examples test_*", 34 | "problemMatcher": [], 35 | "presentation": { 36 | "panel": "new", 37 | "focus": true 38 | } 39 | } 40 | ] 41 | } -------------------------------------------------------------------------------- /examples/7guis/gui4.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from htag import Tag # the only thing you'll need ;-) 3 | 4 | class Percent(Tag.div): 5 | def init(self,v): 6 | self.value=v 7 | self["style"]="width:100px;height:20px;border:1px solid black" 8 | def render(self): 9 | self <= Tag.div(_style="height:20px;background:blue;width:%s%%;" % self.value) 10 | 11 | 12 | class Gui4(Tag.body): 13 | """ https://eugenkiss.github.io/7guis/tasks/#timer """ 14 | """ https://svelte.dev/examples/7guis-timer """ 15 | 16 | #TODO: finnish it 17 | #TODO: finnish it 18 | #TODO: finnish it 19 | #TODO: finnish it 20 | #TODO: finnish it 21 | 22 | def init(self): 23 | self.value=10 24 | self.gauge = Percent(20) 25 | 26 | def render(self): 27 | self.clear() 28 | self <= self.gauge 29 | 30 | self <= Tag.input( 31 | _value=self.value, 32 | _type="range", 33 | _min=1, 34 | _max=100, 35 | _step=1, 36 | _onchange=self.bind.change(b"this.value") 37 | ) 38 | self<=Tag.button("Reset") 39 | 40 | def change(self,v): 41 | self.value=v 42 | 43 | self.gauge.value=v # FOR TEST ONLY 44 | 45 | 46 | App=Gui4 47 | if __name__=="__main__": 48 | # and execute it in a pywebview instance 49 | from htag.runners import * 50 | PyWebWiew( Gui4 ).run() 51 | 52 | # here is another runner, in a simple browser (thru ajax calls) 53 | # BrowserHTTP( Page ).run() 54 | -------------------------------------------------------------------------------- /test_update.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 -u 2 | # -*- coding: utf-8 -*- 3 | from dataclasses import replace 4 | import pytest 5 | import asyncio 6 | import re 7 | 8 | from htag import Tag,HTagException 9 | from htag.render import Stater,HRenderer,fmtcaller 10 | 11 | # Test new feature tag.update() with compatible hrenderer/runner (for htag > 0.10.0) 12 | 13 | @pytest.mark.asyncio 14 | async def test_update_default(): 15 | class MyTag(Tag.div): 16 | def init(self): 17 | pass 18 | 19 | hr=HRenderer( MyTag, "//") 20 | 21 | tag=hr.tag 22 | assert not await tag.update() 23 | 24 | 25 | @pytest.mark.asyncio 26 | async def test_update_capable(): 27 | class MyTag(Tag.div): 28 | def init(self): 29 | pass 30 | 31 | async def _sendactions(actions:dict) -> bool: 32 | assert "update" in actions 33 | assert "post" in actions 34 | ll=list(actions["update"].items()) 35 | assert len(ll)==1 36 | id,content = ll[0] 37 | assert str(id) in content 38 | assert ">hello<" in content 39 | return True 40 | 41 | hr=HRenderer( MyTag, "//") 42 | hr.sendactions = _sendactions 43 | 44 | tag=hr.tag 45 | tag+="hello" 46 | tag.js="console.log(42)" # add a js/post 47 | assert await tag.update() 48 | 49 | 50 | 51 | 52 | if __name__=="__main__": 53 | import logging 54 | logging.basicConfig(format='[%(levelname)-5s] %(name)s: %(message)s',level=logging.DEBUG) 55 | # asyncio.run( test_update_default() ) 56 | asyncio.run( test_update_capable() ) -------------------------------------------------------------------------------- /manual_tests_persitent.py: -------------------------------------------------------------------------------- 1 | #!./venv/bin/python3 2 | from htag import Tag # the only thing you'll need ;-) 3 | 4 | class Nimp(Tag.div): 5 | def init(self): 6 | # !!! previous3 will not be saved in '/tmp/AEFFFF.json' !!! 7 | # (it's not the main/managed tag (which is Page), so it's an inner dict) 8 | self.state["previous3"]=self.state.get("previous3","") + "!" 9 | self.clear( self.state["previous3"] ) 10 | 11 | 12 | class Page(Tag.body): 13 | def init(self): 14 | self.session["previous"]=self.session.get("previous","") + "!" 15 | self.state["previous2"]=self.state.get("previous2","") + "!" 16 | 17 | self+=Tag.div( self.session["previous"] ) 18 | self+=Tag.div( self.state["previous2"] ) 19 | self+=Nimp() 20 | 21 | 22 | # from htag.runners import DevApp as Runner 23 | from htag.runners import ChromeApp as Runner 24 | # from htag.runners import WinApp as Runner 25 | # from htag.runners import BrowserTornadoHTTP as Runner 26 | # from htag.runners import BrowserStarletteWS as Runner 27 | # from htag.runners import BrowserStarletteWS as Runner 28 | # from htag.runners import BrowserHTTP as Runner 29 | # from htag.runners import AndroidApp as Runner 30 | # from htag.runners import PyWebView as Runner 31 | 32 | app=Runner( Page , file="/tmp/AEFFFF.json") 33 | if __name__ == "__main__": 34 | import logging 35 | logging.basicConfig(format='[%(levelname)-5s] %(name)s: %(message)s',level=logging.ERROR) 36 | logging.getLogger("htag.tag").setLevel( logging.ERROR ) 37 | # app.run() 38 | app.run() 39 | -------------------------------------------------------------------------------- /examples/new_timer.py: -------------------------------------------------------------------------------- 1 | 2 | # -*- coding: utf-8 -*- 3 | 4 | import os,sys; sys.path.insert(0,os.path.dirname(os.path.dirname(__file__))) 5 | import asyncio,sys,time 6 | 7 | 8 | from htag import Tag 9 | 10 | 11 | class App(Tag.body): 12 | statics="body {background:#EEE;}" 13 | 14 | def init(self): 15 | self.place = Tag.div(js="console.log('I update myself')") 16 | 17 | asyncio.ensure_future( self.loop_timer() ) 18 | 19 | self += "Hello World" + self.place 20 | self+= Tag.button("yo",_onclick=self.doit) 21 | 22 | async def doit(self,o): 23 | self+="x" 24 | 25 | async def loop_timer(self): 26 | while 1: 27 | await asyncio.sleep(0.5) 28 | self.place.clear(time.time() ) 29 | if not await self.place.update(): # update component using current websocket 30 | # break if can't (<- good practice to kill this asyncio/loop) 31 | break 32 | 33 | 34 | 35 | #================================================================================= with update capacity 36 | # from htag.runners import BrowserStarletteWS as Runner 37 | # from htag.runners import ChromeApp as Runner 38 | # from htag.runners import WinApp as Runner 39 | from htag.runners import DevApp as Runner 40 | #================================================================================= 41 | 42 | #~ from htagweb import WebServer as Runner 43 | # from htag.runners import BrowserHTTP as Runner 44 | 45 | app=Runner(App) 46 | 47 | if __name__=="__main__": 48 | app.run() 49 | -------------------------------------------------------------------------------- /selenium/tests.py: -------------------------------------------------------------------------------- 1 | ################################################################################################## 2 | ## Run the selenium test on port 'sys.argv[1]' with the App 'sys.argv[2]' 3 | ## used by github action : .github/workflows/selenium.yaml 4 | ################################################################################################## 5 | 6 | import sys,os,time 7 | from selenium import webdriver 8 | from webdriver_manager.chrome import ChromeDriverManager 9 | try: 10 | from webdriver_manager.core.utils import ChromeType 11 | except ImportError: 12 | from webdriver_manager.core.os_manager import ChromeType 13 | from selenium.webdriver.chrome.options import Options 14 | from selenium.webdriver.chrome.service import Service 15 | import hclient 16 | 17 | 18 | import importlib 19 | ####################################################### 20 | port = sys.argv[1] 21 | tests=importlib.import_module(sys.argv[2]).tests 22 | ####################################################### 23 | 24 | chrome_service = Service(ChromeDriverManager(chrome_type=ChromeType.CHROMIUM).install()) 25 | 26 | chrome_options = Options() 27 | options = [ 28 | "--headless", 29 | "--disable-gpu", 30 | "--window-size=1920,1200", 31 | "--ignore-certificate-errors", 32 | "--disable-extensions", 33 | "--no-sandbox", 34 | "--disable-dev-shm-usage" 35 | ] 36 | for option in options: 37 | chrome_options.add_argument(option) 38 | 39 | with webdriver.Chrome(service=chrome_service, options=chrome_options) as driver: 40 | driver.get('http://localhost:'+port) 41 | x=hclient.testDriver(driver,tests) 42 | 43 | if x: 44 | print("----> OK") 45 | sys.exit(0) 46 | else: 47 | print("----> KO") 48 | sys.exit(-1) 49 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "htag" 3 | version = "0.0.0" # auto-updated 4 | description = "GUI toolkit for building GUI toolkits (and create beautiful applications for mobile, web, and desktop from a single python3 codebase)" 5 | authors = [{name = "manatlan", email = "manatlan@gmail.com"}] 6 | readme = "README.md" 7 | license = {text = "MIT"} 8 | requires-python = ">=3.7" 9 | keywords = [ 10 | "gui", "electron", "cef", "pywebview", "starlette", "uvicorn", 11 | "tornado", "asyncio", "desktop", "web", "mobile", "http", 12 | "websocket", "html", "pyscript", "android", "kivy", "apk" 13 | ] 14 | classifiers = [ 15 | "Operating System :: OS Independent", 16 | "Topic :: Software Development :: Libraries :: Python Modules", 17 | "Topic :: Software Development :: Build Tools", 18 | "License :: OSI Approved :: Apache Software License", 19 | ] 20 | 21 | [project.urls] 22 | Homepage = "https://github.com/manatlan/htag" 23 | Repository = "https://github.com/manatlan/htag" 24 | Documentation = "https://manatlan.github.io/htag/" 25 | 26 | [project.optional-dependencies] 27 | dev = [ 28 | "pytest>=6", 29 | "pytest-cov>=3", 30 | "pytest-asyncio>=0.19", 31 | "pywebview>=3.6", 32 | "selenium>=4", 33 | "fake-winreg>=1.6", 34 | ] 35 | 36 | [build-system] 37 | requires = ["hatchling"] 38 | build-backend = "hatchling.build" 39 | 40 | [project.scripts] 41 | htag = "htag.__main__:command" 42 | 43 | [tool.uv] 44 | dev-dependencies = [ 45 | "pytest>=6", 46 | "pytest-cov>=3", 47 | "pytest-asyncio>=0.19", 48 | "pywebview>=3.6", 49 | "selenium>=4", 50 | "fake-winreg>=1.6", 51 | ] 52 | 53 | [tool.pytest.ini_options] 54 | addopts = "--cov=htag --cov-report=html --cov-report=xml --cov-report=term-missing" 55 | -------------------------------------------------------------------------------- /examples/pyscript_matplotlib.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Test htag 7 | 8 | 9 | 10 | 11 | loading... 12 | 51 | 52 | 53 | -------------------------------------------------------------------------------- /htag/runners/pywebview.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # # ############################################################################# 3 | # Copyright (C) 2022 manatlan manatlan[at]gmail(dot)com 4 | # 5 | # MIT licence 6 | # 7 | # https://github.com/manatlan/htag 8 | # ############################################################################# 9 | 10 | from .. import Tag 11 | from ..render import HRenderer 12 | from . import commons 13 | 14 | 15 | import asyncio,os 16 | import webview 17 | 18 | 19 | """ 20 | LIMITATION : 21 | 22 | til pywebview doen't support async jsapi ...(https://github.com/r0x0r/pywebview/issues/867) 23 | it can't work for "async generator" with the asyncio.run() trick (line 50) 24 | 25 | pywebview doesn't support : 26 | - document location changes 27 | - handling query_params from url 28 | - "tag.update()" 29 | """ 30 | 31 | class PyWebView: 32 | """ Open the rendering in a pywebview instance 33 | Interactions with builtin pywebview.api ;-) 34 | """ 35 | def __init__(self,tagClass:Tag,file:"str|None"=None): 36 | self._hr_session=commons.SessionFile(file) if file else None 37 | assert issubclass(tagClass,Tag) 38 | 39 | js = """ 40 | async function interact( o ) { 41 | action( await pywebview.api.interact( o["id"], o["method"], o["args"], o["kargs"], o["event"] ) ); 42 | } 43 | 44 | window.addEventListener('pywebviewready', start ); 45 | """ 46 | 47 | self.renderer=HRenderer(tagClass, js, lambda: os._exit(0), session=self._hr_session) 48 | 49 | def run(self): 50 | class Api: 51 | def interact(this,tagid,method,args,kargs,event): 52 | return asyncio.run(self.renderer.interact(tagid,method,args,kargs,event)) 53 | 54 | window = webview.create_window(self.renderer.title, html=str(self.renderer), js_api=Api(),text_select=True) 55 | webview.start(debug=False) 56 | 57 | def exit(self,rc=0): 58 | os._exit(rc) 59 | -------------------------------------------------------------------------------- /examples/7guis/gui5.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from htag import Tag # the only thing you'll need ;-) 3 | 4 | 5 | class Gui5(Tag.body): 6 | """ https://eugenkiss.github.io/7guis/tasks#crud """ 7 | 8 | def init(self): 9 | self.db=["albert","franz","fred"] 10 | self.selected=None 11 | self.filter="" 12 | self.name = "" 13 | 14 | def render(self): 15 | options = [Tag.option(i,_value=idx,_selected=idx==self.selected) for idx,i in enumerate(self.db) if self.filter in i] 16 | 17 | self <= Tag.Input( _value=self.filter, _onchange=self.bind.changeFilter(b"this.value"),_placeholder="filter" ) 18 | self <= Tag.br() 19 | self <= Tag.select( options, _size=10, _onchange=self.bind.setSelected(b"this.value") ,_style="width:80px") 20 | self <= Tag.Input( _value=self.name, _onchange=self.bind.changeName(b"this.value") ) 21 | self <= Tag.hr() 22 | self <= Tag.Button( "create", _onclick=self.bind.create() ) 23 | self <= Tag.Button( "delete", _onclick=self.bind.delete() ) 24 | self <= Tag.Button( "update", _onclick=self.bind.update() ) 25 | 26 | def setSelected(self,v): 27 | self.selected = int(v) 28 | self.name = self.db[self.selected] 29 | 30 | def changeFilter(self,v): 31 | self.filter = v 32 | 33 | def changeName(self,v): 34 | self.name = v 35 | 36 | def create(self): 37 | self.db.append(self.name) 38 | 39 | def delete(self): 40 | if self.selected is not None: 41 | del self.db[self.selected] 42 | self.selected=None 43 | 44 | def update(self): 45 | if self.selected is not None: 46 | self.db[self.selected] = self.name 47 | 48 | 49 | App=Gui5 50 | if __name__=="__main__": 51 | # and execute it in a pywebview instance 52 | from htag.runners import * 53 | PyWebWiew( Gui5 ).run() 54 | 55 | # here is another runner, in a simple browser (thru ajax calls) 56 | # BrowserHTTP( Page ).run() 57 | -------------------------------------------------------------------------------- /manual_tests_remove.py: -------------------------------------------------------------------------------- 1 | #!./venv/bin/python3 2 | # -*- coding: utf-8 -*- 3 | from htag import Tag # the only thing you'll need ;-) 4 | 5 | 6 | 7 | class Button(Tag.button): 8 | def init(self,txt): 9 | # we set some html attributs 10 | self["class"]="my" # set @class to "my" 11 | self["onclick"]=self.onclick # bind a js event on @onclick 12 | self<= txt 13 | 14 | def onclick(self): 15 | print("REMOVE",self.innerHTML) 16 | self.remove() 17 | 18 | 19 | class Page(Tag.body): # define a , but the renderer will force it to in all cases 20 | """ This is the main Tag, it will be rendered as by the htag/renderer """ 21 | statics = b"function error(m) {document.body.innerHTML += m}",".fun {background: red}" 22 | def init(self): 23 | 24 | self <= Button("A") 25 | self <= Button("A2") 26 | self <= Button("A3") 27 | self <= Button("A4") 28 | self <= Button("A5") 29 | self <= Tag.button("error", _onclick=lambda o: fdsgdfgfdsgfds()) 30 | 31 | self <= Tag.button("print",_onclick=self.print,_class="ki") 32 | 33 | def print(self,o): 34 | print("Should appear", o["class"]) 35 | o["class"].toggle("fun") 36 | 37 | class Page2(Tag.body): # define a , but the renderer will force it to in all cases 38 | def init(self): 39 | self+="Hello" 40 | self+=Tag.a("remover",_href="/p") 41 | 42 | 43 | App=Page 44 | 45 | # and execute it in a pywebview instance 46 | from htag.runners import * 47 | # PyWebWiew( Page ).run() 48 | 49 | # here is another runner, in a simple browser (thru ajax calls) 50 | # ChromeApp( Page ).run() 51 | # BrowserHTTP( Page ).run() 52 | app=DevApp( Page ) 53 | app.add_route("/p", lambda request: app.handle( request, Page ) ) 54 | app.add_route("/b", lambda request: app.handle( request, Page2 ) ) 55 | if __name__ == "__main__": 56 | # BrowserTornadoHTTP( Page ).run() 57 | app.run() 58 | -------------------------------------------------------------------------------- /test_ui.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # ############################################################################# 3 | # Copyright (C) 2024 manatlan manatlan[at]gmail(dot)com 4 | # 5 | # MIT licence 6 | # 7 | # https://github.com/manatlan/htag 8 | # ############################################################################# 9 | import sys 10 | import types 11 | import pytest 12 | 13 | @pytest.fixture(autouse=True) 14 | def cleanup_imports(): 15 | """Fixture to ensure a clean slate for imports for each test.""" 16 | original_modules = dict(sys.modules) 17 | 18 | # Unload modules that might interfere 19 | for mod in ['htag.ui', 'htagui', 'htagui.basics']: 20 | if mod in sys.modules: 21 | del sys.modules[mod] 22 | 23 | yield 24 | 25 | # Restore original modules 26 | sys.modules.clear() 27 | sys.modules.update(original_modules) 28 | 29 | 30 | def test_ui_import_from_basics_jules(): 31 | """ Test if 'htag.ui' imports from 'htagui.basics' when available """ 32 | # Create fake modules 33 | htagui_module = types.ModuleType('htagui') 34 | basics_module = types.ModuleType('htagui.basics') 35 | basics_module.MyWidget = 'BasicsWidget' 36 | 37 | # Add to sys.modules 38 | sys.modules['htagui'] = htagui_module 39 | sys.modules['htagui.basics'] = basics_module 40 | 41 | # Import htag.ui and test 42 | import htag.ui 43 | assert hasattr(htag.ui, 'MyWidget') 44 | assert htag.ui.MyWidget == 'BasicsWidget' 45 | 46 | 47 | def test_ui_import_fallback_to_htagui_jules(): 48 | """ Test if 'htag.ui' falls back to importing from 'htagui' """ 49 | # Create a fake htagui module (without .basics) 50 | htagui_module = types.ModuleType('htagui') 51 | htagui_module.MyWidget = 'HtaguiWidget' 52 | 53 | # Add to sys.modules 54 | sys.modules['htagui'] = htagui_module 55 | 56 | # Import htag.ui and test 57 | import htag.ui 58 | assert hasattr(htag.ui, 'MyWidget') 59 | assert htag.ui.MyWidget == 'HtaguiWidget' -------------------------------------------------------------------------------- /manual_tests_htbulma.py: -------------------------------------------------------------------------------- 1 | #!./venv/bin/python3 2 | from htag import Tag 3 | import htbulma as b 4 | 5 | class Star1(Tag.div): # it's a component ;-) 6 | """ rendering lately (using render (called before __str__)) 7 | 8 | it's simpler ... but event/action shouldn't try to draw something, coz render will rebuild all at each time 9 | """ 10 | 11 | def init(self,value=0): 12 | self.value=value 13 | 14 | def inc(self,v): 15 | self.value+=v 16 | 17 | def render(self): # <- ensure dynamic rendering 18 | self.clear() 19 | self <= b.Button( "-", _onclick = lambda o: self.inc(-1), _class="is-small" ) 20 | self <= b.Button( "+", _onclick = lambda o: self.inc(+1), _class="is-small" ) 21 | self <= "⭐"*self.value 22 | 23 | class Star2(Tag.div): # it's a component ;-) 24 | """ rendering immediatly (each event does the rendering) 25 | 26 | it's less simple ... but event/action should redraw something ! 27 | 28 | """ 29 | 30 | def init(self,value=0): 31 | self.value=value 32 | self <= b.Button( "-", _onclick = lambda o: self.inc(-1), _class="is-small" ) 33 | self <= b.Button( "+", _onclick = lambda o: self.inc(+1), _class="is-small" ) 34 | 35 | self.content = Tag.span( "⭐"*self.value ) 36 | self <= self.content 37 | 38 | def inc(self,v): 39 | self.value+=v 40 | self.content.clear( "⭐"*self.value ) 41 | 42 | 43 | 44 | class App(Tag.body): 45 | 46 | def init(self): 47 | self._s = b.Service(self) 48 | 49 | nav = b.Nav("My App") 50 | nav.addEntry("entry 1", lambda: self._s.alert( "You choose 1" ) ) 51 | nav.addEntry("entry 2", lambda: self._s.alert( "You choose 2" ) ) 52 | 53 | tab = b.Tabs() 54 | tab.addTab("Tab 1", Tag.b("Set star1s") + Star1(12) ) 55 | tab.addTab("Tab 2", Tag.b("Set star2s") + Star2(12) ) 56 | 57 | self <= nav + b.Section( tab ) 58 | 59 | 60 | if __name__=="__main__": 61 | # import logging 62 | # logging.basicConfig(format='[%(levelname)-5s] %(name)s: %(message)s',level=logging.DEBUG) 63 | 64 | from htag.runners import * 65 | BrowserStarletteWS( App ).run() 66 | -------------------------------------------------------------------------------- /examples/camshot.py: -------------------------------------------------------------------------------- 1 | import os,sys; sys.path.insert(0,os.path.dirname(os.path.dirname(__file__))) 2 | from htag import Tag 3 | 4 | class Cam(Tag.video): 5 | """ Htag component to start the cam, and to take screnshot (by clicking on it)""" 6 | 7 | # some javascripts needed for the component 8 | statics = Tag.script(""" 9 | function startCam(video) { 10 | navigator.mediaDevices.getUserMedia({ 11 | video: true, 12 | audio: false 13 | }) 14 | .then(function(stream) { 15 | video.srcObject = stream; 16 | video.play(); 17 | }) 18 | .catch(function(err) { 19 | const o = document.createElement("div"); 20 | o.innerHTML = "NO CAMERA: " + err; 21 | video.replaceWith(o); 22 | }); 23 | } 24 | 25 | function takeCamShot(video) { 26 | let {width, height} = video.srcObject.getTracks()[0].getSettings(); 27 | 28 | let canvas = document.createElement("canvas"); 29 | let context = canvas.getContext('2d'); 30 | canvas.width = width; 31 | canvas.height = height; 32 | context.drawImage(video, 0, 0, canvas.width, canvas.height); 33 | return canvas.toDataURL('image/jpeg'); 34 | } 35 | """) 36 | 37 | # js which will be executed, each time the component is appended 38 | js = """startCam(tag);""" 39 | 40 | def init(self,callback=None,width=300,height=300): 41 | self.width=width 42 | self.height=height 43 | self["style"]=f"width:{width}px;height:{height}px;border:1px solid black" 44 | if callback: 45 | self["onclick"]=self.bind( callback, b"takeCamShot(this)" ) 46 | 47 | class App(Tag.body): 48 | """ An Htag App to handle the camera, screenshots are displayed in the flow""" 49 | 50 | statics = b"function error(m) {alert(m)}" 51 | 52 | def init(self): 53 | self <= Cam(self.takeShot) 54 | 55 | def takeShot(self,o,dataurl): 56 | self <= Tag.img(_src=dataurl,_style="max-width:%spx;max-height:%spx;" % (o.width,o.height)) 57 | 58 | 59 | 60 | if __name__=="__main__": 61 | from htag.runners import * 62 | # r=PyWebWiew( App ) 63 | # r=BrowserStarletteHTTP( App ) 64 | # r=BrowserStarletteWS( App ) 65 | # r=BrowserHTTP( App ) 66 | r=BrowserTornadoHTTP( App ) 67 | r.run() 68 | 69 | -------------------------------------------------------------------------------- /examples/calc.py: -------------------------------------------------------------------------------- 1 | import os,sys; sys.path.insert(0,os.path.dirname(os.path.dirname(__file__))) 2 | 3 | from htag import Tag 4 | 5 | """ 6 | This example show you how to make a "Calc App" 7 | (with physical buttons + keyboard events) 8 | 9 | There is no work for rendering the layout ;-) 10 | 11 | Can't be simpler ! 12 | 13 | """ 14 | 15 | class Calc(Tag.div): 16 | statics=[Tag.style(""" 17 | .mycalc *,button {font-size:2em;font-family: monospace} 18 | """)] 19 | 20 | def init(self): 21 | self.txt="" 22 | self.aff = Tag.Div(" ",_style="border:1px solid black") 23 | 24 | self["class"]="mycalc" 25 | self <= self.aff 26 | self <= Tag.button("C", _onclick=self.bind( self.clean) ) 27 | self <= [Tag.button(i, _onclick=self.bind( self.press, i) ) for i in "0123456789+-x/."] 28 | self <= Tag.button("=", _onclick=self.bind( self.compute ) ) 29 | 30 | #-/-/-/-/-/-/-/-/-/-/-/-/-/-/-/-/-/-/-/-/-/-/-/-/-/-/-/-/-/-/-/ with real keyboard 31 | self["onkeyup"] = self.presskey 32 | 33 | def presskey(self,ev): 34 | key=ev.key 35 | if key in "0123456789+-*/.": 36 | self.press(key) 37 | elif key=="Enter": 38 | self.compute() 39 | elif key in ["Delete","Backspace"]: 40 | self.clean() 41 | #-/-/-/-/-/-/-/-/-/-/-/-/-/-/-/-/-/-/-/-/-/-/-/-/-/-/-/-/-/-/-/ 42 | 43 | def press(self,val): 44 | self.txt += val 45 | self.aff.clear( self.txt ) 46 | 47 | def compute(self): 48 | try: 49 | self.txt = str(eval(self.txt.replace("x","*"))) 50 | self.aff.clear( self.txt ) 51 | except: 52 | self.txt = "" 53 | self.aff.clear( "Error" ) 54 | 55 | def clean(self): 56 | self.txt="" 57 | self.aff.clear(" ") 58 | 59 | App=Calc 60 | 61 | if __name__=="__main__": 62 | # import logging 63 | # logging.basicConfig(format='[%(levelname)-5s] %(name)s: %(message)s',level=logging.DEBUG) 64 | # logging.getLogger("htag.tag").setLevel( logging.INFO ) 65 | 66 | # and execute it in a pywebview instance 67 | from htag.runners import * 68 | 69 | # here is another runner, in a simple browser (thru ajax calls) 70 | BrowserHTTP( Calc ).run() 71 | # PyWebWiew( Calc ).run() 72 | -------------------------------------------------------------------------------- /docs/query_params.md: -------------------------------------------------------------------------------- 1 | # Instanciating main htag class with url/query params 2 | 3 | In most runners, you can use "query parameters" (from the url) to instanciate your htag main class. 4 | 5 | Admit that your main class looks like that : 6 | 7 | ```python 8 | 9 | class MyTag(Tag.div): 10 | def init(self,name,age=12): 11 | ... 12 | 13 | ``` 14 | 15 | If can point your running App to urls like: 16 | 17 | * `/?name=john&age=12` -> will create the instance `MyTag("john",'12')` 18 | * `/?Jim&42` -> will create the instance `MyTag("Jim",'42')` 19 | * `/?Jo` -> will create the instance `MyTag("Jo",12)` 20 | 21 | As long as parameters, fulfill the signature of the constructor : it will construct the instance with them. 22 | If it doesn't fit : it try to construct the instance with no parameters ! 23 | 24 | So things like that : 25 | 26 | * `/` -> will result in http/400, **because `name` is mandatory** in all cases !! 27 | 28 | BTW, if your class looks like that (with `**a` trick, to accept html attributes or property instance) 29 | 30 | ```python 31 | class MyTag(Tag.div): 32 | def init(self,name,age=12,**a): 33 | ... 34 | ``` 35 | things like that, will work : 36 | 37 | * `/?Jim&_style=background:red` -> will create the instance `MyTag("Jim",'42')`, and change the default bg color of the instance. 38 | 39 | So, it's a **best practice**, to not have the `**a` trick in the constructor of main htag class (the one which is runned by the runner) 40 | 41 | **Remarks:** 42 | 43 | * all query params are string. It's up to you to cast to your needs. 44 | * this feature comes with htag >= 0.8.0 45 | 46 | ## Try by yourself 47 | 48 | ```python 49 | from htag import Tag 50 | 51 | class MyApp(Tag.div): 52 | def init(self,param="default",**a): 53 | self <= f"Param: {param}" 54 | aa=lambda x: Tag.a("test: "+x,_href=x,_style="display:block") 55 | self <= aa("?") 56 | self <= aa("?rien") 57 | self <= aa("?toto") 58 | self <= aa("?toto&56") 59 | self <= aa("?param=kiki") 60 | self <= aa("?nimp=kaka") 61 | self <= aa("?tot1#a1") 62 | self <= aa("?tot2#a2") 63 | self <= aa("?to12&p=12") 64 | self <= aa("?tot3&_style=background:red") 65 | 66 | from htag.runners import Runner 67 | Runner(MyApp).run() 68 | ``` 69 | 70 | Currently, this feature works in all htag.runners except PyWebView ;-( 71 | -------------------------------------------------------------------------------- /examples/pyscript_with_hashchange.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Test htag 7 | 8 | 9 | 10 | 11 | loading... 12 | 62 | 63 | 64 | -------------------------------------------------------------------------------- /.github/workflows/selenium.yaml: -------------------------------------------------------------------------------- 1 | # taken from https://github.com/jsoma/selenium-github-actions 2 | name: Selenium GUI Tests 3 | on: 4 | workflow_dispatch: 5 | schedule: 6 | # every day at 3:00 AM 7 | - cron: '0 3 * * *' 8 | jobs: 9 | selenium: 10 | runs-on: ubuntu-latest 11 | strategy: 12 | matrix: 13 | app: [app1, app2, app3, app4, app_all_bindings] 14 | steps: 15 | - name: Check out this repo 16 | uses: actions/checkout@v3 17 | - name: Set up Python 18 | uses: actions/setup-python@v4 19 | with: 20 | python-version: '3.11' 21 | - name: Installed package list 22 | run: apt list --installed 23 | - name: Remove Chrome 24 | run: sudo apt purge google-chrome-stable 25 | - name: Remove default Chromium 26 | run: sudo apt purge chromium-browser 27 | - name: Install a new Chromium 28 | run: sudo apt install -y chromium-browser 29 | - name: Install selenium/uv packages 30 | run: pip install webdriver-manager selenium uv 31 | - name: pip list 32 | run: pip list 33 | 34 | 35 | ############################################################################# 36 | ## test the basic Runner (WS MODE) 37 | ############################################################################# 38 | - name: Run Tests Runner/WS ${{ matrix.app }} 39 | run: | 40 | python selenium/run.py WS ${{ matrix.app }} & 41 | python selenium/tests.py 8000 ${{ matrix.app }} 42 | 43 | ############################################################################# 44 | ## test the basic Runner (HTTP MODE) 45 | ############################################################################# 46 | - name: Run Tests Runner/HTTP ${{ matrix.app }} 47 | run: | 48 | python selenium/run.py HTTP ${{ matrix.app }} & 49 | python selenium/tests.py 8000 ${{ matrix.app }} 50 | 51 | ############################################################################# 52 | ## test with PyScript Runner 53 | ############################################################################# 54 | - name: Build WHL for pyscript tests 55 | run: uv build 56 | 57 | - name: Run Tests PyScript (can't exit itself) ${{ matrix.app }} 58 | run: | 59 | python selenium/run.py PyScript ${{ matrix.app }} 8001 & 60 | python selenium/tests.py 8001 ${{ matrix.app }} 61 | killall python 62 | 63 | -------------------------------------------------------------------------------- /.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 | 131 | AEFF* 132 | -------------------------------------------------------------------------------- /old_runners/browserstarlettehttp.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # # ############################################################################# 3 | # Copyright (C) 2022 manatlan manatlan[at]gmail(dot)com 4 | # 5 | # MIT licence 6 | # 7 | # https://github.com/manatlan/htag 8 | # ############################################################################# 9 | 10 | from .. import Tag 11 | from ..render import HRenderer 12 | from . import commons 13 | 14 | import os 15 | 16 | from starlette.applications import Starlette 17 | from starlette.responses import HTMLResponse,JSONResponse 18 | from starlette.routing import Route 19 | 20 | 21 | class BrowserStarletteHTTP(Starlette): 22 | """ Simple ASync Web Server (with starlette) with HTTP interactions with htag. 23 | Open the rendering in a browser tab. 24 | 25 | The instance is an ASGI htag app 26 | """ 27 | def __init__(self,tagClass:Tag,file:"str|None"=None): 28 | self._hr_session=commons.SessionFile(file) if file else None 29 | assert issubclass(tagClass,Tag) 30 | 31 | self.hrenderer = None 32 | self.tagClass = tagClass 33 | 34 | Starlette.__init__(self,debug=True, routes=[ 35 | Route('/', self.GET, methods=["GET"]), 36 | Route('/', self.POST, methods=["POST"]), 37 | ]) 38 | 39 | def instanciate(self,url:str): 40 | init = commons.url2ak(url) 41 | if self.hrenderer and self.hrenderer.init == init: 42 | return self.hrenderer 43 | 44 | js = """ 45 | async function interact( o ) { 46 | action( await (await window.fetch("/",{method:"POST", body:JSON.stringify(o)})).text() ) 47 | } 48 | 49 | window.addEventListener('DOMContentLoaded', start ); 50 | """ 51 | 52 | return HRenderer(self.tagClass, js, lambda: os._exit(0), init=init,session=self._hr_session) 53 | 54 | async def GET(self,request) -> HTMLResponse: 55 | self.hrenderer = self.instanciate( str(request.url) ) 56 | return HTMLResponse( str(self.hrenderer) ) 57 | 58 | async def POST(self,request) -> JSONResponse: 59 | data = await request.json() 60 | dico = await self.hrenderer.interact(data["id"],data["method"],data["args"],data["kargs"],data.get("event")) 61 | return JSONResponse(dico) 62 | 63 | def run(self, host="127.0.0.1", port=8000, openBrowser=True): # localhost, by default !! 64 | import uvicorn,webbrowser 65 | if openBrowser: 66 | webbrowser.open_new_tab(f"http://{host}:{port}") 67 | 68 | uvicorn.run(self, host=host, port=port) 69 | 70 | -------------------------------------------------------------------------------- /examples/navigate_with_hashchange.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import os,sys; sys.path.insert(0,os.path.dirname(os.path.dirname(__file__))) 3 | 4 | from htag import Tag,expose 5 | 6 | #/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\ 7 | # an htag Tag, to use hashchange events 8 | #/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\ 9 | class View(Tag.div): 10 | def __init__(self,tag=None,**a): # use complex constructor to do complex things ;-) 11 | super().__init__(tag,**a) 12 | self.default = tag 13 | self._refs={} 14 | self.js = """ 15 | if(!window._hashchange_listener) { 16 | window.addEventListener('hashchange',() => {self._hashchange(document.location.hash);}); 17 | window._hashchange_listener=true; 18 | } 19 | """ 20 | @expose 21 | def _hashchange(self,hash): 22 | self.clear( self._refs.get(hash, self.default) ) 23 | 24 | def go(self,tag,anchor=None): 25 | """ Set object 'tag' in the View, and navigate to it """ 26 | anchor=anchor or str(id(tag)) 27 | self._refs[f'#{anchor}'] = tag 28 | self.call( f"document.location=`#{anchor}`") 29 | #/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\ 30 | 31 | class Page1(Tag.span): 32 | def init(self): 33 | self+="Page 1" 34 | 35 | class Page2(Tag.div): 36 | def init(self): 37 | self+="Page 2" 38 | 39 | class App(Tag.body): 40 | """ Example1: classic use (view is just a child of the body), at child level """ 41 | def init(self): 42 | p0 = "welcome" # default page 43 | p1 = Page1() 44 | p2 = Page2() 45 | 46 | self.v = View( p0, _style="border:1px solid red;width:100%;height:400px" ) 47 | 48 | # layout 49 | self += Tag.button("p1",_onclick=lambda o: self.v.go( p1,"p1" )) 50 | self += Tag.button("p2",_onclick=lambda o: self.v.go( p2,"p2" )) 51 | self += self.v 52 | 53 | # class MyApp(View): 54 | # """ Example2: The body is a 'View' (at top level) """ 55 | # def __init__(self,**a): 56 | # p0 = "welcome" # default page 57 | # p1 = Page1() 58 | # p2 = Page2() 59 | 60 | # # add "menus" to pages, to be able to navigate 61 | # sp = lambda o: self.go( o.page, o.page.__class__.__name__ ) 62 | # menus = Tag.button("p1", page=p1, _onclick=sp) + Tag.button("p2",page=p2,_onclick=sp) 63 | # p1 += menus 64 | # p2 += menus 65 | 66 | # super().__init__(p0 + menus,**a) 67 | 68 | 69 | #====================================== 70 | from htag.runners import DevApp as Runner 71 | 72 | app=Runner( App ) 73 | if __name__=="__main__": 74 | app.run() 75 | -------------------------------------------------------------------------------- /.github/workflows/on_tag_do_release_and_deploy_pypi.yml: -------------------------------------------------------------------------------- 1 | # This workflow will upload a Python Package to PyPI when a release is created 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python#publishing-to-package-registries 3 | 4 | # This workflow uses actions that are not certified by GitHub. 5 | # They are provided by a third-party and are governed by 6 | # separate terms of service, privacy policy, and support 7 | # documentation. 8 | 9 | name: Upload Python Package 10 | 11 | on: 12 | # workflow_dispatch: # to be able to test manually on github/actions 13 | # release: 14 | # types: [published] 15 | push: 16 | tags: 17 | - "v*.*.*" 18 | 19 | permissions: 20 | contents: read 21 | 22 | jobs: 23 | release-build: 24 | runs-on: ubuntu-latest 25 | 26 | steps: 27 | - uses: actions/checkout@v4 28 | 29 | - uses: actions/setup-python@v5 30 | with: 31 | python-version: "3.x" 32 | 33 | - name: Build release distributions 34 | run: | 35 | # NOTE: put your own distribution build steps here. 36 | python -m pip install uv 37 | uv sync --dev 38 | python .github/workflows/update_version.py ${{github.ref_name}} 39 | uv build 40 | 41 | - name: Upload distributions 42 | uses: actions/upload-artifact@v4 43 | with: 44 | name: release-dists 45 | path: dist/ 46 | 47 | pypi-publish: 48 | runs-on: ubuntu-latest 49 | needs: 50 | - release-build 51 | permissions: 52 | # IMPORTANT: this permission is mandatory for trusted publishing 53 | id-token: write 54 | 55 | # Dedicated environments with protections for publishing are strongly recommended. 56 | # For more information, see: https://docs.github.com/en/actions/deployment/targeting-different-environments/using-environments-for-deployment#deployment-protection-rules 57 | environment: 58 | name: pypi 59 | # OPTIONAL: uncomment and update to include your PyPI project URL in the deployment status: 60 | # url: https://pypi.org/p/YOURPROJECT 61 | # 62 | # ALTERNATIVE: if your GitHub Release name is the PyPI project version string 63 | # ALTERNATIVE: exactly, uncomment the following line instead: 64 | # url: https://pypi.org/project/YOURPROJECT/${{ github.event.release.name }} 65 | 66 | steps: 67 | - name: Retrieve release distributions 68 | uses: actions/download-artifact@v4 69 | with: 70 | name: release-dists 71 | path: dist/ 72 | 73 | - name: Publish release distributions to PyPI 74 | uses: pypa/gh-action-pypi-publish@release/v1 75 | with: 76 | packages-dir: dist/ -------------------------------------------------------------------------------- /docs/htag.svg: -------------------------------------------------------------------------------- 1 | 2 | 18 | 20 | 38 | HTML5 Logo 40 | 45 | 50 | 52 | 53 | 55 | HTML5 Logo 56 | 57 | 58 | 59 | # 70 | 71 | -------------------------------------------------------------------------------- /examples/ace_editor.py: -------------------------------------------------------------------------------- 1 | import os,sys; sys.path.insert(0,os.path.dirname(os.path.dirname(__file__))) 2 | 3 | from htag import Tag 4 | import html 5 | 6 | class Ed(Tag.div): 7 | """ A class which embed the ace editor (python syntax) """ 8 | 9 | statics = [ 10 | Tag.script(_src="https://cdnjs.cloudflare.com/ajax/libs/ace/1.4.14/ace.js"), 11 | Tag.script(_src="https://cdnjs.cloudflare.com/ajax/libs/ace/1.4.14/mode-python.js"), 12 | Tag.script(_src="https://cdnjs.cloudflare.com/ajax/libs/ace/1.4.14/theme-cobalt.js"), 13 | Tag.script(_src="https://cdnjs.cloudflare.com/ajax/libs/ace/1.4.14/ext-searchbox.js"), 14 | Tag.script(_src="https://cdnjs.cloudflare.com/ajax/libs/ace/1.4.14/ext-language_tools.js"), 15 | ] 16 | 17 | def __init__(self,value,width="100%",height="100%",mode="python",onsave=None): 18 | self.value = value 19 | super().__init__(_style="width:%s;height:%s;" % (width,height)) 20 | placeholder="myed%s" % id(self) 21 | 22 | oed= Tag.div(self.value,_style="width:100%;height:100%;min-height:20px;") 23 | self <= oed 24 | self.onsave=onsave 25 | 26 | self.js = """ 27 | tag.ed=ace.edit( "%s" ); 28 | tag.ed.setTheme("ace/theme/cobalt"); 29 | tag.ed.session.setMode("ace/mode/%s"); 30 | tag.ed.session.setUseWrapMode(false); 31 | tag.ed.setOptions({"fontSize": "12pt"}); 32 | tag.ed.setBehavioursEnabled(false); 33 | tag.ed.session.setUseWorker(false); 34 | tag.ed.getSession().setUseSoftTabs(true); 35 | 36 | function commandSave () {%s} 37 | 38 | tag.ed.commands.addCommand({ 39 | name: "commandSave", 40 | bindKey: {"win": "Ctrl-S", "mac": "Command-S"}, 41 | readOnly: "True", 42 | exec: commandSave, 43 | }) 44 | """ % (id(oed),mode, self.bind._save( b"tag.ed.getValue()")) 45 | 46 | def _save(self,value): 47 | self.value = value 48 | if self.onsave: self.onsave(self) 49 | 50 | 51 | class App(Tag.body): 52 | """ Using a component which embed the ace editor """ 53 | statics=[Tag.style(""" 54 | html, body {width:100%;height:100%;margin:0px} 55 | """)] 56 | imports=Ed # IRL, this line is not needed 57 | 58 | def init(self): 59 | self.e1 = Ed("text1", onsave=self.maj) 60 | self.e2 = Ed("text2", onsave=self.maj) 61 | self <= Tag.div( 62 | self.e1+self.e2, 63 | _style="height:100px;display:flex;gap:4px;" 64 | ) 65 | self.txt=Tag.pre("Press CTRL+S in each Editor") 66 | self <= self.txt 67 | 68 | def maj(self,o): 69 | self.txt.clear( f"{html.escape(repr(o))} saved -> '{o.value}'" ) 70 | 71 | 72 | from htag.runners import DevApp as Runner # need starlette+uvicorn !!! 73 | #from htag.runners import BrowserHTTP as Runner 74 | app=Runner(App) 75 | if __name__=="__main__": 76 | app.run() 77 | -------------------------------------------------------------------------------- /old_runners/browsertornadohttp.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # # ############################################################################# 3 | # Copyright (C) 2022 manatlan manatlan[at]gmail(dot)com 4 | # 5 | # MIT licence 6 | # 7 | # https://github.com/manatlan/htag 8 | # ############################################################################# 9 | 10 | from .. import Tag 11 | from ..render import HRenderer 12 | from . import commons 13 | 14 | import os,json,sys,asyncio 15 | 16 | import tornado.ioloop 17 | import tornado.web 18 | 19 | 20 | class BrowserTornadoHTTP: 21 | """ Simple ASync Web Server (with TORNADO) with HTTP interactions with htag. 22 | Open the rendering in a browser tab. 23 | """ 24 | def __init__(self,tagClass:Tag,file:"str|None"=None): 25 | self._hr_session=commons.SessionFile(file) if file else None 26 | assert issubclass(tagClass,Tag) 27 | 28 | self.hrenderer=None 29 | self.tagClass=tagClass 30 | 31 | self._routes=[] 32 | 33 | try: # https://bugs.python.org/issue37373 FIX: tornado/py3.8 on windows 34 | if sys.platform == 'win32': 35 | asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) 36 | except: 37 | pass 38 | 39 | def instanciate(self,url:str): 40 | init = commons.url2ak(url) 41 | if self.hrenderer and self.hrenderer.init == init: 42 | return self.hrenderer 43 | 44 | js = """ 45 | async function interact( o ) { 46 | action( await (await window.fetch("/",{method:"POST", body:JSON.stringify(o)})).text() ) 47 | } 48 | 49 | window.addEventListener('DOMContentLoaded', start ); 50 | """ 51 | return HRenderer(self.tagClass, js, lambda: os._exit(0), init=init,session=self._hr_session) 52 | 53 | 54 | def run(self, host="127.0.0.1", port=8000 , openBrowser=True): # localhost, by default !! 55 | 56 | class MainHandler(tornado.web.RequestHandler): 57 | async def get(this): 58 | self.hrenderer = self.instanciate( str(this.request.uri) ) 59 | this.write( str(self.hrenderer) ) 60 | async def post(this): 61 | data = json.loads( this.request.body.decode() ) 62 | dico = await self.hrenderer.interact(data["id"],data["method"],data["args"],data["kargs"],data.get("event")) 63 | this.write(json.dumps(dico)) 64 | 65 | if openBrowser: 66 | import webbrowser 67 | webbrowser.open_new_tab(f"http://{host}:{port}") 68 | 69 | handlers=[(r"/", MainHandler),] 70 | for path,handler in self._routes: 71 | handlers.append( ( path,handler ) ) 72 | app = tornado.web.Application( handlers ) 73 | app.listen(port) 74 | tornado.ioloop.IOLoop.current().start() 75 | 76 | def add_handler(self, path:str, handler:tornado.web.RequestHandler): 77 | self._routes.append( (path,handler) ) 78 | -------------------------------------------------------------------------------- /test_init_render.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 -u 2 | # -*- coding: utf-8 -*- 3 | from typing import Type 4 | import pytest 5 | 6 | from htag import Tag,HTagException 7 | 8 | anon=lambda t: str(t).replace( str(id(t)),"*" ) 9 | 10 | 11 | ################################################################################################################ 12 | def test_simplified_init(): 13 | class Toto(Tag.div): pass 14 | assert str(Toto()) == '
' 15 | 16 | class Toto(Tag.div): 17 | def init(self,v,**a): 18 | self.add(v) 19 | assert str(Toto("hello")) == '
hello
' 20 | assert str(Toto("hello",_data_text='my')) == '
hello
' 21 | 22 | with pytest.raises(TypeError): # can't auto assign instance attribut with own simplified init() 23 | Toto(js="tag.focus()") 24 | 25 | class Toto(Tag.div): 26 | def init(self,v,vv=42,**a): 27 | self.add(v) 28 | self.add(vv) 29 | assert str(Toto("hello")) == '
hello42
' 30 | assert str(Toto("hello",43)) == '
hello43
' 31 | assert str(Toto("hello",vv=44)) == '
hello44
' 32 | 33 | assert str(Toto("hello",_class='my')) == '
hello42
' 34 | assert str(Toto("hello",_class='my',vv=45)) == '
hello45
' 35 | 36 | def test_own_render(): 37 | class Toto(Tag.div): 38 | def render(self): 39 | self.clear() 40 | self <= "own" 41 | assert str(Toto("hello")) == '
own
' 42 | 43 | class Toto(Tag.div): 44 | def init(self,nb): 45 | self.nb=nb 46 | def render(self): 47 | self.clear() 48 | self <= "*" * self.nb 49 | t=Toto(4) 50 | assert anon(t) == '
****
' 51 | t.nb=8 52 | assert anon(t) == '
********
' 53 | 54 | 55 | 56 | def test_weird_with_real_constructor(): 57 | class Toto(Tag.div): 58 | def __init__(self): 59 | super().__init__() 60 | assert str(Toto()) == '
' 61 | 62 | class Toto(Tag.div): 63 | def __init__(self): 64 | super().__init__(1) 65 | assert str(Toto()) == '
1
' 66 | 67 | class Toto(Tag.div): 68 | def __init__(self): 69 | super().__init__(1,2) 70 | 71 | with pytest.raises(TypeError): 72 | Toto() 73 | 74 | class Toto(Tag.div): 75 | def __init__(self): 76 | super().__init__(1,js="tag.focus()") 77 | 78 | t=Toto() 79 | assert str(t) == '
1
' 80 | assert t.js == "tag.focus()" 81 | 82 | 83 | if __name__=="__main__": 84 | 85 | import logging 86 | logging.basicConfig(format='[%(levelname)-5s] %(name)s: %(message)s',level=logging.DEBUG) 87 | # logging.basicConfig(format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',level=logging.DEBUG) 88 | 89 | test_simplified_init() 90 | test_own_render() 91 | test_weird_with_real_constructor() 92 | -------------------------------------------------------------------------------- /examples/stream.py: -------------------------------------------------------------------------------- 1 | import os,sys; sys.path.insert(0,os.path.dirname(os.path.dirname(__file__))) 2 | 3 | from htag import Tag # the only thing you'll need ;-) 4 | import asyncio 5 | 6 | """ 7 | This example is for "htag powerusers" ... 8 | If you discovering the project, come back here later ;-) 9 | (just keep in mind, that it's possible to output a large amount of data, in a decent way) 10 | 11 | It shows you the "htag way" to create a component which 12 | can output a large amount of data (outputed from an async source) 13 | without rendering all the object at each yield statement ! 14 | (ex: rendering an http paging, or an apache httpd log ...) 15 | 16 | It use a "htag mechanism" which use the yield to add an object in. 17 | this mechanism is named "stream" 18 | 19 | """ 20 | 21 | async def asyncsource(): 22 | """ this is an async source (which simulate delay to get datas) """ 23 | for i in range(3): 24 | yield "line %s" % i 25 | await asyncio.sleep(0.2) # simulate delay from the input source 26 | 27 | 28 | class Viewer(Tag.ul): 29 | """ Object which render itself using a async generator (see self.feed) 30 | (the content is streamed from an async source) 31 | """ 32 | def __init__(self): 33 | super().__init__(_style="border:1px solid red") 34 | 35 | 36 | async def feed(self): 37 | """ async yield object in self (stream)""" 38 | self.clear() 39 | async for i in asyncsource(): 40 | yield Tag.li(i) # <- automatically added to self instance /!\ 41 | 42 | async def feed_bad(self): 43 | """ very similar (visually), but this way IS NOT GOOD !!!! 44 | because it will render ALL THE OUTPUT at each yield !!!!! 45 | """ 46 | self.clear() 47 | async for i in getdata(): 48 | self <= Tag.li(i) # manually add 49 | yield # and force output all ! 50 | 51 | 52 | class Page(Tag.body): 53 | 54 | def init(self): 55 | 56 | self.view = Viewer() 57 | self <= self.view 58 | 59 | # not good result (yield in others space) 60 | self <= Tag.button( "feed1", _onclick= lambda o: self.view.feed() ) # in the button 61 | self <= Tag.button( "feed2", _onclick= self.bind( lambda o: self.view.feed() ) ) # in Page 62 | 63 | # good result (yield in the viewer) 64 | self <= Tag.button( "feed3", _onclick= self.view.bind( self.view.feed ) ) 65 | self <= Tag.button( "feed4", _onclick= self.view.bind.feed() ) 66 | 67 | App=Page 68 | if __name__=="__main__": 69 | # import logging 70 | # logging.basicConfig(format='[%(levelname)-5s] %(name)s: %(message)s',level=logging.DEBUG) 71 | # logging.getLogger("htag.tag").setLevel( logging.INFO ) 72 | 73 | # and execute it in a pywebview instance 74 | from htag.runners import * 75 | 76 | # here is another runner, in a simple browser (thru ajax calls) 77 | BrowserHTTP( Page ).run() 78 | # PyWebWiew( Page ).run() 79 | -------------------------------------------------------------------------------- /htag/runners/pyscript.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # # ############################################################################# 3 | # Copyright (C) 2022 manatlan manatlan[at]gmail(dot)com 4 | # 5 | # MIT licence 6 | # 7 | # https://github.com/manatlan/htag 8 | # ############################################################################# 9 | 10 | from .. import Tag 11 | from ..render import HRenderer 12 | from . import commons 13 | 14 | import json 15 | 16 | class PyScript: 17 | 18 | def __init__(self,tagClass:type): 19 | #TODO: __init__ could accept a 'file' parameter based on localstorage to make persistent session ! 20 | assert issubclass(tagClass,Tag) 21 | self.tagClass=tagClass 22 | 23 | def run(self,window=None): # **DEPRECATED** window: "pyscript js.window" 24 | if window is None: 25 | try: 26 | from js import window # this import should work in pyscript context ;-) 27 | except: 28 | pass 29 | self.window=window 30 | 31 | js = """ 32 | interact=async function(o) { 33 | action( await window.interactions( JSON.stringify(o) ) ); 34 | } 35 | 36 | function pyscript_starter() { 37 | if(document.querySelector("*[needToLoadBeforeStart]")) 38 | window.setTimeout( pyscript_starter, 100 ) 39 | else 40 | window.start() 41 | } 42 | """ 43 | self.hr = HRenderer(self.tagClass, js, init=commons.url2ak( window.document.location.href )) 44 | self.hr.sendactions=self.updateactions 45 | 46 | window.interactions = self.interactions 47 | assert window.document.head, "No in " 48 | assert window.document.body, "No in " 49 | 50 | # install statics in headers 51 | window.document.head.innerHTML="" 52 | for s in self.hr._statics: 53 | if isinstance(s,Tag): 54 | tag=window.document.createElement(s.tag) 55 | tag.innerHTML = "".join([str(i) for i in s.childs if i is not None]) 56 | for key,value in s.attrs.items(): 57 | setattr(tag, key, value) 58 | if key in ["src","href"]: 59 | tag.setAttribute("needToLoadBeforeStart", True) 60 | tag.onload = lambda o: o.target.removeAttribute("needToLoadBeforeStart") 61 | 62 | window.document.head.appendChild(tag) 63 | 64 | # install the first object in body 65 | window.document.body.outerHTML=str(self.hr) 66 | 67 | # and start the process 68 | window.pyscript_starter() # will run window.start, when dom ready 69 | 70 | async def interactions(self, o): 71 | data=json.loads(o) 72 | actions = await self.hr.interact( data["id"], data["method"], data["args"], data["kargs"], data.get("event") ) 73 | return json.dumps(actions) 74 | 75 | async def updateactions(self, actions:dict): 76 | self.window.action( json.dumps(actions) ) # send action as json (not a js(py) object) ;-( 77 | return True 78 | -------------------------------------------------------------------------------- /htag/runners/commons/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # # ############################################################################# 3 | # Copyright (C) 2022 manatlan manatlan[at]gmail(dot)com 4 | # 5 | # MIT licence 6 | # 7 | # https://github.com/manatlan/htag 8 | # ############################################################################# 9 | 10 | import json 11 | import urllib.parse 12 | 13 | def url2ak(url:str): 14 | """ transform the querystring of 'url' to (*args,**kargs)""" 15 | info = urllib.parse.urlsplit(url) 16 | args=[] 17 | kargs={} 18 | if info.query: 19 | items=info.query.split("&") 20 | first=lambda x: x[0] 21 | for i in items: 22 | if i: 23 | if "=" in i: 24 | if i.endswith("="): 25 | kargs[ i.split("=")[0] ] = None 26 | else: 27 | tt=list(urllib.parse.parse_qs(i).items())[0] 28 | kargs[ tt[0] ] = first(tt[1]) 29 | else: 30 | tt=list(urllib.parse.parse_qs("x="+i).items())[0] 31 | args.append( first(tt[1]) ) 32 | return tuple(args), dict(kargs) 33 | 34 | #---------------------------------------------- 35 | import re 36 | def match(mpath,path): 37 | "return a dict of declared vars from mpath if found in path" 38 | mode={ 39 | "str": r"[^/]+", # default 40 | "int": r"\\d+", 41 | "path": r".+", 42 | } 43 | 44 | #TODO: float, uuid ... like https://www.starlette.io/routing/#path-parameters 45 | 46 | patterns=[ 47 | (re.sub( r"{(\w[\w\d_]+)}" , r"(?P<\1>%s)" % mode["str"], mpath), lambda x: x), 48 | (re.sub( r"{(\w[\w\d_]+):str}" , r"(?P<\1>%s)" % mode["str"], mpath), lambda x: x), 49 | (re.sub( r"{(\w[\w\d_]+):int}" , r"(?P<\1>%s)" % mode["int"], mpath), lambda x: int(x)), 50 | (re.sub( r"{(\w[\w\d_]+):path}" , r"(?P<\1>%s)" % mode["path"], mpath), lambda x: x), 51 | ] 52 | 53 | dico={} 54 | for pattern,cast in patterns: 55 | g=re.match(pattern,path) 56 | if g: 57 | dico.update( {k:cast(v) for k,v in g.groupdict().items()} ) 58 | return dico 59 | 60 | #---------------------------------------------- 61 | import json,os 62 | class SessionFile(dict): 63 | def __init__(self,file): 64 | self._file=file 65 | 66 | if os.path.isfile(self._file): 67 | with open(self._file,"r+") as fid: 68 | d=json.load(fid) 69 | else: 70 | d={} 71 | 72 | super().__init__( d ) 73 | 74 | def __delitem__(self,k:str): 75 | super().__delitem__(k) 76 | self._save() 77 | 78 | def __setitem__(self,k:str,v): 79 | super().__setitem__(k,v) 80 | self._save() 81 | 82 | def clear(self): 83 | super().clear() 84 | self._save() 85 | 86 | def _save(self): 87 | if len(self): 88 | with open(self._file,"w+") as fid: 89 | json.dump(dict(self),fid, indent=4) 90 | else: 91 | if os.path.isfile(self._file): 92 | os.unlink(self._file) -------------------------------------------------------------------------------- /test_new_events.py: -------------------------------------------------------------------------------- 1 | from htag import Tag 2 | from htag.tag import NotBindedCaller,Caller 3 | 4 | class T(Tag.div): 5 | pass 6 | 7 | def cb(o): 8 | print('k') 9 | 10 | 11 | def test_old_way_is_ok(): # <=0.7.4 12 | # CURRENT 13 | #======================================== 14 | t=T() 15 | t["onclick"] = t.bind(cb) + "var x=42" 16 | assert isinstance(t["onclick"],Caller) 17 | assert t["onclick"]._befores == [] 18 | assert t["onclick"]._afters == ["var x=42"] 19 | assert len(t["onclick"]._others ) == 0 20 | 21 | # ensure the base is ok 22 | t=T() 23 | t["onclick"] = "var x=42" 24 | assert isinstance(t["onclick"],str) 25 | 26 | t=T() 27 | t["onclick"] = t.bind(cb).bind(cb) + "var x=40" 28 | assert isinstance(t["onclick"],Caller) 29 | assert t["onclick"]._befores == [] 30 | assert t["onclick"]._afters == ["var x=40"] 31 | assert len(t["onclick"]._others ) == 1 32 | 33 | t=T() 34 | t["onclick"] = t.bind(cb) + "var x=40" 35 | assert isinstance(t["onclick"],Caller) 36 | assert t["onclick"]._befores == [] 37 | assert t["onclick"]._afters == ["var x=40"] 38 | assert len(t["onclick"]._others ) == 0 39 | 40 | t=T() 41 | t["onclick"] = "var x=40" + t.bind(cb) 42 | assert isinstance(t["onclick"],Caller) 43 | assert t["onclick"]._befores == ["var x=40"] 44 | assert t["onclick"]._afters == [] 45 | assert len(t["onclick"]._others ) == 0 46 | 47 | def test_new_events(): # >0.7.4 48 | # all "on*" attrs are not None, by default ... 49 | t=T() 50 | assert isinstance(t["onclick"],NotBindedCaller) 51 | 52 | # new syntax 53 | t=T() 54 | t["onclick"]+= "var x=2" 55 | assert isinstance(t["onclick"],NotBindedCaller) 56 | assert t["onclick"]._befores == [] 57 | assert t["onclick"]._afters == ["var x=2"] 58 | 59 | t=T() 60 | t["onclick"] = "var x=2" + t["onclick"] 61 | assert isinstance(t["onclick"],NotBindedCaller) 62 | assert t["onclick"]._befores == ["var x=2"] 63 | assert t["onclick"]._afters == [] 64 | 65 | t=T() 66 | t["onclick"] + "var x=43" # does nothing .. NON SENSE ! 67 | assert isinstance(t["onclick"],NotBindedCaller) 68 | assert t["onclick"]._befores == [] 69 | assert t["onclick"]._afters == [] 70 | 71 | # new syntax (over fucked ?!) (side effect, but works) 72 | t=T() 73 | t["onclick"].bind(cb).bind(cb) + "var x=41" 74 | assert isinstance(t["onclick"],Caller) 75 | assert t["onclick"]._befores == [] 76 | assert t["onclick"]._afters == ["var x=41"] 77 | assert len(t["onclick"]._others ) == 1 78 | 79 | def test_base(): 80 | def test(o): 81 | print("kkk") 82 | 83 | b=Tag.button("hello") 84 | b["onclick"] = test 85 | assert ' onclick="try{interact' in str(b) 86 | 87 | b=Tag.button("hello") 88 | b["onclick"] = b.bind( test ) 89 | assert ' onclick="try{interact' in str(b) 90 | 91 | b=Tag.button("hello") 92 | b["onclick"].bind( test ) 93 | assert ' onclick="try{interact' in str(b) 94 | 95 | if __name__=="__main__": 96 | 97 | import logging 98 | logging.basicConfig(format='[%(levelname)-5s] %(name)s: %(message)s',level=logging.DEBUG) 99 | -------------------------------------------------------------------------------- /brython/example1.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Example 1 5 | 6 | 7 | 10 | 15 | 16 | 17 | 18 | 31 | 32 | 33 | 34 | 35 | 77 | 78 |
79 | 80 | 81 | 82 | 83 | 84 | -------------------------------------------------------------------------------- /examples/todomvc.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import os,sys; sys.path.insert(0,os.path.dirname(os.path.dirname(__file__))) 3 | # see https://metaperl.github.io/pure-python-web-development/todomvc.html 4 | 5 | from htag import Tag 6 | from dataclasses import dataclass 7 | 8 | @dataclass 9 | class Todo: 10 | txt: str 11 | done: bool=False 12 | 13 | class MyTodoListTag(Tag.div): 14 | statics = "label {display:block;cursor:pointer;padding:4px}" 15 | 16 | def init(self): 17 | # init the list 18 | self._list = [] 19 | 20 | # and make a 1st draw 21 | self.redraw() 22 | 23 | def redraw(self): 24 | # clear content 25 | self.clear() 26 | 27 | if self._list: 28 | # if there are todos 29 | 30 | def statechanged(o): 31 | # toggle boolean, using the ref of the instance object 'o' 32 | o.ref.done = not o.ref.done 33 | 34 | # force a redraw (to keep state sync) 35 | self.redraw() 36 | 37 | # we draw the todos with checkboxes 38 | for i in self._list: 39 | self += Tag.label([ 40 | # create a 'ref' attribut on the instance of the input, for the event needs 41 | Tag.input(ref=i,_type="checkbox",_checked=i.done,_onchange=statechanged), 42 | i.txt 43 | ]) 44 | else: 45 | # if no todos 46 | self += Tag.label("nothing to do ;-)") 47 | 48 | def addtodo(self,txt:str): 49 | txt=txt.strip() 50 | if txt: 51 | # if content, add as toto in our ref list 52 | self._list.append( Todo(txt) ) 53 | 54 | # and force a redraw 55 | self.redraw() 56 | 57 | 58 | class App(Tag.body): 59 | statics="body {background:#EEE;}" 60 | 61 | # just to declare that this component will use others components 62 | # (so this one can declare 'statics' from others) 63 | imports=[MyTodoListTag,] # not needed IRL ;-) 64 | 65 | def init(self): 66 | # create an instance of the class 'MyTodoListTag', to manage the list 67 | olist=MyTodoListTag() 68 | 69 | # create a form to be able to add todo, and bind submit event on addtodo method 70 | oform = Tag.form( _onsubmit=olist.bind.addtodo(b"this.q.value") + "return false" ) 71 | oform += Tag.input( _name="q", _type="search", _placeholder="a todo ?") 72 | oform += Tag.Button("add") 73 | 74 | # draw ui 75 | self += Tag.h3("Todo list") + oform + olist 76 | 77 | 78 | 79 | #================================================================================= 80 | # the runner part 81 | #================================================================================= 82 | from htag.runners import BrowserHTTP as Runner 83 | # from htag.runners import DevApp as Runner 84 | # from htag.runners import PyWebView as Runner 85 | # from htag.runners import BrowserStarletteHTTP as Runner 86 | # from htag.runners import BrowserStarletteWS as Runner 87 | # from htag.runners import BrowserTornadoHTTP as Runner 88 | # from htag.runners import AndroidApp as Runner 89 | # from htag.runners import ChromeApp as Runner 90 | # from htag.runners import WinApp as Runner 91 | 92 | app=Runner(App) 93 | if __name__=="__main__": 94 | app.run() 95 | -------------------------------------------------------------------------------- /manual_tests_events.py: -------------------------------------------------------------------------------- 1 | #!./venv/bin/python3 2 | from htag import Tag # the only thing you'll need ;-) 3 | 4 | def nimp(obj): 5 | print("!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!") 6 | 7 | 8 | class MyTag(Tag.span): 9 | def __init__(self,titre,callback): 10 | self.titre = titre 11 | super().__init__( titre + Tag.Button("x",_class="delete",_onclick=callback), _class="tag",_style="margin:4px" ) 12 | 13 | 14 | class Page(Tag.body): 15 | 16 | def init(self): 17 | 18 | def ff(obj): 19 | self <= "ff" 20 | 21 | def ffw(obj,size): 22 | self <= "f%s" %size 23 | 24 | def aeff(obj): 25 | print(obj) 26 | obj <= "b" 27 | 28 | 29 | # EVEN NEW MECHANISM 30 | self <= Tag.button( "TOP", _onclick=self.bind(ffw,b"window.innerWidth") ) 31 | self <= Tag.button( "TOP2", _onclick=self.bind(ffw,"toto") ) 32 | self <= MyTag( "test", aeff ) 33 | self <= Tag.button( "Stream In", _onclick=lambda o: self.stream() ) # stream in current button ! 34 | self <= Tag.button( "Stream Out", _onclick=self.bind( self.stream )) # stream in parent obj 35 | self <= Tag.button( "Stream Out", _onclick=self.bind.stream() ) 36 | 37 | self <= "
" 38 | 39 | # NEW MECHANISM 40 | self <= Tag.button( "externe (look in console)", _onclick=nimp ) 41 | self <= Tag.button( "lambda", _onclick=lambda o: ffw(o,"lambda") ) 42 | #/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\ 43 | #/\ try something new (multiple callbacks as a list) 44 | #/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\ 45 | self <= Tag.button( "**NEW**", _onclick=[ff,self.mm,lambda o: ffw(o,"KIKI")] ) 46 | #/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\ 47 | self <= Tag.button( "ff", _onclick=ff ) 48 | self <= Tag.button( "mm", _onclick=self.mm ) 49 | 50 | self <= Tag.button( "ymm", _onclick=self.ymm ) 51 | self <= Tag.button( "amm", _onclick=self.amm ) 52 | self <= Tag.button( "aymm", _onclick=self.aymm ) 53 | self <= "
" 54 | 55 | # OLD MECHANISM 56 | self <= Tag.button( "mm", _onclick=self.bind.mm("x") ) 57 | self <= Tag.button( "ymm", _onclick=self.bind.ymm("x") ) 58 | self <= Tag.button( "amm", _onclick=self.bind.amm("x") ) 59 | self <= Tag.button( "aymm", _onclick=self.bind.aymm("x") ) 60 | self <= "
" 61 | 62 | def mm(self,obj): 63 | self<="mm" 64 | 65 | def ymm(self,obj): 66 | self<="mm1" 67 | yield 68 | self<="mm2" 69 | 70 | async def amm(self,obj): 71 | self <= "amm" 72 | 73 | async def aymm(self,obj): 74 | self <= "aymm1" 75 | yield 76 | self <= "aymm2" 77 | 78 | def stream(self): 79 | yield "a" 80 | yield "b" 81 | yield ["c","d"] 82 | yield MyTag("kiki", nimp) 83 | 84 | App=Page 85 | from htag.runners import DevApp as Runner 86 | app=Runner( Page ) 87 | if __name__ == "__main__": 88 | import logging 89 | logging.basicConfig(format='[%(levelname)-5s] %(name)s: %(message)s',level=logging.DEBUG) 90 | logging.getLogger("htag.tag").setLevel( logging.INFO ) 91 | app.run() -------------------------------------------------------------------------------- /examples/demo.py: -------------------------------------------------------------------------------- 1 | import os,sys; sys.path.insert(0,os.path.dirname(os.path.dirname(__file__))) 2 | 3 | from htag import Tag # the only thing you'll need ;-) 4 | 5 | 6 | class Button(Tag.button): # this Tag will be rendered as a """ 8 | 9 | # this allow you to include statics in headers 10 | # (it will be included only once !!!) 11 | statics = [Tag.style("button.my {background:yellow; border:1px solid black; border-radius:4px}")] 12 | 13 | def __init__(self,txt, callback): 14 | super().__init__() 15 | 16 | # we set some html attributs 17 | self["class"]="my" # set @class to "my" 18 | self["onclick"]=self.bind.onclick() # bind a js event on @onclick 19 | # "self.bind.()" is the trick to generate a js interaction 20 | # binded to this component 21 | 22 | self <= txt # put a text into the button 23 | # it's a shortcut for "self.add( txt )" 24 | 25 | self.callback=callback # save the py callback for later use 26 | 27 | def onclick(self): 28 | # this is the event called by the @onclick 29 | # it will call the py callback 30 | self.callback() 31 | 32 | class Star(Tag.div): # it's a div tag 33 | """ This Star component display 2 buttons to decrease/increase a value 34 | (it displays nb x star according the value) 35 | """ 36 | 37 | def __init__(self,value=0): 38 | super().__init__() 39 | self.nb=value 40 | 41 | def inc(self,v): 42 | self.nb+=v 43 | 44 | def render(self): 45 | # here, the representation is built lately 46 | # (during the __str__ rendering) 47 | 48 | # self.clear() 49 | # we add our buttons, binded to its py method 50 | self <= Button( "-", lambda: self.inc(-1) ) 51 | self <= Button( "+", lambda: self.inc(1) ) 52 | 53 | # we draw the stars 54 | self <= "⭐"*self.nb 55 | 56 | 57 | class Page(Tag.body): # define a , but the renderer will force it to in all cases 58 | """ This is the main Tag, it will be rendered as by the htag/renderer """ 59 | 60 | def init(self): 61 | 62 | # here is a list of movies ;-) 63 | self.movies=[ 64 | ("BatMan", Star(5)), 65 | ("Thor", Star(9)), 66 | ("Superman", Star(7)), 67 | ] 68 | 69 | def render(self): 70 | # here, the representation is built lately 71 | # (during the __str__ rendering) 72 | 73 | # self.clear() 74 | 75 | # we put a title 76 | self <= Tag.h1("Best movies ;-)") # here is shortcut to create "

Best movies ;-)

" 77 | # (it works for any html tag you want ;-) 78 | 79 | # and add our stuff, sorted by nb of stars 80 | for name,star in sorted( self.movies, key=lambda x: -x[1].nb ): 81 | self <= Tag.div( [name,star] ) 82 | 83 | App=Page 84 | if __name__== "__main__": 85 | # and execute it in a pywebview instance 86 | from htag.runners import PyWebView 87 | PyWebView( Page ).run() 88 | 89 | # here is another runner, in a simple browser (thru ajax calls) 90 | # BrowserHTTP( Page ).run() 91 | -------------------------------------------------------------------------------- /test_constructors.py: -------------------------------------------------------------------------------- 1 | from htag import Tag 2 | import pytest 3 | 4 | 5 | ##################################################################################### 6 | # test base 7 | ##################################################################################### 8 | 9 | def test_basic_creation(): # auto init (property/html_attributes) in all cases 10 | # test base constructor 11 | t=Tag.div("hello",param1=12,_param2=13) 12 | assert t.param1 == 12 13 | assert t["param2"] == 13 14 | 15 | class MyDiv(Tag.div): 16 | pass 17 | 18 | def test_basic_inherited_creation(): # auto init (property/html_attributes) in all cases 19 | # test Tag simple inherits (no constructor) 20 | t=MyDiv("hello",param1=12,_param2=13) 21 | assert t.param1 == 12 22 | assert t["param2"] == 13 23 | 24 | 25 | 26 | 27 | 28 | 29 | ##################################################################################### 30 | # test Tag constructor with real/python __init__() method 31 | ##################################################################################### 32 | 33 | ############################################# 34 | ## with real __init__() constructor 35 | ############################################# 36 | 37 | class MyRDivOpen(Tag.div): # accept auto set property/html_attributes 38 | def __init__(self,content,**a): 39 | Tag.div.__init__(self,content,**a) 40 | 41 | class MyRDivClosed(Tag.div): # DONT't accept auto set property/html_attributes 42 | def __init__(self,content): 43 | Tag.div.__init__(self,content) 44 | 45 | 46 | def test_real_inherited_creation(): 47 | # test Tag inherited with __init__() constructor 48 | t=MyRDivOpen("hi",param1=12,_param2=13) 49 | assert t.param1 == 12 50 | assert t["param2"] == 13 51 | 52 | t=MyRDivOpen(content="hi",param1=12,_param2=13) 53 | assert t.param1 == 12 54 | assert t["param2"] == 13 55 | 56 | with pytest.raises(TypeError): 57 | MyRDivClosed("hi",param1=12) 58 | 59 | with pytest.raises(TypeError): 60 | MyRDivClosed("hi",_param2=13) 61 | 62 | 63 | 64 | 65 | 66 | ##################################################################################### 67 | # test Tag constructor with simplified init() method 68 | ##################################################################################### 69 | 70 | 71 | ############################################# 72 | ## with simplified init() constructor 73 | ############################################# 74 | 75 | class MyDivOpen(Tag.div): # accept auto set property/html_attributes 76 | def init(self,content,**a): # <- **a 77 | self <= content 78 | 79 | class MyDivClosed(Tag.div): # DONT't accept auto set property/html_attributes 80 | def init(self,content): # no **a ! 81 | self <= content 82 | 83 | def test_simplified_inherited_creation(): 84 | 85 | # test Tag inherited with init() constructor 86 | t=MyDivOpen("hi",param1=12,_param2=13) 87 | assert t.param1 == 12 88 | assert t["param2"] == 13 89 | 90 | t=MyDivOpen(content="hi",param1=12,_param2=13) 91 | assert t.param1 == 12 92 | assert t["param2"] == 13 93 | 94 | with pytest.raises(TypeError): 95 | MyDivClosed("hi",param1=12) 96 | 97 | with pytest.raises(TypeError): 98 | MyDivClosed("hi",_param2=13) 99 | 100 | -------------------------------------------------------------------------------- /examples/pyscript_htbulma.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Test htag 7 | 8 | 9 | 10 | 11 | loading... 12 | 77 | 78 | 79 | -------------------------------------------------------------------------------- /manual_tests_qp.py: -------------------------------------------------------------------------------- 1 | #!./venv/bin/python3 2 | # -*- coding: utf-8 -*- 3 | 4 | from htag import Tag 5 | import asyncio,time 6 | 7 | class App(Tag.body): 8 | 9 | def init(self,param="nada"): 10 | self["style"]="background:#EEE;" 11 | self <= Tag.h3("param = "+param) 12 | self <= Tag.a("test '?' ?",_href="?",_style="display:block") 13 | self <= Tag.a("test '?param=A1'",_href="?param=A1",_style="display:block") 14 | self <= Tag.a("test '?param=A2'",_href="?param=A2",_style="display:block") 15 | self <= Tag.button("error", _onclick=lambda o: fdsgdfgfdsgfds()) 16 | self <= Tag.button("add content", _onclick=self.add_content) # just to control interact 17 | self <= Tag.button("EXIT app", _onclick=lambda o: self.exit()) # just to test QUIT/EXIT app 18 | self <= Tag.hr() 19 | 20 | self <= Tag.h3("Only if it handles tha '/other' route (DevApp/htagweb) :") 21 | self <= Tag.a("test '/other'" ,_href="/other",_style="display:block") 22 | self <= Tag.a("test '/other?pablo'",_href="/other?pablo",_style="display:block") 23 | self <= Tag.hr() 24 | 25 | self <= Tag.iframe(_src="/item/42") 26 | 27 | # self.place=Tag.div("this should be updated... no?") 28 | # self <= self.place 29 | # asyncio.ensure_future( self.loop_timer() ) 30 | 31 | async def loop_timer(self): 32 | while 1: 33 | await asyncio.sleep(0.5) 34 | self.place.clear(time.time() ) 35 | if not await self.place.update(): # update component (without interaction) 36 | # break if can't (<- good practice to kill this asyncio/loop) 37 | print("asyncio loop stopped") 38 | break 39 | 40 | 41 | def add_content(self,o): 42 | self <= "X " 43 | 44 | 45 | #================================================================================= 46 | #--------------------------------------------------------------------------------- 47 | # from htag.runners import DevApp as Runner # with .serve() and no QUIT 48 | 49 | # from htag.runners import BrowserHTTP as Runner 50 | # from htag.runners import BrowserStarletteWS as Runner 51 | # from htag.runners import BrowserStarletteHTTP as Runner 52 | # from htag.runners import BrowserTornadoHTTP as Runner 53 | # from htag.runners import ChromeApp as Runner 54 | # from htag.runners import AndroidApp as Runner 55 | # from htag.runners import PyWebView as Runner # just the "add content" will work (no query params / no other route) 56 | 57 | from htag.runners import Runner,HTTPResponse 58 | app=Runner(App,reload=False,debug=True,interface=(400,400),use_first_free_port=True) 59 | 60 | class AnotherApp(Tag.body): 61 | 62 | def init(self, name="vide"): 63 | self["style"]="background:#FFE;" 64 | self <= "Hello "+name 65 | self <= Tag.button("add content", _onclick=self.add_content) 66 | 67 | def add_content(self,o): 68 | self <= "X " 69 | 70 | #note : no path_params/query_params in route path ! 71 | app.add_route( "/other", lambda request: app.handle(request, AnotherApp ) ) 72 | 73 | 74 | async def handlerItem( request ): 75 | idx=request.path_params.get("idx") 76 | txt=request.query_params.get("txt") 77 | return HTTPResponse(200,"Numero %d (txt=%s)" % (idx,txt)) 78 | 79 | app.add_route( "/item/{idx:int}", handlerItem ) 80 | 81 | 82 | if __name__=="__main__": 83 | app.run() 84 | -------------------------------------------------------------------------------- /selenium/app4.py: -------------------------------------------------------------------------------- 1 | #!./venv/bin/python3 2 | import sys,os; sys.path.insert(0,os.path.join( os.path.dirname(__file__),"..")) 3 | import hclient 4 | ################################################################################# 5 | # 6 | from htag import Tag 7 | 8 | STAR = "☆" 9 | 10 | class Stars(Tag.span): # it's a component ;-) 11 | def init(self,name,value=0): 12 | self.name=name 13 | self["class"]=name 14 | self["style"]="display:block" 15 | self.value=value 16 | self.bless = Tag.Button( self.name+"-", _onclick = lambda o: self.inc(-1) ) 17 | self.bmore = Tag.Button( self.name+"+", _onclick = lambda o: self.inc(+1) ) 18 | def inc(self,v): 19 | self.value+=v 20 | def render(self): 21 | self.clear() 22 | self += self.bless + self.bmore + (STAR*self.value) 23 | #------------------------------------------------------------ 24 | 25 | class App(Tag.div): # it's a component ;-) 26 | """ Using reactive component, in a reactive context 27 | """ 28 | 29 | def init(self): 30 | # here we use the previous component "Stars" 31 | # in a a reactive way (with a render method) 32 | self.s1= Stars("a") 33 | self.s2= Stars("b",2) 34 | self.s3= Stars("c",4) 35 | self.reset= Tag.Button( "Reset", _onclick = self.clickreset ) 36 | self.show = Tag.div(_class="show") 37 | self.exiter = Tag.button("exit",_onclick = lambda o: self.exit()) 38 | 39 | 40 | def render(self): # it's present -> it's used 41 | self.clear() 42 | 43 | # so the rendering is managed by htag 44 | self <= self.s1+self.s2+self.s3+self.reset + self.show + self.exiter 45 | 46 | # and so, this div will be updated at reset ! 47 | self.show.clear("Values: %s,%s,%s" % (self.s1.value,self.s2.value,self.s3.value)) 48 | 49 | def clickreset(self,o): 50 | # so, resetting values, will redraw this component (App) automatically 51 | self.s1.value=0 52 | self.s2.value=0 53 | self.s3.value=0 54 | 55 | # 56 | ################################################################################# 57 | 58 | def tests(client:hclient.HClient): 59 | assert "App" in client.title 60 | client.click('//button[text()="a+"]') 61 | client.click('//button[text()="a+"]') 62 | client.click('//button[text()="a+"]') 63 | client.click('//button[text()="a+"]') 64 | client.click('//button[text()="b+"]') 65 | client.click('//button[text()="b+"]') 66 | 67 | values=client.find("//div[@class='show']")[0] 68 | assert values.text == "Values: 4,4,4" 69 | assert client.find("//span[@class='a']")[0].text.count("☆")==4 70 | assert client.find("//span[@class='b']")[0].text.count("☆")==4 71 | assert client.find("//span[@class='c']")[0].text.count("☆")==4 72 | 73 | client.click('//button[text()="Reset"]') 74 | 75 | values=client.find("//div[@class='show']")[0] 76 | assert values.text == "Values: 0,0,0" 77 | assert client.find("//span[@class='a']")[0].text.count("☆")==0 78 | assert client.find("//span[@class='b']")[0].text.count("☆")==0 79 | assert client.find("//span[@class='c']")[0].text.count("☆")==0 80 | 81 | client.click('//button[text()="exit"]') 82 | 83 | return True 84 | 85 | if __name__=="__main__": 86 | # hclient.normalRun(App) 87 | hclient.test( App, "WS", tests) 88 | # hclient.test( App, "HTTP", tests) 89 | # hclient.test( App, "PyScript", tests) #NEED a "uv build" before !!!! 90 | -------------------------------------------------------------------------------- /test_states_guesser.py: -------------------------------------------------------------------------------- 1 | from htag import Tag 2 | from htag.render import Stater 3 | 4 | import pytest 5 | 6 | @pytest.fixture(params=[1,2,3,4,5]) 7 | def env(request): 8 | A=Tag.A("a") 9 | B=Tag.B("b") 10 | C=Tag.C("c") 11 | D=Tag.D("d") 12 | 13 | A <= B <= C + D 14 | assert str(A)== "abcd" 15 | 16 | if request.param == 1: 17 | modmethod= lambda x: x<="mod" 18 | elif request.param == 2: 19 | modmethod= lambda x: x.__setitem__("class","mod") 20 | elif request.param == 3: 21 | modmethod= lambda x: x.clear() 22 | elif request.param == 4: 23 | modmethod= lambda x: x<=Tag.X("x") 24 | elif request.param == 5: 25 | modmethod= lambda x: x.__setitem__("class","mod") or x<="mod" 26 | 27 | return Stater(A),A,B,C,D,modmethod 28 | 29 | 30 | def test_no_mod(env): 31 | s,A,B,C,D,mod = env 32 | 33 | assert s.guess()==[] 34 | 35 | 36 | def test_mod_a_leaf(env): 37 | s,A,B,C,D,mod = env 38 | 39 | mod(C) 40 | assert s.guess()==[C] 41 | 42 | 43 | def test_mod_the_two_leaf(env): 44 | s,A,B,C,D,mod = env 45 | 46 | mod(D) 47 | mod(C) 48 | assert s.guess()==[C,D] 49 | 50 | def test_mod_the_two_leaf_in_other_order(env): 51 | s,A,B,C,D,mod = env 52 | 53 | mod(C) 54 | mod(D) 55 | assert s.guess()==[C,D] 56 | 57 | def test_mod_a_leaf_and_its_parent(env): 58 | s,A,B,C,D,mod = env 59 | 60 | mod(D) 61 | mod(B) 62 | assert s.guess()==[B] # just B (coz B include D) 63 | 64 | 65 | 66 | def test_mod_the_root(env): 67 | s,A,B,C,D,mod = env 68 | 69 | mod(A) 70 | assert s.guess()==[A] 71 | 72 | 73 | ######################### 74 | # same tests ^^but with placeholder in the middle 75 | ######################### 76 | 77 | 78 | @pytest.fixture(params=[1,3,4]) 79 | def env_placeholder(request): 80 | A=Tag.A("a") 81 | B=Tag("b") 82 | C=Tag.C("c") 83 | D=Tag.D("d") 84 | 85 | A <= B <= C + D 86 | assert str(A)== "abcd" 87 | 88 | if request.param == 1: 89 | modmethod= lambda x: x<="mod" 90 | elif request.param == 3: 91 | modmethod= lambda x: x.clear() 92 | elif request.param == 4: 93 | modmethod= lambda x: x<=Tag.X("x") 94 | 95 | return Stater(A),A,B,C,D,modmethod 96 | 97 | 98 | def test_ph_no_mod(env_placeholder): 99 | s,A,B,C,D,mod = env_placeholder 100 | 101 | assert s.guess()==[] 102 | 103 | 104 | def test_ph_mod_a_leaf(env_placeholder): 105 | s,A,B,C,D,mod = env_placeholder 106 | 107 | mod(C) 108 | assert s.guess()==[C] 109 | 110 | 111 | def test_ph_mod_the_two_leaf(env_placeholder): 112 | s,A,B,C,D,mod = env_placeholder 113 | 114 | mod(D) 115 | mod(C) 116 | assert s.guess()==[C,D] 117 | 118 | def test_ph_mod_the_two_leaf_in_other_order(env_placeholder): 119 | s,A,B,C,D,mod = env_placeholder 120 | 121 | mod(C) 122 | mod(D) 123 | assert s.guess()==[C,D] 124 | 125 | def test_ph_mod_a_leaf_and_its_parent(env_placeholder): 126 | s,A,B,C,D,mod = env_placeholder 127 | 128 | mod(D) 129 | mod(B) 130 | # assert s.guess()==[B] # just B (coz B include D) 131 | assert s.guess()==[A] # just B (coz B include D) 132 | 133 | def test_ph_mod_the_root(env_placeholder): 134 | s,A,B,C,D,mod = env_placeholder 135 | 136 | mod(A) 137 | assert s.guess()==[A] 138 | 139 | 140 | if __name__=="__main__": 141 | pytest.main() 142 | -------------------------------------------------------------------------------- /brython/example2.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Example 2 5 | 6 | 7 | 10 | 15 | 16 | 24 | 26 | 27 | 28 | 29 | 89 | 90 | 91 | 92 | -------------------------------------------------------------------------------- /old_runners/browserstarlettews.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # ############################################################################# 3 | # Copyright (C) 2022 manatlan manatlan[at]gmail(dot)com 4 | # 5 | # MIT licence 6 | # 7 | # https://github.com/manatlan/htag 8 | # ############################################################################# 9 | 10 | from .. import Tag 11 | from ..render import HRenderer 12 | from . import commons 13 | 14 | 15 | import os,json 16 | from starlette.applications import Starlette 17 | from starlette.responses import HTMLResponse 18 | from starlette.routing import Route,WebSocketRoute 19 | from starlette.endpoints import WebSocketEndpoint 20 | 21 | import logging 22 | logger = logging.getLogger(__name__) 23 | 24 | 25 | class BrowserStarletteWS(Starlette): 26 | """ Simple ASync Web Server (with starlette) with WebSocket interactions with HTag. 27 | Open the rendering in a browser tab. 28 | 29 | The instance is an ASGI htag app 30 | """ 31 | def __init__(self,tagClass:Tag,file:"str|None"=None): 32 | self._hr_session=commons.SessionFile(file) if file else None 33 | assert issubclass(tagClass,Tag) 34 | self.hrenderer = None 35 | self.tagClass = tagClass 36 | 37 | async def _sendactions(ws, actions:dict) -> bool: 38 | try: 39 | await ws.send_text( json.dumps(actions) ) 40 | return True 41 | except Exception as e: 42 | logger.error("Can't send to socket, error: %s",e) 43 | return False 44 | 45 | 46 | class WsInteract(WebSocketEndpoint): 47 | encoding = "json" 48 | 49 | #========================================================= 50 | async def on_connect(this, websocket): 51 | 52 | # accept cnx 53 | await websocket.accept() 54 | 55 | # declare hr.sendactions (async method) 56 | self.hrenderer.sendactions = lambda actions: _sendactions(websocket,actions) 57 | 58 | #========================================================= 59 | 60 | async def on_receive(this, websocket, data): 61 | actions = await self.hrenderer.interact(data["id"],data["method"],data["args"],data["kargs"],data.get("event")) 62 | await _sendactions( websocket, actions ) 63 | 64 | Starlette.__init__(self,debug=True, routes=[ 65 | Route('/', self.GET, methods=["GET"]), 66 | WebSocketRoute("/ws", WsInteract), 67 | ]) 68 | 69 | 70 | def instanciate(self,url:str): 71 | init = commons.url2ak(url) 72 | if self.hrenderer and self.hrenderer.init == init: 73 | return self.hrenderer 74 | 75 | js = """ 76 | async function interact( o ) { 77 | ws.send( JSON.stringify(o) ); 78 | } 79 | 80 | var ws = new WebSocket("ws://"+document.location.host+"/ws"); 81 | ws.onopen = start; 82 | ws.onmessage = function(e) { 83 | action( e.data ); 84 | }; 85 | """ 86 | return HRenderer(self.tagClass, js, lambda: os._exit(0), init=init,session=self._hr_session) 87 | 88 | async def GET(self,request): 89 | self.hrenderer=self.instanciate( str(request.url) ) 90 | return HTMLResponse( str(self.hrenderer) ) 91 | 92 | def run(self, host="127.0.0.1", port=8000, openBrowser=True): # localhost, by default !! 93 | import uvicorn,webbrowser 94 | if openBrowser: 95 | webbrowser.open_new_tab(f"http://{host}:{port}") 96 | 97 | uvicorn.run(self, host=host, port=port) -------------------------------------------------------------------------------- /brython/README.md: -------------------------------------------------------------------------------- 1 | # HTag for Brython 2 | 3 | [Brython](https://brython.info/) is a marvelous implementation of py3 in javascript, and here is an **htag.Tag** implem for brython. 4 | The goal of this side htag project, is to provide a brython's htag way to create components whose could be compatibles with **htag** and **brython**. 5 | 6 | In fact, it's just It's a class helper, to facilitate the creation of html element. 7 | 8 | In this repo, you will find : 9 | 10 | - [htag.txt](https://github.com/manatlan/htag/blob/main/brython/htag.txt) : the minimal implementation (to use with brython) 11 | - `htagfull.txt` : a more complete implem ... **when it will be ready** ;-) 12 | - somes examples 13 | - [bryted](https://raw.githack.com/manatlan/htag/main/brython/bryted.html) : an online editor to test brython'htag components (made with brython'htag) **PREVERSION** 14 | 15 | 16 | ## Instructions 17 | Put this line in your html file : 18 | ```html 19 | 20 | ``` 21 | In a ` 51 | 52 | 53 | loading pyscript ;-) 54 | 52 | 53 | 54 | 55 | Starting pyscript ;-) 56 | 57 | 68 | 69 | """ 70 | 71 | import inspect,re 72 | import http.server 73 | import socketserver 74 | import webbrowser 75 | 76 | src = re.search(r"#(.+)#", open(inspect.getsourcefile(App)).read(),re.DOTALL) 77 | if src: 78 | src=src.group(1).strip() 79 | else: 80 | print("This app is not pyscript'able (miss '#(.+)#')") 81 | sys.exit(-1) 82 | try: 83 | with open("index.html","w+") as fid: 84 | fid.write(content % src) 85 | 86 | Handler = http.server.SimpleHTTPRequestHandler 87 | try: 88 | with socketserver.TCPServer(("", port), Handler) as httpd: 89 | print("serving at port", port) 90 | if openBrowser: 91 | webbrowser.open_new_tab(f"http://localhost:{port}") 92 | httpd.serve_forever() 93 | except Exception as e: 94 | print("can't start httpd server",e) 95 | sys.exit(-1) 96 | finally: 97 | os.unlink("index.html") 98 | elif runner == "WS": 99 | from htag.runners import Runner 100 | Runner(App,port=port,interface = 1 if openBrowser else 0).run() 101 | elif runner == "HTTP": 102 | from htag.runners import Runner 103 | Runner(App,port=port,interface = 1 if openBrowser else 0,http_only=True).run() 104 | 105 | def test(App,runner:str, tests): 106 | """ for test on a local machine only """ 107 | Process(target=run, args=(App, runner, False)).start() 108 | with webdriver.Chrome() as driver: 109 | driver.get("http://127.0.0.1:8000/") 110 | x=testDriver(driver,tests) 111 | print("-->",x and "OK" or "KO") 112 | 113 | def testDriver(driver,tests): 114 | #=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- # pyscript specific 115 | time.sleep(1) 116 | hc=HClient(driver) 117 | while 1: 118 | bodys=hc.find("//body") 119 | if bodys and ("Starting" not in bodys[0].text): 120 | break 121 | time.sleep(1) 122 | print("Start") 123 | #=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- 124 | return tests( hc ) 125 | 126 | def normalRun(App): 127 | """ just for test/dev in a normal context """ 128 | from htag.runners import Runner 129 | Runner(App,interface = 1,use_first_free_port=True).run() # run in all cases (find a free port automatically) -------------------------------------------------------------------------------- /test_statics.py: -------------------------------------------------------------------------------- 1 | from htag import Tag,HTagException 2 | from htag.render import HRenderer 3 | import pytest 4 | 5 | def test_statics_only_tagbase(): 6 | class AEFF(Tag): 7 | statics="body {background:red}", b"alert(42);" 8 | 9 | h=str(HRenderer(AEFF,"//js")) 10 | 11 | assert "" in h 12 | assert "" in h 13 | 14 | del AEFF 15 | 16 | 17 | def test_built_immediatly(): 18 | ################################################################ 19 | # test static discovering (in built immediatly) 20 | ################################################################ 21 | class O(Tag.div): 22 | statics=Tag.style("/*S1*/") 23 | 24 | assert "/*S1*/" in str(HRenderer( O, "//")) 25 | ################################################################ 26 | class OO1(Tag.div): 27 | imports = O 28 | def init(self): 29 | self <= O() # "O" is a direct child 30 | 31 | assert "/*S1*/" in str(HRenderer( OO1, "//")) 32 | # ################################################################ 33 | class OO2(Tag.div): 34 | imports = O 35 | def init(self): 36 | self <= Tag.div( O() ) # "O" is a non-direct child 37 | 38 | assert "/*S1*/" in str(HRenderer( OO2, "//")) 39 | ################################################################ 40 | 41 | def test_build_lately(): 42 | ################################################################ 43 | # test static discovering (in built lately) 44 | ################################################################ 45 | class O(Tag.div): 46 | statics=Tag.style("/*S1*/") 47 | 48 | assert "/*S1*/" in str(HRenderer( O, "//")) 49 | ################################################################ 50 | class OO1(Tag.div): 51 | imports = O 52 | def render(self): 53 | self.clear() 54 | self <= O() # "O" is a direct child 55 | 56 | assert "/*S1*/" in str(HRenderer( OO1, "//")) 57 | ################################################################ 58 | class OO2(Tag.div): 59 | imports = O 60 | def render(self): 61 | self.clear() 62 | self <= Tag.div( O() ) # "O" is a non-direct child 63 | 64 | assert "/*S1*/" in str(HRenderer( OO2, "//")) 65 | ################################################################ 66 | 67 | # def test_TagBase_md5(): 68 | 69 | # sameContent="hello" 70 | # sameattrs=dict(_class="hello") 71 | # t1=Tag.a(sameContent,**sameattrs) 72 | # t2=Tag.a(sameContent,**sameattrs) 73 | 74 | # assert t1.md5 == t2.md5 75 | 76 | # def test_Tag_md5(): 77 | # class My(Tag.div): 78 | # def __init__(self,txt,**a): 79 | # Tag.div.__init__(self,**a) 80 | # self <= txt 81 | 82 | # sameContent="hello" 83 | # sameattrs=dict(_class="hello") 84 | # t1=My(sameContent,**sameattrs) 85 | # t2=My(sameContent,**sameattrs) 86 | 87 | # #md5 is computed, but not useful 88 | # #(as it's only for tagbase in statics) 89 | # assert t1.md5 != t2.md5 # so, it's different 90 | 91 | def test_doubbles_statics(): 92 | class AppSS(Tag.div): 93 | statics = "kiki","kiki" 94 | imports=[] # just to avoid import all Tag in the scoped process 95 | def init(self,m="default"): 96 | self <= m 97 | self <= Tag.span("world") 98 | 99 | hr1=HRenderer(AppSS,"") 100 | assert len(hr1._statics)==2 # 2 real statics 101 | assert str(hr1).count("")==1 # but just one rendered (coz they are identicals (_hash_)) 102 | 103 | class AppST(Tag.div): 104 | statics = Tag.style("kiki"),Tag.style("kiki") 105 | imports=[] # just to avoid import all Tag in the scoped process 106 | def init(self,m="default"): 107 | self <= m 108 | self <= Tag.span("world") 109 | 110 | hr2=HRenderer(AppST,"") 111 | assert len(hr2._statics)==2 # 2 real statics 112 | assert str(hr2).count("")==1 # but just one rendered (coz they are identicals (_hash_)) 113 | 114 | def test_inherit_bases(): 115 | class A(Tag): 116 | statics = "StylesA" 117 | imports=[] 118 | 119 | class B(A): 120 | statics = "StylesB" 121 | imports=[] 122 | 123 | hr=HRenderer(A,"") 124 | styles=[i for i in hr._statics if i.tag=="style"] 125 | assert len(styles)==1 126 | 127 | hr=HRenderer(B,"") 128 | styles=[i for i in hr._statics if i.tag=="style"] 129 | assert len(styles)==2 130 | 131 | 132 | if __name__=="__main__": 133 | 134 | import logging 135 | logging.basicConfig(format='[%(levelname)-5s] %(name)s: %(message)s',level=logging.DEBUG) 136 | # logging.basicConfig(format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',level=logging.DEBUG) 137 | # test_Tag_md5() 138 | # test_statics_only_tagbase() 139 | test_built_immediatly() -------------------------------------------------------------------------------- /examples/htag_with_state_manager.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import os,sys; sys.path.insert(0,os.path.dirname(os.path.dirname(__file__))) 3 | 4 | """ 5 | For bigger project ... it's a good practice to start 6 | with an unique "source of truth" (all data in one place). 7 | 8 | Here is a example 9 | 10 | Principes: 11 | - You make a private dict in your htag component -> THE store 12 | (all data will be read/write from here) 13 | - In your component, you only have read access on this store. 14 | - and ONLY yours (inter-)actions can mutate this store 15 | 16 | See pinia/vuex for vuejs, redux for react, or ngrx for angular, etc ... 17 | """ 18 | 19 | from htag import Tag 20 | from dataclasses import dataclass 21 | 22 | # the DB simulation part 23 | #....................................................................... 24 | @dataclass 25 | class Product: 26 | name: str 27 | price: int 28 | 29 | PRODUCTS={ 30 | "ref1":Product("Peach",10), 31 | "ref4":Product("Apple",2), 32 | "ref5":Product("Pear",3), 33 | "ref7":Product("Banana",3), 34 | } 35 | 36 | # a class to provide only readacess to your store (dict) 37 | #....................................................................... 38 | class Store: 39 | def __init__(self, store:dict ): 40 | self.__store = store 41 | def __getitem__(self,k): 42 | return self.__store.get(k) 43 | 44 | 45 | # the components : 46 | #....................................................................... 47 | class PageList(Tag.div): 48 | def init(self): 49 | self <= Tag.h1("Products") 50 | for ref,p in PRODUCTS.items(): 51 | d=Tag.div(_style="border:1px dotted black;display:inline-block;width:100px;height:100px") 52 | d<=Tag.h3(p.name) 53 | d<=Tag.button("View", value=ref, _onclick = lambda o: self.root.action('SELECT',selected=o.value) ) 54 | d<=Tag.button("Add", value=ref,_onclick = lambda o: self.root.action('ADD',selected=o.value) ) 55 | self <= d 56 | 57 | class PageProduct(Tag.div): 58 | def init(self,ref): 59 | p = PRODUCTS[ref] 60 | 61 | b=Tag.button("back", _onclick = lambda o: self.root.action('LISTE') ) 62 | self <= Tag.h1(b+f"Products > {p.name}") 63 | self <= Tag.h3(f"Price: {p.price}€") 64 | self <= Tag.button("Add", _onclick = lambda o: self.root.action('ADD',selected=ref) ) 65 | 66 | class Basket(Tag.div): 67 | 68 | def render(self): # dynamic rendering (so it can react on store changes) 69 | liste = self.root.store["baskets"] 70 | 71 | self.clear() 72 | if liste: 73 | somme=0 74 | for ref in liste: 75 | p = PRODUCTS[ref] 76 | self <= Tag.li( f"{p.name}: {p.price}€" ) 77 | somme+=p.price 78 | self <= Tag.b(f"Total: {somme}€") 79 | self <= Tag.button("clear", _onclick = lambda o: self.root.action('CLEAR') ) 80 | else: 81 | self <= "vide" 82 | 83 | # and your main tag (which will be runned in a runner) 84 | #....................................................................... 85 | 86 | class App(Tag.body): 87 | def init(self): 88 | # the private store 89 | self.__store = {"baskets": []} 90 | 91 | # the public store (read only) 92 | self.store = Store( self.__store ) 93 | 94 | # prepare layout 95 | self.main = Tag() # placeholder ! 96 | 97 | # draw layout 98 | self <= self.main + Tag.div(Basket(),_style="position:fixed;top:0px;right:0px;background:yellow") 99 | 100 | # 1st action 101 | self.action("LISTE") 102 | 103 | def action(self, action, **params): 104 | """ here are the mutations for your actions 105 | The best practice : the store is mutated only in this place ! 106 | """ 107 | if action == "LISTE": 108 | self.main.clear(PageList() ) 109 | elif action == "SELECT": 110 | self.main.clear(PageProduct( params["selected"] ) ) 111 | elif action == "ADD": 112 | self.__store["baskets"].append( params["selected"] ) 113 | elif action == "CLEAR": 114 | self.__store["baskets"] = [] 115 | 116 | print("NEW STORE:",self.__store) 117 | 118 | # the runner part 119 | #....................................................................... 120 | from htag.runners import DevApp as Runner 121 | # from htag.runners import BrowserHTTP as Runner 122 | # from htag.runners import ChromeApp as Runner 123 | 124 | 125 | app=Runner(App) 126 | if __name__=="__main__": 127 | import logging 128 | logging.basicConfig(format='[%(levelname)-5s] %(name)s: %(message)s',level=logging.DEBUG) 129 | 130 | logging.getLogger("htag.tag").setLevel( logging.ERROR ) 131 | logging.getLogger("htag.render").setLevel( logging.ERROR ) 132 | logging.getLogger("uvicorn.error").setLevel( logging.ERROR ) 133 | logging.getLogger("asyncio").setLevel( logging.ERROR ) 134 | app.run() 135 | -------------------------------------------------------------------------------- /_pyscript_dev.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 19 | 20 | 21 | loading (recent/2024) pyscript ;-) 22 | 23 | 24 | 130 | 131 | 132 | -------------------------------------------------------------------------------- /test_dom.py: -------------------------------------------------------------------------------- 1 | from htag import Tag,HTagException 2 | import pytest 3 | 4 | def test_base(): 5 | t=Tag.div() 6 | assert t.parent == None 7 | assert t.root == t 8 | 9 | 10 | def test_adding_None(): 11 | parent = Tag.div() 12 | parent.add(None) # does nothing ! 13 | parent <= None # does nothing ! 14 | parent += None # does nothing ! 15 | parent += [None,None] # does nothing ! 16 | assert len(parent.childs)==0 17 | 18 | def test_try_to_override_important_props(): 19 | t = Tag.div() 20 | with pytest.raises( AttributeError ): 21 | t.parent=None 22 | 23 | with pytest.raises( AttributeError ): 24 | t.root=None 25 | 26 | with pytest.raises( AttributeError ): 27 | t.event=None 28 | 29 | 30 | def test_unparenting_remove(): 31 | parent = Tag.div() 32 | child = Tag.span() 33 | childchild = Tag.b() 34 | 35 | parent <= child <= childchild 36 | assert str(parent)=="
" 37 | assert child.root == childchild.root == parent.root == parent 38 | 39 | assert child.parent == parent 40 | assert childchild.parent == child 41 | assert parent.childs[0] == child 42 | 43 | child.remove() 44 | assert str(parent)=="
" 45 | 46 | assert len(parent.childs) == 0 47 | assert child.parent == None 48 | assert childchild.parent == child 49 | 50 | assert parent.root == parent 51 | assert child.root == child 52 | assert childchild.root == child 53 | 54 | def test_unparenting_clear(): 55 | parent = Tag.div() 56 | child = Tag.span() 57 | childchild = Tag.b() 58 | 59 | parent <= child <= childchild 60 | assert str(parent)=="
" 61 | assert child.root == childchild.root == parent.root == parent 62 | 63 | parent.clear() 64 | assert str(parent)=="
" 65 | 66 | assert len(parent.childs) == 0 67 | assert child.parent == None 68 | assert childchild.parent == child 69 | 70 | assert parent.root == parent 71 | assert child.root == child 72 | assert childchild.root == child 73 | 74 | 75 | def test_cant_add_many_times(): 76 | parent1 = Tag.div() 77 | parent2 = Tag.div() 78 | 79 | parent1.STRICT_MODE=True 80 | parent2.STRICT_MODE=True 81 | 82 | 83 | a_child = Tag.span() 84 | parent1 += a_child 85 | 86 | # can't be added to another one 87 | with pytest.raises(HTagException): 88 | parent2 += a_child 89 | 90 | assert a_child.parent == parent1 91 | 92 | # clear parent1 93 | parent1.clear() 94 | 95 | # so the child is no more in parent1 96 | # we can add it to parent2 97 | parent2 += a_child 98 | assert a_child.parent == parent2 99 | 100 | ##################################################################### 101 | ##################################################################### 102 | ##################################################################### 103 | def t0(): 104 | parent = Tag.div() 105 | a_child = Tag.span() 106 | 107 | parent.add( a_child, True) # force reparent 108 | parent.add( a_child, True) # force reparent 109 | 110 | def t00(): 111 | parent = Tag.div() 112 | a_child = Tag.span() 113 | 114 | parent.add( a_child, False) # don't force reparent (default) 115 | parent.add( a_child, False) # don't force reparent (default) 116 | 117 | def t1(): 118 | parent = Tag.div() 119 | a_child = Tag.span() 120 | 121 | parent += a_child 122 | parent += a_child # raise 123 | 124 | def t2(): 125 | parent = Tag.div() 126 | a_child = Tag.span() 127 | 128 | parent += [a_child,a_child] # raise 129 | 130 | def t3(): 131 | parent = Tag.div() 132 | a_child = Tag.span() 133 | 134 | parent += Tag.div(a_child) 135 | parent += Tag.div(a_child) # raise 136 | 137 | 138 | def t4(): 139 | parent = Tag.div() 140 | a_child = Tag.span() 141 | 142 | parent <= Tag.div() <= a_child 143 | parent <= Tag.div() <= a_child # raise 144 | 145 | def t5(): 146 | parent = Tag.div() 147 | a_child = Tag.span() 148 | 149 | parent.childs.append( a_child ) # since 'childs' is a tuple -> AttributeError 150 | parent.childs.append( a_child ) 151 | 152 | def test_strictmode_off(): 153 | old=Tag.STRICT_MODE 154 | try: 155 | Tag.STRICT_MODE=False 156 | 157 | t0() 158 | t00() 159 | 160 | t1() 161 | t2() 162 | t3() 163 | t4() 164 | 165 | with pytest.raises(AttributeError): # AttributeError: 'tuple' object has no attribute 'append' 166 | t5() 167 | finally: 168 | Tag.STRICT_MODE=old 169 | 170 | def test_strictmode_on(): 171 | old=Tag.STRICT_MODE 172 | try: 173 | Tag.STRICT_MODE=True 174 | 175 | t0() 176 | 177 | with pytest.raises(HTagException): 178 | t00() 179 | 180 | with pytest.raises(HTagException): 181 | t1() 182 | 183 | with pytest.raises(HTagException): 184 | t2() 185 | 186 | with pytest.raises(HTagException): 187 | t3() 188 | 189 | with pytest.raises(HTagException): 190 | t4() 191 | 192 | with pytest.raises(AttributeError): # AttributeError: 'tuple' object has no attribute 'append' 193 | t5() 194 | 195 | finally: 196 | Tag.STRICT_MODE=old 197 | 198 | 199 | 200 | if __name__=="__main__": 201 | # test_unparenting_clear() 202 | t0() -------------------------------------------------------------------------------- /htag/__main__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # ############################################################################# 3 | # Copyright (C) 2022 manatlan manatlan[at]gmail(dot)com 4 | # 5 | # MIT licence 6 | # 7 | # https://github.com/manatlan/htag 8 | # ############################################################################# 9 | 10 | import os,sys 11 | 12 | code = """# -*- coding: utf-8 -*- 13 | # /// script 14 | # requires-python = ">=3.8" 15 | # dependencies = ["htag"] 16 | # /// 17 | 18 | from htag import Tag 19 | 20 | class App(Tag.body): 21 | statics="body {background:#EEE;}" 22 | 23 | def init(self): 24 | self <= "Hello World" 25 | self <= Tag.button("Say hi", _onclick=self.sayhi) 26 | 27 | def sayhi(self,ev): 28 | self <= "hi!" 29 | 30 | 31 | #================================================================================= 32 | from htag.runners import Runner 33 | 34 | if __name__ == "__main__": 35 | Runner(App).run() 36 | """ 37 | 38 | import argparse 39 | 40 | class BooleanOptionalAction(argparse.Action): 41 | """ only here for compatibility with py < 3.9 """ 42 | def __init__(self, option_strings, dest, default=None, required=False, help=None, **kwargs): 43 | # Créer les options avec et sans 'no' 44 | _option_strings = [] 45 | for option_string in option_strings: 46 | _option_strings.append(option_string) 47 | if option_string.startswith('--'): 48 | _option_strings.append(option_string.replace('--', '--no-')) 49 | 50 | super(BooleanOptionalAction, self).__init__( 51 | option_strings=_option_strings, 52 | dest=dest, 53 | nargs=0, 54 | const=None, 55 | default=default, 56 | required=required, 57 | help=help, 58 | **kwargs 59 | ) 60 | 61 | def __call__(self, parser, namespace, values, option_string=None): 62 | if option_string.startswith('--no-'): 63 | setattr(namespace, self.dest, False) 64 | else: 65 | setattr(namespace, self.dest, True) 66 | 67 | def format_help(self): 68 | return f"{', '.join(self.option_strings)}: {self.help} (default: {self.default})" 69 | 70 | 71 | def command(): 72 | parser = argparse.ArgumentParser( 73 | prog="htag", 74 | description="""Entrypoint to help you or a htag'app. 75 | If a [file] is given: it will try to run it (using dev mode), 76 | else it will create an empty htag file named 'main.py' in current path. Options are just here for the run mode. 77 | """, 78 | ) 79 | parser.add_argument('file', nargs='?', help="if present, the htag'file will be runned (in dev mode)") 80 | parser.add_argument('--host', help='Host listener [default: 127.0.0.1]', default="127.0.0.1") 81 | parser.add_argument('--port', help='Port number [default: 8000]', default="8000") 82 | parser.add_argument('--gui', help="Automatically open interface in a browser [default]",action=BooleanOptionalAction, default=True) 83 | parser.add_argument('--dev', help="Run in dev mode (reload+debug) [default]",action=BooleanOptionalAction, default=True) 84 | args = parser.parse_args() 85 | if args.file: 86 | ########################################################################## 87 | ## run mode 88 | ########################################################################## 89 | htagfile=os.path.realpath(args.file) 90 | try: 91 | assert os.path.isfile(htagfile), f"file '{htagfile}' not found" 92 | from htag.runners import Runner 93 | import importlib.util 94 | module_name=os.path.basename(htagfile)[:-3] 95 | spec = importlib.util.spec_from_file_location(module_name, htagfile) 96 | module = importlib.util.module_from_spec(spec) 97 | sys.modules[module_name] = module 98 | spec.loader.exec_module(module) 99 | 100 | if hasattr(module,"app"): 101 | app=getattr(module,"app") 102 | if isinstance(app,Runner): 103 | print("Found 'app' (new Runner), will run it") 104 | print(app,"Serving") 105 | # run part (like defined in file) 106 | app.run() 107 | sys.exit(0) 108 | 109 | if hasattr(module,"App"): 110 | print("Found 'App' (tag class), will run it") 111 | tagClass=getattr(module,"App") 112 | 113 | # run part (here FULL DEV, and open a tab/browser) 114 | app=Runner(tagClass,reload=args.dev,debug=args.dev,host=args.host,port=args.port, interface=1 if args.gui else 0) 115 | print(app,"Serving") 116 | app.run() 117 | else: 118 | print("ERROR",htagfile,"doesn't contain 'App' (tag class)") 119 | except Exception as e: 120 | print("ERROR",e) 121 | else: 122 | ########################################################################## 123 | ## create mode 124 | ########################################################################## 125 | newfile = "main.py" 126 | 127 | if not os.path.isfile(newfile): 128 | with open(newfile,"w+") as fid: 129 | fid.write(code) 130 | 131 | print("HTag App file created -->", newfile) 132 | else: 133 | print(f"It seems that you've already got a '{newfile}' file") 134 | 135 | if __name__=="__main__": 136 | command() 137 | -------------------------------------------------------------------------------- /htag/runners/chromeappmode.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # # ############################################################################# 3 | # Copyright (C) 2022 manatlan manatlan[at]gmail(dot)com 4 | # 5 | # MIT licence 6 | # 7 | # https://github.com/manatlan/htag 8 | # ############################################################################# 9 | 10 | 11 | from .. import Tag 12 | from ..render import HRenderer 13 | from . import commons 14 | 15 | import os 16 | 17 | #="="="="="="="="="="="="="="="="="="="="="="="="="="="="="="="="="="="="="="=" 18 | # mainly code from the good old guy ;-) 19 | #="="="="="="="="="="="="="="="="="="="="="="="="="="="="="="="="="="="="="="=" 20 | import sys 21 | import shutil 22 | import tempfile 23 | import subprocess 24 | import logging 25 | import webbrowser 26 | 27 | logger = logging.getLogger(__name__) 28 | 29 | class FULLSCREEN: pass 30 | CHROMECACHE=".cache" 31 | 32 | def find_chrome_win(): 33 | import winreg 34 | 35 | reg_path = r"SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\chrome.exe" 36 | for install_type in winreg.HKEY_CURRENT_USER, winreg.HKEY_LOCAL_MACHINE: 37 | try: 38 | with winreg.OpenKey(install_type, reg_path, 0, winreg.KEY_READ) as reg_key: 39 | return winreg.QueryValue(reg_key, None) 40 | except WindowsError: 41 | pass 42 | 43 | def find_chrome_mac(): 44 | default_dir = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" 45 | if os.path.exists(default_dir): 46 | return default_dir 47 | 48 | class ChromeApp: 49 | def __init__(self, url, appname="driver",size=None,lockPort=None,chromeargs=[]): 50 | self._p=None 51 | 52 | if sys.platform[:3] == "win": 53 | exe = find_chrome_win() 54 | elif sys.platform == "darwin": 55 | exe = find_chrome_mac() 56 | else: 57 | for i in ["chromium-browser", "chromium", "google-chrome", "chrome"]: 58 | try: 59 | exe = webbrowser.get(i).name 60 | break 61 | except webbrowser.Error: 62 | exe = None 63 | 64 | if not exe: 65 | raise Exception("no chrome browser, no app-mode !") 66 | else: 67 | args = [ #https://peter.sh/experiments/chromium-command-line-switches/ 68 | exe, 69 | "--app=" + url, # need to be a real http page ! 70 | "--app-id=%s" % (appname), 71 | "--app-auto-launched", 72 | "--no-first-run", 73 | "--no-default-browser-check", 74 | "--disable-notifications", 75 | "--disable-features=TranslateUI", 76 | "--autoplay-policy=no-user-gesture-required", 77 | #~ "--no-proxy-server", 78 | ] + chromeargs 79 | if size: 80 | if size == FULLSCREEN: 81 | args.append("--start-fullscreen") 82 | else: 83 | args.append( "--window-size=%s,%s" % (size[0],size[1]) ) 84 | 85 | if lockPort: #enable reusable cache folder (coz only one instance can be runned) 86 | self.cacheFolderToRemove=None 87 | args.append("--remote-debugging-port=%s" % lockPort) 88 | args.append("--disk-cache-dir=%s" % CHROMECACHE) 89 | args.append("--user-data-dir=%s/%s" % (CHROMECACHE,appname)) 90 | else: 91 | self.cacheFolderToRemove=os.path.join(tempfile.gettempdir(),appname+"_"+str(os.getpid())) 92 | args.append("--user-data-dir=" + self.cacheFolderToRemove) 93 | args.append("--aggressive-cache-discard") 94 | args.append("--disable-cache") 95 | args.append("--disable-application-cache") 96 | args.append("--disable-offline-load-stale-cache") 97 | args.append("--disk-cache-size=0") 98 | 99 | logger.debug("CHROME APP-MODE: %s"," ".join(args)) 100 | # self._p = subprocess.Popen(args) 101 | self._p = subprocess.Popen(args,stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) 102 | 103 | #~ if lockPort: 104 | #~ http_client = tornado.httpclient.HTTPClient() 105 | #~ self._ws = None 106 | #~ while self._ws == None: 107 | #~ try: 108 | #~ url = http_client.fetch("http://localhost:%s/json" % debugport).body 109 | #~ self._ws = json.loads(url)[0]["webSocketDebuggerUrl"] 110 | #~ except Exception as e: 111 | #~ self._ws = None 112 | 113 | def wait(self,thread): 114 | if self._p: 115 | self._p.wait() 116 | 117 | def __del__(self): # really important ! 118 | if self._p: 119 | self._p.kill() 120 | if self.cacheFolderToRemove: shutil.rmtree(self.cacheFolderToRemove, ignore_errors=True) 121 | 122 | #~ def _com(self, payload: dict): 123 | #~ """ https://chromedevtools.github.io/devtools-protocol/tot/Browser/#method-close """ 124 | #~ payload["id"] = 1 125 | #~ r=json.loads(wsquery(self._ws,json.dumps(payload)))["result"] 126 | #~ return r or True 127 | 128 | #~ def focus(self): # not used 129 | #~ return self._com(dict(method="Page.bringToFront")) 130 | 131 | #~ def navigate(self, url): # not used 132 | #~ return self._com(dict(method="Page.navigate", params={"url": url})) 133 | 134 | def exit(self): 135 | #~ self._com(dict(method="Browser.close")) 136 | if self._p: 137 | self._p.kill() 138 | #="="="="="="="="="="="="="="="="="="="="="="="="="="="="="="="="="="="="="="=" 139 | --------------------------------------------------------------------------------