diff --git a/docs/tutorials/echo.rst b/docs/tutorials/echo.rst index 0e1a068b..5ed83f98 100644 --- a/docs/tutorials/echo.rst +++ b/docs/tutorials/echo.rst @@ -12,7 +12,7 @@ configuration file. Prerequisites ------------- -Asphalt requires Python 3.9 or later. You will also need to have the ``venv`` module +Asphalt requires Python 3.8 or later. You will also need to have the ``venv`` module installed for your Python version of choice. It should come with most Python distributions, but if it does not, you can usually install it with your operating system's package manager (``python3-venv`` is a good guess). @@ -68,26 +68,18 @@ Creating the first component ---------------------------- Now, let's write some code! Create a file named ``server.py`` in the ``echo`` package -directory:: +directory: - import anyio - - from asphalt.core import Component, run_application - - - class ServerComponent(Component): - async def start(self) -> None: - print("Hello, world!") - - if __name__ == "__main__": - component = ServerComponent() - run_application(component) +.. literalinclude:: snippets/echo1.py + :language: python + :start-after: isort: off The ``ServerComponent`` class is the *root component* (and in this case, the only -component) of this application. Its ``start()`` method is called by ``run_application`` -when it has set up the event loop. Finally, the ``if __name__ == '__main__':`` block is -not strictly necessary but is good, common practice that prevents ``run_application()`` -from being called again if this module is ever imported from another module. +component) of this application. Its ``start()`` method is called by +:func:`run_application` when it has set up the event loop. Finally, the +``if __name__ == '__main__':`` block is not strictly necessary but is good, common +practice that prevents :func:`run_application()` from being called again if this module +is ever imported from another module. You can now try running the above application. With the project directory (``tutorial``) as your current directory, do: @@ -103,39 +95,11 @@ Making the server listen for connections ---------------------------------------- The next step is to make the server actually accept incoming connections. -For this purpose, we will use AnyIO's :func:`~anyio.create_tcp_listener` function:: - - from collections.abc import AsyncIterator - - import anyio - from anyio.abc import SocketStream - - from asphalt.core import ( - Component, - context_teardown, - run_application, - start_background_task, - ) - - - async def handle(stream: SocketStream) -> None: - message = await stream.receive() - await stream.send(message) - print("Message from client:", message.decode().rstrip()) +For this purpose, we will use AnyIO's :func:`~anyio.create_tcp_listener` function: - - class ServerComponent(Component): - @context_teardown - async def start(self) -> AsyncGenerator[None, Exception | None]: - async with await anyio.create_tcp_listener( - local_host="localhost", local_port=64100 - ) as listener: - start_background_task(lambda: listener.serve(handle), "Echo server") - yield - - if __name__ == '__main__': - component = ServerComponent() - run_application(component) +.. literalinclude:: ../../examples/tutorial1/echo/server.py + :language: python + :start-after: isort: off Here, :func:`anyio.create_tcp_listener` is used to listen to incoming TCP connections on the ``localhost`` interface on port 64100. The port number is totally arbitrary and can @@ -175,30 +139,11 @@ No server is very useful without a client to access it, so we'll need to add a c module in this project. And to make things a bit more interesting, we'll make the client accept a message to be sent as a command line argument. -Create the file ``client.py`` file in the ``echo`` package directory as follows:: - - import sys - - import anyio - - from asphalt.core import CLIApplicationComponent, run_application - - - class ClientComponent(CLIApplicationComponent): - def __init__(self, message: str): - super().__init__() - self.message = message - - async def run(self) -> None: - async with await anyio.connect_tcp("localhost", 64100) as stream: - await stream.send(self.message.encode() + b"\n") - response = await stream.receive() - - print("Server responded:", response.decode().rstrip()) +Create the file ``client.py`` file in the ``echo`` package directory as follows: - if __name__ == '__main__': - component = ClientComponent(sys.argv[1]) - run_application(component) +.. literalinclude:: ../../examples/tutorial1/echo/client.py + :language: python + :start-after: isort: off You may have noticed that ``ClientComponent`` inherits from :class:`CLIApplicationComponent` instead of :class:`Component` and that instead of diff --git a/docs/tutorials/snippets/echo1.py b/docs/tutorials/snippets/echo1.py new file mode 100644 index 00000000..811b9787 --- /dev/null +++ b/docs/tutorials/snippets/echo1.py @@ -0,0 +1,14 @@ +# isort: off +from __future__ import annotations + +from asphalt.core import Component, run_application + + +class ServerComponent(Component): + async def start(self) -> None: + print("Hello, world!") + + +if __name__ == "__main__": + component = ServerComponent() + run_application(component) diff --git a/docs/tutorials/snippets/webnotifier-app1.py b/docs/tutorials/snippets/webnotifier-app1.py new file mode 100644 index 00000000..7ecb884f --- /dev/null +++ b/docs/tutorials/snippets/webnotifier-app1.py @@ -0,0 +1,20 @@ +# isort: off +import logging + +import anyio +import httpx +from asphalt.core import CLIApplicationComponent, run_application + +logger = logging.getLogger(__name__) + + +class ApplicationComponent(CLIApplicationComponent): + async def run(self) -> None: + async with httpx.AsyncClient() as http: + while True: + await http.get("https://imgur.com") + await anyio.sleep(10) + + +if __name__ == "__main__": + run_application(ApplicationComponent(), logging=logging.DEBUG) diff --git a/docs/tutorials/snippets/webnotifier-app2.py b/docs/tutorials/snippets/webnotifier-app2.py new file mode 100644 index 00000000..197b5bb9 --- /dev/null +++ b/docs/tutorials/snippets/webnotifier-app2.py @@ -0,0 +1,32 @@ +# isort: off +from __future__ import annotations + +import logging +from typing import Any + +import anyio +import httpx +from asphalt.core import CLIApplicationComponent, run_application + +logger = logging.getLogger(__name__) + + +class ApplicationComponent(CLIApplicationComponent): + async def run(self) -> None: + last_modified = None + async with httpx.AsyncClient() as http: + while True: + headers: dict[str, Any] = ( + {"if-modified-since": last_modified} if last_modified else {} + ) + response = await http.get("https://imgur.com", headers=headers) + logger.debug("Response status: %d", response.status_code) + if response.status_code == 200: + last_modified = response.headers["date"] + logger.info("Contents changed") + + await anyio.sleep(10) + + +if __name__ == "__main__": + run_application(ApplicationComponent(), logging=logging.DEBUG) diff --git a/docs/tutorials/snippets/webnotifier-app3.py b/docs/tutorials/snippets/webnotifier-app3.py new file mode 100644 index 00000000..3f7ea3af --- /dev/null +++ b/docs/tutorials/snippets/webnotifier-app3.py @@ -0,0 +1,39 @@ +# isort: off +from __future__ import annotations + +import logging +from difflib import unified_diff +from typing import Any + +import anyio +import httpx +from asphalt.core import CLIApplicationComponent, run_application + +logger = logging.getLogger(__name__) + + +class ApplicationComponent(CLIApplicationComponent): + async def run(self) -> None: + async with httpx.AsyncClient() as http: + last_modified, old_lines = None, None + while True: + logger.debug("Fetching webpage") + headers: dict[str, Any] = ( + {"if-modified-since": last_modified} if last_modified else {} + ) + response = await http.get("https://imgur.com", headers=headers) + logger.debug("Response status: %d", response.status_code) + if response.status_code == 200: + last_modified = response.headers["date"] + new_lines = response.text.split("\n") + if old_lines is not None and old_lines != new_lines: + difference = unified_diff(old_lines, new_lines) + logger.info("Contents changed:\n%s", difference) + + old_lines = new_lines + + await anyio.sleep(10) + + +if __name__ == "__main__": + run_application(ApplicationComponent(), logging=logging.DEBUG) diff --git a/docs/tutorials/snippets/webnotifier-app4.py b/docs/tutorials/snippets/webnotifier-app4.py new file mode 100644 index 00000000..9f9f68ed --- /dev/null +++ b/docs/tutorials/snippets/webnotifier-app4.py @@ -0,0 +1,55 @@ +# isort: off +from __future__ import annotations + +import logging +from difflib import HtmlDiff +from typing import Any + +import anyio +import httpx +from asphalt.core import CLIApplicationComponent, run_application +from asphalt.core import inject, resource +from asphalt.mailer import Mailer + +logger = logging.getLogger(__name__) + + +class ApplicationComponent(CLIApplicationComponent): + async def start(self) -> None: + self.add_component( + "mailer", + backend="smtp", + host="your.smtp.server.here", + message_defaults={"sender": "your@email.here", "to": "your@email.here"}, + ) + await super().start() + + @inject + async def run(self, *, mailer: Mailer = resource()) -> None: + async with httpx.AsyncClient() as http: + last_modified, old_lines = None, None + diff = HtmlDiff() + while True: + logger.debug("Fetching webpage") + headers: dict[str, Any] = ( + {"if-modified-since": last_modified} if last_modified else {} + ) + response = await http.get("https://imgur.com", headers=headers) + logger.debug("Response status: %d", response.status_code) + if response.status_code == 200: + last_modified = response.headers["date"] + new_lines = response.text.split("\n") + if old_lines is not None and old_lines != new_lines: + difference = diff.make_file(old_lines, new_lines, context=True) + await mailer.create_and_deliver( + subject="Change detected in web page", html_body=difference + ) + logger.info("Sent notification email") + + old_lines = new_lines + + await anyio.sleep(10) + + +if __name__ == "__main__": + run_application(ApplicationComponent(), logging=logging.DEBUG) diff --git a/docs/tutorials/snippets/webnotifier-detector1.py b/docs/tutorials/snippets/webnotifier-detector1.py new file mode 100644 index 00000000..683bbfb2 --- /dev/null +++ b/docs/tutorials/snippets/webnotifier-detector1.py @@ -0,0 +1,12 @@ +# isort: off +from __future__ import annotations + +from dataclasses import dataclass + +from asphalt.core import Event + + +@dataclass +class WebPageChangeEvent(Event): + old_lines: list[str] + new_lines: list[str] diff --git a/docs/tutorials/snippets/webnotifier-detector2.py b/docs/tutorials/snippets/webnotifier-detector2.py new file mode 100644 index 00000000..920b9f2f --- /dev/null +++ b/docs/tutorials/snippets/webnotifier-detector2.py @@ -0,0 +1,47 @@ +# isort: off +from __future__ import annotations + +import logging +from dataclasses import dataclass +from typing import Any + +import anyio +import httpx + +from asphalt.core import Event, Signal + +logger = logging.getLogger(__name__) + + +@dataclass +class WebPageChangeEvent(Event): + old_lines: list[str] + new_lines: list[str] + + +class Detector: + changed = Signal(WebPageChangeEvent) + + def __init__(self, url: str, delay: float): + self.url = url + self.delay = delay + + async def run(self) -> None: + async with httpx.AsyncClient() as http: + last_modified, old_lines = None, None + while True: + logger.debug("Fetching contents of %s", self.url) + headers: dict[str, Any] = ( + {"if-modified-since": last_modified} if last_modified else {} + ) + response = await http.get("https://imgur.com", headers=headers) + logger.debug("Response status: %d", response.status_code) + if response.status_code == 200: + last_modified = response.headers["date"] + new_lines = response.text.split("\n") + if old_lines is not None and old_lines != new_lines: + self.changed.dispatch(WebPageChangeEvent(old_lines, new_lines)) + + old_lines = new_lines + + await anyio.sleep(self.delay) diff --git a/docs/tutorials/webnotifier.rst b/docs/tutorials/webnotifier.rst index eb543359..ab5f0def 100644 --- a/docs/tutorials/webnotifier.rst +++ b/docs/tutorials/webnotifier.rst @@ -34,29 +34,11 @@ Detecting changes in a web page ------------------------------- The first task is to set up a loop that periodically retrieves the web page. To that -end, you need to set up an asynchronous HTTP client using the httpx_ library:: +end, you need to set up an asynchronous HTTP client using the httpx_ library: - import logging - from typing import Any - - import anyio - import httpx - from asphalt.core import CLIApplicationComponent, Context, run_application - - logger = logging.getLogger(__name__) - - - class ApplicationComponent(CLIApplicationComponent): - async def run(self) -> None: - async with httpx.AsyncClient() as http: - while True: - async with http.get("https://imgur.com") as resp: - await resp.text() - - await anyio.sleep(10) - - if __name__ == "__main__": - run_application(ApplicationComponent(), logging=logging.DEBUG) +.. literalinclude:: snippets/webnotifier-app1.py + :language: python + :start-after: isort: off Great, so now the code fetches the contents of ``https://imgur.com`` at 10 second intervals. But this isn't very useful yet – you need something that compares the old and @@ -69,24 +51,11 @@ requests after the initial one specify the last modified date value in the reque headers, the remote server will respond with a ``304 Not Modified`` if the contents have not changed since that moment. -So, modify the code as follows:: - - class ApplicationComponent(CLIApplicationComponent): - async def run(self) -> None: - last_modified = None - async with httpx.AsyncClient() as http: - while True: - headers: dict[str, Any] = ( - {"if-modified-since": last_modified} if last_modified else {} - ) - async with http.get("https://imgur.com", headers=headers) as resp: - logger.debug("Response status: %d", resp.status) - if resp.status == 200: - last_modified = resp.headers["date"] - await resp.text() - logger.info("Contents changed") - - await anyio.sleep(10) +So, modify the code as follows: + +.. literalinclude:: snippets/webnotifier-app2.py + :language: python + :start-after: isort: off The code here stores the ``date`` header from the first response and uses it in the ``if-modified-since`` header of the next request. A ``200`` response indicates that the @@ -99,35 +68,14 @@ idea of what's happening. Computing the changes between old and new versions -------------------------------------------------- -Now you have code that actually detects when the page has been modified between the requests. -But it doesn't yet show *what* in its contents has changed. The next step will then be to use the -standard library :mod:`difflib` module to calculate the difference between the contents and send it -to the logger:: - - from difflib import unified_diff - - - class ApplicationComponent(CLIApplicationComponent): - async def run(self) -> None: - async with httpx.AsyncClient() as http: - last_modified, old_lines = None, None - while True: - logger.debug("Fetching webpage") - headers: dict[str, Any] = ( - {"if-modified-since": last_modified} if last_modified else {} - ) - async with http.get("https://imgur.com", headers=headers) as resp: - logger.debug("Response status: %d", resp.status) - if resp.status == 200: - last_modified = resp.headers["date"] - new_lines = (await resp.text()).split("\n") - if old_lines is not None and old_lines != new_lines: - difference = unified_diff(old_lines, new_lines) - logger.info("Contents changed:\n%s", difference) - - old_lines = new_lines +Now you have code that actually detects when the page has been modified between the +requests. But it doesn't yet show *what* in its contents has changed. The next step will +then be to use the standard library :mod:`difflib` module to calculate the difference +between the contents and send it to the logger: - await anyio.sleep(10) +.. literalinclude:: snippets/webnotifier-app3.py + :language: python + :start-after: isort: off This modified code now stores the old and new contents in different variables to enable them to be compared. The ``.split("\n")`` is needed because @@ -153,47 +101,11 @@ function as a resource by adding a keyword-only argument, annotated with the typ the resource you want to inject (:class:`~asphalt.mailer.Mailer`). And to make the the results look nicer in an email message, you can switch to using -:class:`difflib.HtmlDiff` to produce the delta output:: - - from difflib import HtmlDiff - - from asphalt.core import inject, resource - from asphalt.mailer import Mailer - - - class ApplicationComponent(CLIApplicationComponent): - async def start(self) -> None: - self.add_component( - "mailer", backend="smtp", host="your.smtp.server.here", - message_defaults={"sender": "your@email.here", "to": "your@email.here"}) - await super().start() - - @inject - async def run(self, *, mailer: Mailer = resource()) -> None: - async with httpx.AsyncClient() as http: - last_modified, old_lines = None, None - diff = HtmlDiff() - while True: - logger.debug("Fetching webpage") - headers: dict[str, Any] = ( - {"if-modified-since": last_modified} if last_modified else {} - ) - async with http.get("https://imgur.com", headers=headers) as resp: - logger.debug("Response status: %d", resp.status) - if resp.status == 200: - last_modified = resp.headers["date"] - new_lines = (await resp.text()).split("\n") - if old_lines is not None and old_lines != new_lines: - difference = diff.make_file(old_lines, new_lines, context=True) - await mailer.create_and_deliver( - subject="Change detected in web page", - html_body=difference - ) - logger.info("Sent notification email") - - old_lines = new_lines - - await anyio.sleep(10) +:class:`difflib.HtmlDiff` to produce the delta output: + +.. literalinclude:: snippets/webnotifier-app4.py + :language: python + :start-after: isort: off You'll need to replace the ``host``, ``sender`` and ``to`` arguments for the mailer component and possibly add the ``username`` and ``password`` arguments if your SMTP @@ -214,123 +126,45 @@ code. A well designed application should maintain proper `separation of concerns way to do this is to separate the change detection logic to its own class. Create a new module named ``detector`` in the ``webnotifier`` package. Then, add the -change event class to it:: - - from dataclasses import dataclass - import logging - - import httpx - from asphalt.core import Component, Event, Signal, context_teardown +change event class to it: - logger = logging.getLogger(__name__) - - - @dataclass - class WebPageChangeEvent(Event): - old_lines: list[str] - new_lines: list[str] +.. literalinclude:: snippets/webnotifier-detector1.py + :language: python + :start-after: isort: off This class defines the type of event that the notifier will emit when the target web page changes. The old and new content are stored in the event instance to allow the event listener to generate the output any way it wants. Next, add another class in the same module that will do the HTTP requests and change -detection:: - - class Detector: - changed = Signal(WebPageChangeEvent) - - def __init__(self, url: str, delay: float): - self.url = url - self.delay = delay - - async def run(self) -> None: - async with aiohttp.ClientSession() as http: - last_modified, old_lines = None, None - while True: - logger.debug("Fetching contents of %s", self.url) - headers: dict[str, Any] = ( - {"if-modified-since": last_modified} if last_modified else {} - ) - async with http.get(self.url, headers=headers) as resp: - logger.debug("Response status: %d", resp.status) - if resp.status == 200: - last_modified = resp.headers["date"] - new_lines = (await resp.text()).split("\n") - if old_lines is not None and old_lines != new_lines: - await self.changed.dispatch( - WebPageChangeEvent(old_lines, new_lines) - ) - - old_lines = new_lines - - await anyio.sleep(self.delay) - -The constructor arguments allow you to freely specify the parameters for the detection +detection: + +.. literalinclude:: snippets/webnotifier-detector2.py + :language: python + :start-after: isort: off + +The initializer arguments allow you to freely specify the parameters for the detection process. The class includes a signal named ``changed`` that uses the previously created ``WebPageChangeEvent`` class. The code dispatches such an event when a change in the target web page is detected. Finally, add the component class which will allow you to integrate this functionality -into any Asphalt application:: - - class ChangeDetectorComponent(Component): - def __init__(self, url: str, delay: float = 10): - self.url = url - self.delay = delay - - @context_teardown - async def start(self) -> None: - detector = Detector(self.url, self.delay) - await ctx.add_resource(detector) - start_service_task(detector.run, "Web page change detector") - logging.info( - 'Started web page change detector for url "%s" with a delay of %d seconds', - self.url, - self.delay, - ) +into any Asphalt application: - yield - - # This part is run when the context is being torn down - logger.info("Shut down web page change detector") +.. literalinclude:: ../../examples/tutorial2/webnotifier/detector.py + :language: python + :start-after: isort: off The component's ``start()`` method starts the detector's ``run()`` method as a new task, adds the detector object as resource and installs an event listener that will shut down the detector when the context is torn down. Now that you've moved the change detection code to its own module, -``ApplicationComponent`` will become somewhat lighter:: - - from contextlib import aclosing # on Python < 3.10, import from async_generator or contextlib2 - - - class ApplicationComponent(CLIApplicationComponent): - async def start(self) -> None: - self.add_component("detector", ChangeDetectorComponent, url="https://imgur.com") - self.add_component( - "mailer", backend="smtp", host="your.smtp.server.here", - message_defaults={"sender": "your@email.here", "to": "your@email.here"}) - await super().start(ctx) - - @inject - async def run( - self, - *, - mailer: Mailer = resource(), - detector: Detector = resource(), - ): - diff = HtmlDiff() - async with aclosing(detector.changed.stream_events()) as stream: - async for event in stream: - difference = diff.make_file( - event.old_lines, event.new_lines, context=True - ) - await mailer.create_and_deliver( - subject=f"Change detected in {event.source.url}", - html_body=difference, - ) - logger.info("Sent notification email") +``ApplicationComponent`` will become somewhat lighter: + +.. literalinclude:: ../../examples/tutorial2/webnotifier/app.py + :language: python + :start-after: isort: off The main application component will now use the detector resource added by ``ChangeDetectorComponent``. It adds one event listener which reacts to change events by @@ -355,37 +189,8 @@ different configuration. In your project directory (``tutorial2``), create a file named ``config.yaml`` with the following contents: -.. code-block:: yaml - - --- - component: - type: !!python/name:webnotifier.app.ApplicationComponent - components: - detector: - url: https://imgur.com/ - delay: 15 - mailer: - host: your.smtp.server.here - message_defaults: - sender: your@email.here - to: your@email.here - - logging: - version: 1 - disable_existing_loggers: false - formatters: - default: - format: '[%(asctime)s %(levelname)s] %(message)s' - handlers: - console: - class: logging.StreamHandler - formatter: default - root: - handlers: [console] - level: INFO - loggers: - webnotifier: - level: DEBUG +.. literalinclude:: ../../examples/tutorial2/config.yaml + :language: yaml The ``component`` section defines parameters for the root component. Aside from the special ``type`` key which tells the runner where to find the component class, all the diff --git a/examples/tutorial1/echo/client.py b/examples/tutorial1/echo/client.py index 3dc39b1f..60bc3b78 100644 --- a/examples/tutorial1/echo/client.py +++ b/examples/tutorial1/echo/client.py @@ -1,9 +1,9 @@ """This is the client code for the Asphalt echo server tutorial.""" +# isort: off import sys import anyio - from asphalt.core import CLIApplicationComponent, run_application diff --git a/examples/tutorial1/echo/server.py b/examples/tutorial1/echo/server.py index 9222ea5a..5f9e23a0 100644 --- a/examples/tutorial1/echo/server.py +++ b/examples/tutorial1/echo/server.py @@ -1,10 +1,10 @@ """This is the server code for the Asphalt echo server tutorial.""" +# isort: off from __future__ import annotations import anyio from anyio.abc import SocketStream, TaskStatus - from asphalt.core import ( Component, run_application, diff --git a/examples/tutorial2/config.yaml b/examples/tutorial2/config.yaml index 9767e33a..19c1bfc2 100644 --- a/examples/tutorial2/config.yaml +++ b/examples/tutorial2/config.yaml @@ -1,4 +1,3 @@ ---- services: default: component: diff --git a/examples/tutorial2/webnotifier/detector.py b/examples/tutorial2/webnotifier/detector.py index 8cd5469e..80b4e009 100644 --- a/examples/tutorial2/webnotifier/detector.py +++ b/examples/tutorial2/webnotifier/detector.py @@ -1,8 +1,8 @@ """This is the change detector component for the Asphalt webnotifier tutorial.""" +# isort: off from __future__ import annotations -# isort: off import logging from dataclasses import dataclass from typing import Any @@ -41,15 +41,13 @@ async def run(self) -> None: headers: dict[str, Any] = ( {"if-modified-since": last_modified} if last_modified else {} ) - resp = await http.get(self.url, headers=headers) - logger.debug("Response status: %d", resp.status_code) - if resp.status_code == 200: - last_modified = resp.headers["date"] - new_lines = resp.text.split("\n") + response = await http.get(self.url, headers=headers) + logger.debug("Response status: %d", response.status_code) + if response.status_code == 200: + last_modified = response.headers["date"] + new_lines = response.text.split("\n") if old_lines is not None and old_lines != new_lines: - await self.changed.dispatch( - WebPageChangeEvent(old_lines, new_lines) - ) + self.changed.dispatch(WebPageChangeEvent(old_lines, new_lines)) old_lines = new_lines