diff --git a/htmltools/tags.py b/htmltools/tags.py index d2292d3..40469ca 100644 --- a/htmltools/tags.py +++ b/htmltools/tags.py @@ -5,29 +5,46 @@ from pathlib import Path from copy import deepcopy from urllib.parse import quote +import webbrowser from typing import Optional, Union, List, Dict, Callable, Any from .util import * from packaging import version package_version = version.parse Version = version.Version -AttrsType = Union[str, bool, None] - -# -------------------------------------------------------- -# tag_list() is essentially a tag() without attributes -# -------------------------------------------------------- class tag_list(): + ''' + Create a list (i.e., fragment) of HTML content + + Methods: + -------- + show: Render and preview as HTML. + save_html: Save the HTML to a file. + append: Add content _after_ any existing children. + prepend: Add content before_ any existing children. + get_html_string: Get a string of representation of the HTML object + (html_dependency()s are not included in this representation). + get_dependencies: Obtains any html_dependency()s attached to this tag list + or any of its children. + + Attributes: + ----------- + children: A list of child tags. + + Examples: + --------- + >>> print(tag_list(h1('Hello htmltools'), tags.p('for python'))) + ''' def __init__(self, *args: Any) -> None: self.children: List[Any] = [] if args: - self.append_children(*args) + self.append(*args) - # TODO: rename to append() and maybe adopt other list-like methods? - def append_children(self, *args: Any) -> None: + def append(self, *args: Any) -> None: if args: self.children += flatten(args) - def prepend_children(self, *args: Any) -> None: + def prepend(self, *args: Any) -> None: if args: self.children = flatten(args) + self.children @@ -77,19 +94,18 @@ def show(self, renderer: str = "auto") -> Any: # TODO: can we get htmlDependencies working in IPython? if renderer == "ipython": - from IPython.core.display import display as idisplay - from IPython.core.display import HTML as ihtml - return idisplay(ihtml(self.get_html_string())) + from IPython.core.display import display_html + return display_html(self.get_html_string(), raw=True) if renderer == "browser": - tmpdir = tempfile.mkdtemp() - file = os.path.join(tmpdir, "index.html") + tmpdir = tempfile.gettempdir() + key_ = "viewhtml" + str(hash(self.get_html_string())) + dir = os.path.join(tmpdir, key_) + Path(dir).mkdir(parents=True, exist_ok=True) + file = os.path.join(dir, "index.html") self.save_html(file) - port = get_open_port() - # TODO: don't open a new thread/port every time - http_server_bg(port, tmpdir) - import webbrowser - webbrowser.open("http://localhost:" + str(port)) + port = ensure_http_server(tmpdir) + webbrowser.open(f"http://localhost:{port}/{key_}/index.html") return file raise Exception(f"Unknown renderer {renderer}") @@ -104,31 +120,43 @@ def __bool__(self) -> bool: return len(self.children) > 0 def __repr__(self) -> str: - x = '<' + getattr(self, "name", "tag_list") - attrs = self.get_attrs() - n_attrs = len(attrs) - if attrs.get('id'): - x += '#' + attrs['id'] - n_attrs -= 1 - if attrs.get('class'): - x += '.' + attrs['class'].replace(' ', '.') - n_attrs -= 1 - x += ' with ' - if n_attrs > 0: - x += f'{n_attrs} other attributes and ' - n = len(self.children) - x += '1 child>' if n == 1 else f'{n} children>' - return x + return tag_repr_impl("tag_list", {}, self.children) -# -------------------------------------------------------- -# Core tag logic -# -------------------------------------------------------- class tag(tag_list): - def __init__(self, _name: str, *arguments: Any, children: Optional[Any] = None, **kwargs: AttrsType) -> None: + ''' + Create an HTML tag. + + Methods: + -------- + show: Render and preview as HTML. + save_html: Save the HTML to a file. + append: Add children (or attributes) _after_ any existing children (or attributes). + prepend: Add children (or attributes) _before_ any existing children (or attributes). + get_attrs: Get a dictionary of attributes. + get_attr: Get the value of an attribute. + has_attr: Check if an attribute is present. + has_class: Check if the class attribte contains a particular class. + get_html_string: Get a string of representation of the HTML object + (html_dependency()s are not included in this representation). + get_dependencies: Obtains any html_dependency()s attached to this tag list + or any of its children. + + Attributes: + ----------- + name: The name of the tag + children: A list of children + + Examples: + --------- + >>> print(div(h1('Hello htmltools'), tags.p('for python'), _class_ = 'mydiv')) + >>> print(tag("MyJSXComponent")) + ''' + + def __init__(self, _name: str, *arguments: Any, children: Optional[Any] = None, **kwargs: str) -> None: super().__init__(*arguments, children) self.name: str = _name - self.attrs: List[Dict[str, str]] = [] - self.append_attrs(**kwargs) + self._attrs: List[Dict[str, str]] = [] + self.append(**kwargs) # If 1st letter of tag is capital, then it, as well as it's children, are treated as JSX if _name[:1] == _name[:1].upper(): @@ -142,34 +170,34 @@ def flag_as_jsx(x: Any): # http://dev.w3.org/html5/spec/single-page.html#void-elements self._is_void = getattr(self, "_is_jsx", False) or _name in ["area", "base", "br", "col", "command", "embed", "hr", "img", "input", "keygen", "link", "meta", "param", "source", "track", "wbr"] - def __call__(self, *args: Any, **kwargs: AttrsType) -> 'tag': - self.append_attrs(**kwargs) - self.append_children(*args) - return self - - def append_attrs(self, **kwargs: AttrsType) -> None: - if not kwargs: - return - # e.g., _foo_bar_ -> foo-bar - def encode_key(x: str) -> str: - if x.startswith('_') and x.endswith('_'): - x = x[1:-1] - return x.replace("_", "-") - # TODO: actually require a str value - self.attrs.append({encode_key(k): v for k, v in kwargs.items()}) + def __call__(self, *args: Any, **kwargs: str) -> 'tag': + self.append(*args, **kwargs) + return self - def get_attr(self, key: str) -> Optional[str]: - return self.get_attrs().get(key) + def append(self, *args: Any, **kwargs: str) -> None: + if args: + super().append(*args) + if kwargs: + self._attrs.append({encode_attr(k): v for k, v in kwargs.items()}) + + def prepend(self, *args: Any, **kwargs: str) -> None: + if args: + super().prepend(*args) + if kwargs: + self._attrs.insert(0, {encode_attr(k): v for k, v in kwargs.items()}) def get_attrs(self) -> Dict[str, str]: attrs: Dict[str, str] = {} - for x in self.attrs: + for x in self._attrs: for key, val in x.items(): if val is None: continue attrs[key] = (attrs.get(key) + " " + val) if key in attrs else val return attrs + def get_attr(self, key: str) -> Optional[str]: + return self.get_attrs().get(key) + def has_attr(self, key: str) -> bool: return key in self.get_attrs() @@ -214,17 +242,28 @@ def get_html_string(self, indent: int = 0, eol: str = '\n') -> 'html': def __bool__(self) -> bool: return True + def __repr__(self) -> str: + return tag_repr_impl(self.name, self.get_attrs(), self.children) + # -------------------------------------------------------- # tag factory # -------------------------------------------------------- def tag_factory_(_name: str) -> Callable[[Any], 'tag']: - def __init__(self: tag, *args: Any, children: Optional[Any] = None, **kwargs: AttrsType) -> None: + def __init__(self: tag, *args: Any, children: Optional[Any] = None, **kwargs: str) -> None: tag.__init__(self, _name, *args, children = children, **kwargs) return __init__ # TODO: attribute verification? -def tag_factory(_name: str) -> Any: +def tag_factory(_name: str) -> tag: + ''' + Programmatically create a tag class. + + Examples: + --------- + >>> MyTag = tag_factory("MyTag") + >>> MyTag(h1("Hello")) + ''' return type(_name, (tag,), {'__init__': tag_factory_(_name)}) # Generate a class for each known tag @@ -241,16 +280,19 @@ def __init__(self) -> None: tags = create_tags() - -# -------------------------------------------------------- -# html documents -# -------------------------------------------------------- class html_document(tag): - def __init__(self, body: tag_list, head: Optional[tag_list]=None, **kwargs: AttrsType): + ''' + Create an HTML document. + + Examples: + --------- + >>> print(html_document(h1("Hello"), tags.meta(name="description", content="test"), lang = "en")) + ''' + def __init__(self, body: tag_list, head: Optional[tag_list]=None, **kwargs: str): super().__init__("html", **kwargs) head = head.children if isinstance(head, tag) and head.name == "head" else head body = body.children if isinstance(body, tag) and body.name == "body" else body - self.append_children( + self.append( tag("head", head), tag("body", body) ) @@ -264,7 +306,7 @@ def save_html(self, file: str, libdir: str = "lib") -> str: for d in deps: d = d.copy_to(libdir, False) d = d.make_relative(dir, False) - dep_tags.append_children(d.as_tags()) + dep_tags.append(d.as_tags()) head = tag( "head", tag("meta", charset="utf-8"), dep_tags, @@ -282,6 +324,14 @@ def save_html(self, file: str, libdir: str = "lib") -> str: # html strings # -------------------------------------------------------- class html(str): + ''' + Mark a string as raw HTML. + + Example: + ------- + >>> print(div("
Hello
")) + >>> print(div(html("Hello
"))) + ''' def __new__(cls, *args: str) -> 'html': return super().__new__(cls, '\n'.join(args)) @@ -299,6 +349,14 @@ def __add__(self, other: Union[str, 'html']) -> str: # html dependencies # -------------------------------------------------------- class html_dependency(): + ''' + Create an HTML dependency. + + Example: + ------- + >>> x = div("foo", html_dependency(name = "bar", version = "1.0", src = ".", script = "lib/bar.js")) + >>> x.get_dependencies() + ''' def __init__(self, name: str, version: Union[str, Version], src: Union[str, Dict[str, str]], script: Optional[Union[str, List[str], List[Dict[str, str]]]] = None, @@ -315,12 +373,11 @@ def __init__(self, name: str, version: Union[str, Version], for i, s in enumerate(self.stylesheet): if "rel" not in s: self.stylesheet[i].update({"rel": "stylesheet"}) self.package = package - # TODO: implement shiny::createWebDependency() self.all_files = all_files self.meta = meta if meta else [] self.head = head - # TODO: do we really need hrefFilter? Seems rmarkdown was the only one that needed it + # I don't think we need hrefFilter (seems rmarkdown was the only one that needed it)? # https://github.com/search?l=r&q=%22hrefFilter%22+user%3Acran+language%3AR&ref=searchresults&type=Code&utf8=%E2%9C%93 def as_tags(self, src_type: str = "file", encode_path: Callable[[str], str] = quote) -> html: src = self.src[src_type] @@ -420,6 +477,28 @@ def __eq__(self, other: Any) -> bool: return equals_impl(self, other) +# e.g., _foo_bar_ -> foo-bar +def encode_attr(x: str) -> str: + if x.startswith('_') and x.endswith('_'): + x = x[1:-1] + return x.replace("_", "-") + +def tag_repr_impl(name, attrs, children) -> str: + x = '<' + name + n_attrs = len(attrs) + if attrs.get('id'): + x += '#' + attrs['id'] + n_attrs -= 1 + if attrs.get('class'): + x += '.' + attrs['class'].replace(' ', '.') + n_attrs -= 1 + x += ' with ' + if n_attrs > 0: + x += f'{n_attrs} other attributes and ' + n = len(children) + x += '1 child>' if n == 1 else f'{n} children>' + return x + def normalize_text(txt: Any, is_jsx: bool = False) -> str: txt_ = str(txt) if isinstance(txt, jsx): diff --git a/htmltools/util.py b/htmltools/util.py index fbbdef0..6288731 100644 --- a/htmltools/util.py +++ b/htmltools/util.py @@ -3,9 +3,9 @@ import importlib import tempfile from typing import List, Tuple, Union, Any -from contextlib import contextmanager +from contextlib import contextmanager, closing from http.server import SimpleHTTPRequestHandler -import socket +from socket import socket from socketserver import TCPServer from threading import Thread @@ -66,28 +66,37 @@ def package_dir(package: str) -> str: pkg_file = importlib.import_module('.', package = package).__file__ return os.path.dirname(pkg_file) -# TODO: should be done with a try/finally? -def get_open_port(): - s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - s.bind(("",0)) - s.listen(1) - port = s.getsockname()[1] - s.close() - return port -def http_server(port: int, directory: str = None): +_http_servers = {} +def ensure_http_server(path: str): + server = _http_servers.get(path) + if server: + return server._port + + _http_servers[path] = start_http_server(path) + return _http_servers[path]._port + +def start_http_server(path: str): + port = get_open_port() + th = Thread(target=http_server, args=(port, path), daemon=True) + th.start() + th._port = port + return th + +def http_server(port: int, path: str): class Handler(SimpleHTTPRequestHandler): def __init__(self, *args, **kwargs): - super().__init__(*args, directory=directory, **kwargs) + super().__init__(*args, directory=path, **kwargs) def log_message(self, format, *args): pass with TCPServer(("", port), Handler) as httpd: httpd.serve_forever() -def http_server_bg(port: int, directory: str=None): - th = Thread(target=http_server, args=(port, directory), daemon=True) - th.start() +def get_open_port(): + with closing(socket()) as sock: + sock.bind(("", 0)) + return sock.getsockname()[1] @contextmanager def cwd(path: str): diff --git a/intro.ipynb b/intro.ipynb index 21e4596..db33216 100644 --- a/intro.ipynb +++ b/intro.ipynb @@ -47,6 +47,13 @@ ], "metadata": {} }, + { + "cell_type": "code", + "execution_count": null, + "source": [], + "outputs": [], + "metadata": {} + }, { "cell_type": "markdown", "source": [ @@ -144,8 +151,8 @@ "execution_count": 6, "source": [ "x = div()\n", - "x.append_attrs(id = \"foo\", className = \"bar\")\n", - "x.append_children(\n", + "x.append(id = \"foo\", className = \"bar\")\n", + "x.append(\n", " h3(\"Hello htmltools!\"),\n", " p(html(f\"The {tags.i('Python')} version\"))\n", ")\n", @@ -174,7 +181,7 @@ }, { "cell_type": "code", - "execution_count": 7, + "execution_count": 3, "source": [ "x.show()" ], @@ -182,13 +189,9 @@ { "output_type": "display_data", "data": { - "text/plain": [ - "