From 3f804cae51cfc4841059e9c23f006147725a5f76 Mon Sep 17 00:00:00 2001 From: Ryan Morshead Date: Fri, 16 Jun 2023 11:47:59 -0600 Subject: [PATCH 1/6] Update pull_request_template.md --- .github/pull_request_template.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index d889c598a..cf95abff3 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,4 +1,4 @@ -*By submitting this pull request you agree that all contributions to this project are made under the MIT license.* +By submitting this pull request you agree that all contributions to this project are made under the MIT license. ## Issues From 754a6198b04f28e126da8c55c3a03169c1cb29ec Mon Sep 17 00:00:00 2001 From: Ryan Morshead Date: Sun, 2 Jul 2023 16:15:14 -0600 Subject: [PATCH 2/6] minor improvements to project setup (#1082) * minor improvements to project setup * install docs + fix ruff errors * fix lint * fixes first --- .gitignore | 1 + .pre-commit-config.yaml | 15 ++++----- docs/pyproject.toml | 2 +- docs/source/_exts/reactpy_example.py | 4 +-- docs/source/_exts/reactpy_view.py | 6 ++-- docs/source/about/contributor-guide.rst | 22 +++++++++++++ src/py/reactpy/reactpy/core/events.py | 2 +- src/py/reactpy/reactpy/core/layout.py | 6 ++-- tasks.py | 41 ++++++++++++++++++++----- 9 files changed, 74 insertions(+), 25 deletions(-) diff --git a/.gitignore b/.gitignore index 20c041e11..946bff43f 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ .jupyter # --- Python --- +.hatch .venv venv MANIFEST diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e4a66f532..ae748a41d 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -3,15 +3,9 @@ repos: hooks: - id: lint-py-fix name: Fix Python Lint - entry: hatch run lint-py --fix - language: system - pass_filenames: false - - repo: local - hooks: - - id: lint-py-check - name: Check Python Lint entry: hatch run lint-py language: system + args: [--fix] pass_filenames: false - repo: local hooks: @@ -20,6 +14,13 @@ repos: entry: hatch run lint-js --fix language: system pass_filenames: false + - repo: local + hooks: + - id: lint-py-check + name: Check Python Lint + entry: hatch run lint-py + language: system + pass_filenames: false - repo: local hooks: - id: lint-js-check diff --git a/docs/pyproject.toml b/docs/pyproject.toml index d2f47c577..f47b0e944 100644 --- a/docs/pyproject.toml +++ b/docs/pyproject.toml @@ -1,5 +1,5 @@ [tool.poetry] -name = "docs" +name = "docs_app" version = "0.0.0" description = "docs" authors = ["rmorshea "] diff --git a/docs/source/_exts/reactpy_example.py b/docs/source/_exts/reactpy_example.py index c6b054c07..1171d32e0 100644 --- a/docs/source/_exts/reactpy_example.py +++ b/docs/source/_exts/reactpy_example.py @@ -2,7 +2,7 @@ import re from pathlib import Path -from typing import Any +from typing import Any, ClassVar from docs_app.examples import ( SOURCE_DIR, @@ -21,7 +21,7 @@ class WidgetExample(SphinxDirective): required_arguments = 1 _next_id = 0 - option_spec = { + option_spec: ClassVar[dict[str, Any]] = { "result-is-default-tab": directives.flag, "activate-button": directives.flag, } diff --git a/docs/source/_exts/reactpy_view.py b/docs/source/_exts/reactpy_view.py index 7a2bf85a4..6a583998f 100644 --- a/docs/source/_exts/reactpy_view.py +++ b/docs/source/_exts/reactpy_view.py @@ -1,7 +1,5 @@ import os -import sys - -print(sys.path) +from typing import Any, ClassVar from docs_app.examples import get_normalized_example_name from docutils.nodes import raw @@ -20,7 +18,7 @@ class IteractiveWidget(SphinxDirective): required_arguments = 1 _next_id = 0 - option_spec = { + option_spec: ClassVar[dict[str, Any]] = { "activate-button": directives.flag, "margin": float, } diff --git a/docs/source/about/contributor-guide.rst b/docs/source/about/contributor-guide.rst index b44be9b7e..f9fb93154 100644 --- a/docs/source/about/contributor-guide.rst +++ b/docs/source/about/contributor-guide.rst @@ -118,6 +118,26 @@ Then, you should be able to activate your development environment with: hatch shell +From within the shell, to install the projects in this repository, you should then run: + +.. code-block:: bash + + invoke env + +Project Structure +----------------- + +This repository is set up to be able to manage many applications and libraries written +in a variety of languages. All projects can be found under the ``src`` directory: + +- ``src/py/{project}`` - Python packages +- ``src/js/app`` - ReactPy's built-in JS client +- ``src/js/packages/{project}`` - JS packages + +At the root of the repository is a ``pyproject.toml`` file that contains scripts and +their respective dependencies for managing all other projects. Most of these global +scripts can be run via ``hatch run ...`` however, for more complex scripting tasks, we +rely on Invoke_. Scripts implements with Invoke can be found in ``tasks.py``. Running The Tests ----------------- @@ -308,6 +328,8 @@ you should refer to their respective documentation in the links below: .. Links .. ===== +.. _Hatch: https://hatch.pypa.io/ +.. _Invoke: https://www.pyinvoke.org/ .. _Google Chrome: https://www.google.com/chrome/ .. _Docker: https://docs.docker.com/get-docker/ .. _Git: https://git-scm.com/book/en/v2/Getting-Started-Installing-Git diff --git a/src/py/reactpy/reactpy/core/events.py b/src/py/reactpy/reactpy/core/events.py index acc2077b2..cd5de3228 100644 --- a/src/py/reactpy/reactpy/core/events.py +++ b/src/py/reactpy/reactpy/core/events.py @@ -21,7 +21,7 @@ def event( @overload def event( - function: Literal[None] = None, + function: Literal[None] = ..., *, stop_propagation: bool = ..., prevent_default: bool = ..., diff --git a/src/py/reactpy/reactpy/core/layout.py b/src/py/reactpy/reactpy/core/layout.py index 7c24e5ef7..df24a9a0a 100644 --- a/src/py/reactpy/reactpy/core/layout.py +++ b/src/py/reactpy/reactpy/core/layout.py @@ -37,16 +37,16 @@ class Layout: """Responsible for "rendering" components. That is, turning them into VDOM.""" - __slots__ = [ + __slots__: tuple[str, ...] = ( "root", "_event_handlers", "_rendering_queue", "_root_life_cycle_state_id", "_model_states_by_life_cycle_state_id", - ] + ) if not hasattr(abc.ABC, "__weakref__"): # nocov - __slots__.append("__weakref__") + __slots__ += ("__weakref__",) def __init__(self, root: ComponentType) -> None: super().__init__() diff --git a/tasks.py b/tasks.py index 4bbfe52e2..1fcd3c0a3 100644 --- a/tasks.py +++ b/tasks.py @@ -77,14 +77,21 @@ def env(context: Context): @task def env_py(context: Context): """Install Python development environment""" - for py_proj in PY_PROJECTS: - py_proj_toml = toml.load(py_proj / "pyproject.toml") - hatch_default_env = py_proj_toml["tool"]["hatch"]["envs"].get("default", {}) - hatch_default_features = hatch_default_env.get("features", []) - hatch_default_deps = hatch_default_env.get("dependencies", []) + for py_proj in [ + DOCS_DIR, + # Docs installs non-editable versions of packages - ensure + # we overwrite that by installing projects afterwards. + *PY_PROJECTS, + ]: + py_proj_toml_tools = toml.load(py_proj / "pyproject.toml")["tool"] + if "hatch" in py_proj_toml_tools: + install_func = install_hatch_project + elif "poetry" in py_proj_toml_tools: + install_func = install_poetry_project + else: + raise Exit(f"Unknown project type: {py_proj}") with context.cd(py_proj): - context.run(f"pip install '.[{','.join(hatch_default_features)}]'") - context.run(f"pip install {' '.join(map(repr, hatch_default_deps))}") + install_func(context, py_proj) @task @@ -103,6 +110,7 @@ def lint_py(context: Context, fix: bool = False): """Run linters and type checkers""" if fix: context.run("ruff --fix .") + context.run("black .") else: context.run("ruff .") context.run("black --check --diff .") @@ -417,3 +425,22 @@ def publish(dry_run: bool): ) return publish + + +def install_hatch_project(context: Context, path: Path) -> None: + py_proj_toml = toml.load(path / "pyproject.toml") + hatch_default_env = py_proj_toml["tool"]["hatch"]["envs"].get("default", {}) + hatch_default_features = hatch_default_env.get("features", []) + hatch_default_deps = hatch_default_env.get("dependencies", []) + context.run(f"pip install -e '.[{','.join(hatch_default_features)}]'") + context.run(f"pip install {' '.join(map(repr, hatch_default_deps))}") + + +def install_poetry_project(context: Context, path: Path) -> None: + # install dependencies from poetry into the current environment - not in Poetry's venv + poetry_lock = toml.load(path / "poetry.lock") + packages_to_install = [ + f"{package['name']}=={package['version']}" for package in poetry_lock["package"] + ] + context.run("pip install -e .") + context.run(f"pip install {' '.join(packages_to_install)}") From f065655ae1fc8f93a0ca05769be19e304f607dfa Mon Sep 17 00:00:00 2001 From: Ryan Morshead Date: Sun, 2 Jul 2023 16:31:49 -0600 Subject: [PATCH 3/6] Fix publish (#1064) * use env instead of env_dict * check mypy on tasks --- pyproject.toml | 13 ++++++++++++- tasks.py | 18 +++++++++++++++--- 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index a4899a495..27e3a937d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,6 +18,9 @@ dependencies = [ "flake8", "flake8-pyproject", "reactpy-flake8 >=0.7", + # types + "mypy", + "types-toml", # publish "semver >=2, <3", "twine", @@ -42,7 +45,15 @@ test-docs = "invoke test-docs" target-version = ["py39"] line-length = 88 -# --- Flake8 ---------------------------------------------------------------------------- +# --- MyPy ----------------------------------------------------------------------------- + +[tool.mypy] +ignore_missing_imports = true +warn_unused_configs = true +warn_redundant_casts = true +warn_unused_ignores = true + +# --- Flake8 --------------------------------------------------------------------------- [tool.flake8] select = ["RPY"] # only need to check with reactpy-flake8 diff --git a/tasks.py b/tasks.py index 1fcd3c0a3..65f75b208 100644 --- a/tasks.py +++ b/tasks.py @@ -15,6 +15,7 @@ from invoke import task from invoke.context import Context from invoke.exceptions import Exit +from invoke.runners import Result # --- Typing Preamble ------------------------------------------------------------------ @@ -286,7 +287,9 @@ def get_packages(context: Context) -> dict[str, PackageInfo]: def make_py_pkg_info(context: Context, pkg_dir: Path) -> PackageInfo: with context.cd(pkg_dir): - proj_metadata = json.loads(context.run("hatch project metadata").stdout) + proj_metadata = json.loads( + ensure_result(context, "hatch project metadata").stdout + ) return PackageInfo( name=proj_metadata["name"], path=pkg_dir, @@ -329,7 +332,9 @@ def get_current_tags(context: Context) -> set[str]: line for line in map( str.strip, - context.run("git tag --points-at HEAD", hide=True).stdout.splitlines(), + ensure_result( + context, "git tag --points-at HEAD", hide=True + ).stdout.splitlines(), ) if line } @@ -418,7 +423,7 @@ def publish(dry_run: bool): context.run( "twine upload dist/*", - env_dict={ + env={ "TWINE_USERNAME": twine_username, "TWINE_PASSWORD": twine_password, }, @@ -444,3 +449,10 @@ def install_poetry_project(context: Context, path: Path) -> None: ] context.run("pip install -e .") context.run(f"pip install {' '.join(packages_to_install)}") + + +def ensure_result(context: Context, *args: Any, **kwargs: Any) -> Result: + result = context.run(*args, **kwargs) + if result is None: + raise Exit("Command failed") + return result From e82ffdfaa0a9eb3e30ac062dd3e9136e29b53c81 Mon Sep 17 00:00:00 2001 From: Ryan Morshead Date: Mon, 3 Jul 2023 21:10:31 -0600 Subject: [PATCH 4/6] Fix issue from #1081 (#1085) * identify issue from #1081 * fix the bug * update doc * make ruff happy * add changelog entry --- docs/source/about/changelog.rst | 1 + src/py/reactpy/pyproject.toml | 1 + src/py/reactpy/reactpy/core/layout.py | 2 +- src/py/reactpy/reactpy/utils.py | 2 +- src/py/reactpy/tests/test_core/test_layout.py | 60 ++++++++++ src/py/reactpy/tests/tooling/layout.py | 44 +++++++ src/py/reactpy/tests/tooling/select.py | 107 ++++++++++++++++++ 7 files changed, 215 insertions(+), 2 deletions(-) create mode 100644 src/py/reactpy/tests/tooling/layout.py create mode 100644 src/py/reactpy/tests/tooling/select.py diff --git a/docs/source/about/changelog.rst b/docs/source/about/changelog.rst index a6eff8f73..a927f0fcf 100644 --- a/docs/source/about/changelog.rst +++ b/docs/source/about/changelog.rst @@ -31,6 +31,7 @@ Unreleased - :issue:`930` - better traceback for JSON serialization errors (via :pull:`1008`) - :issue:`437` - explain that JS component attributes must be JSON (via :pull:`1008`) +- :issue:`1086` - fix rendering bug when children change positions (via :pull:`1085`) v1.0.0 diff --git a/src/py/reactpy/pyproject.toml b/src/py/reactpy/pyproject.toml index 659ddbf94..87fa7e036 100644 --- a/src/py/reactpy/pyproject.toml +++ b/src/py/reactpy/pyproject.toml @@ -139,6 +139,7 @@ testpaths = "tests" xfail_strict = true python_files = "*asserts.py test_*.py" asyncio_mode = "auto" +log_cli_level = "INFO" # --- MyPy ----------------------------------------------------------------------------- diff --git a/src/py/reactpy/reactpy/core/layout.py b/src/py/reactpy/reactpy/core/layout.py index df24a9a0a..f84cb104e 100644 --- a/src/py/reactpy/reactpy/core/layout.py +++ b/src/py/reactpy/reactpy/core/layout.py @@ -489,7 +489,7 @@ def _update_component_model_state( index=new_index, key=old_model_state.key, model=Ref(), # does not copy the model - patch_path=old_model_state.patch_path, + patch_path=f"{new_parent.patch_path}/children/{new_index}", children_by_key={}, targets_by_event={}, life_cycle_state=( diff --git a/src/py/reactpy/reactpy/utils.py b/src/py/reactpy/reactpy/utils.py index e5e06d98d..5624846a4 100644 --- a/src/py/reactpy/reactpy/utils.py +++ b/src/py/reactpy/reactpy/utils.py @@ -27,7 +27,7 @@ class Ref(Generic[_RefValue]): You can compare the contents for two ``Ref`` objects using the ``==`` operator. """ - __slots__ = "current" + __slots__ = ("current",) def __init__(self, initial_value: _RefValue = _UNDEFINED) -> None: if initial_value is not _UNDEFINED: diff --git a/src/py/reactpy/tests/test_core/test_layout.py b/src/py/reactpy/tests/test_core/test_layout.py index d2e1a8099..215e89137 100644 --- a/src/py/reactpy/tests/test_core/test_layout.py +++ b/src/py/reactpy/tests/test_core/test_layout.py @@ -13,6 +13,7 @@ from reactpy.core.component import component from reactpy.core.hooks import use_effect, use_state from reactpy.core.layout import Layout +from reactpy.core.types import State from reactpy.testing import ( HookCatcher, StaticEventHandler, @@ -20,8 +21,11 @@ capture_reactpy_logs, ) from reactpy.utils import Ref +from tests.tooling import select from tests.tooling.common import event_message, update_message from tests.tooling.hooks import use_force_render, use_toggle +from tests.tooling.layout import layout_runner +from tests.tooling.select import element_exists, find_element @pytest.fixture(autouse=True) @@ -1190,3 +1194,59 @@ def Child(): done, pending = await asyncio.wait([render_task], timeout=0.1) assert not done and pending render_task.cancel() + + +async def test_ensure_model_path_udpates(): + """ + This is regression test for a bug in which we failed to update the path of a bug + that arose when the "path" of a component within the overall model was not updated + when the component changes position amongst its siblings. This meant that when + a component whose position had changed would attempt to update the view at its old + position. + """ + + @component + def Item(item: str, all_items: State[list[str]]): + color = use_state(None) + + def deleteme(event): + all_items.set_value([i for i in all_items.value if (i != item)]) + + def colorize(event): + color.set_value("blue" if not color.value else None) + + return html.div( + {"id": item, "color": color.value}, + html.button({"on_click": colorize}, f"Color {item}"), + html.button({"on_click": deleteme}, f"Delete {item}"), + ) + + @component + def App(): + items = use_state(["A", "B", "C"]) + return html._([Item(item, items, key=item) for item in items.value]) + + async with layout_runner(reactpy.Layout(App())) as runner: + tree = await runner.render() + + # Delete item B + b, b_info = find_element(tree, select.id_equals("B")) + assert b_info.path == (0, 1, 0) + b_delete, _ = find_element(b, select.text_equals("Delete B")) + await runner.trigger(b_delete, "on_click", {}) + + tree = await runner.render() + + # Set color of item C + assert not element_exists(tree, select.id_equals("B")) + c, c_info = find_element(tree, select.id_equals("C")) + assert c_info.path == (0, 1, 0) + c_color, _ = find_element(c, select.text_equals("Color C")) + await runner.trigger(c_color, "on_click", {}) + + tree = await runner.render() + + # Ensure position and color of item C are correct + c, c_info = find_element(tree, select.id_equals("C")) + assert c_info.path == (0, 1, 0) + assert c["attributes"]["color"] == "blue" diff --git a/src/py/reactpy/tests/tooling/layout.py b/src/py/reactpy/tests/tooling/layout.py new file mode 100644 index 000000000..fe78684fe --- /dev/null +++ b/src/py/reactpy/tests/tooling/layout.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +import logging +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from typing import Any + +from jsonpointer import set_pointer + +from reactpy.core.layout import Layout +from reactpy.core.types import VdomJson +from tests.tooling.common import event_message + +logger = logging.getLogger(__name__) + + +@asynccontextmanager +async def layout_runner(layout: Layout) -> AsyncIterator[LayoutRunner]: + async with layout: + yield LayoutRunner(layout) + + +class LayoutRunner: + def __init__(self, layout: Layout) -> None: + self.layout = layout + self.model = {} + + async def render(self) -> VdomJson: + update = await self.layout.render() + logger.info(f"Rendering element at {update['path'] or '/'!r}") + if not update["path"]: + self.model = update["model"] + else: + self.model = set_pointer( + self.model, update["path"], update["model"], inplace=False + ) + return self.model + + async def trigger(self, element: VdomJson, event_name: str, *data: Any) -> None: + event_handler = element.get("eventHandlers", {}).get(event_name, {}) + logger.info(f"Triggering {event_name!r} with target {event_handler['target']}") + if not event_handler: + raise ValueError(f"Element has no event handler for {event_name}") + await self.layout.deliver(event_message(event_handler["target"], *data)) diff --git a/src/py/reactpy/tests/tooling/select.py b/src/py/reactpy/tests/tooling/select.py new file mode 100644 index 000000000..cf7a9c004 --- /dev/null +++ b/src/py/reactpy/tests/tooling/select.py @@ -0,0 +1,107 @@ +from __future__ import annotations + +from collections.abc import Iterator, Sequence +from dataclasses import dataclass +from typing import Callable + +from reactpy.core.types import VdomJson + +Selector = Callable[[VdomJson, "ElementInfo"], bool] + + +def id_equals(id: str) -> Selector: + return lambda element, _: element.get("attributes", {}).get("id") == id + + +def class_equals(class_name: str) -> Selector: + return ( + lambda element, _: class_name + in element.get("attributes", {}).get("class", "").split() + ) + + +def text_equals(text: str) -> Selector: + return lambda element, _: _element_text(element) == text + + +def _element_text(element: VdomJson) -> str: + if isinstance(element, str): + return element + return "".join(_element_text(child) for child in element.get("children", [])) + + +def element_exists(element: VdomJson, selector: Selector) -> bool: + return next(find_elements(element, selector), None) is not None + + +def find_element( + element: VdomJson, + selector: Selector, + *, + first: bool = False, +) -> tuple[VdomJson, ElementInfo]: + """Find an element by a selector. + + Parameters: + element: + The tree to search. + selector: + A function that returns True if the element matches. + first: + If True, return the first element found. If False, raise an error if + multiple elements are found. + + Returns: + Element info, or None if not found. + """ + find_iter = find_elements(element, selector) + found = next(find_iter, None) + if found is None: + raise ValueError("Element not found") + if not first: + try: + next(find_iter) + raise ValueError("Multiple elements found") + except StopIteration: + pass + return found + + +def find_elements( + element: VdomJson, selector: Selector +) -> Iterator[tuple[VdomJson, ElementInfo]]: + """Find an element by a selector. + + Parameters: + element: + The tree to search. + selector: + A function that returns True if the element matches. + + Returns: + Element info, or None if not found. + """ + return _find_elements(element, selector, (), ()) + + +def _find_elements( + element: VdomJson, + selector: Selector, + parents: Sequence[VdomJson], + path: Sequence[int], +) -> tuple[VdomJson, ElementInfo] | None: + info = ElementInfo(parents, path) + if selector(element, info): + yield element, info + + for index, child in enumerate(element.get("children", [])): + if isinstance(child, dict): + yield from _find_elements( + child, selector, (*parents, element), (*path, index) + ) + + +@dataclass +class ElementInfo: + parents: Sequence[VdomJson] + path: Sequence[int] From 77303a38fe4dfcca1a5fb68261379a21460b6f64 Mon Sep 17 00:00:00 2001 From: Mark Bakhit <16909269+Archmonger@users.noreply.github.com> Date: Mon, 3 Jul 2023 20:27:52 -0700 Subject: [PATCH 5/6] `django-reactpy` -> `reactpy-django` (#1080) Co-authored-by: Ryan Morshead --- docs/source/about/contributor-guide.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/source/about/contributor-guide.rst b/docs/source/about/contributor-guide.rst index f9fb93154..73ae3f23d 100644 --- a/docs/source/about/contributor-guide.rst +++ b/docs/source/about/contributor-guide.rst @@ -322,7 +322,7 @@ you should refer to their respective documentation in the links below: Jupyter - `reactpy-dash `__ - ReactPy integration for Plotly Dash -- `django-reactpy `__ - ReactPy integration for +- `reactpy-django `__ - ReactPy integration for Django .. Links From 5582431ca63f944f561b90d6ca965abf1e9fa424 Mon Sep 17 00:00:00 2001 From: Ryan Morshead Date: Mon, 3 Jul 2023 23:28:13 -0600 Subject: [PATCH 6/6] reactpy-v1.0.2 (#1087) --- src/py/reactpy/reactpy/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/py/reactpy/reactpy/__init__.py b/src/py/reactpy/reactpy/__init__.py index 4fb4e8d09..63a8550cc 100644 --- a/src/py/reactpy/reactpy/__init__.py +++ b/src/py/reactpy/reactpy/__init__.py @@ -21,7 +21,7 @@ from reactpy.utils import Ref, html_to_vdom, vdom_to_html __author__ = "The Reactive Python Team" -__version__ = "1.0.1" # DO NOT MODIFY +__version__ = "1.0.2" # DO NOT MODIFY __all__ = [ "backend", pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy