Initial public release
Claude · 2026-10-25 · 34229d790bee138d4aa5993a70b2158cd8802972
Changes
.dockerignore | 12 + .gitignore | 9 + CONTRIBUTING.md | 44 ++++ Dockerfile | 29 +++ LICENSE | 661 +++++++++++++++++++++++++++++++++++++++++++++++++ README.md | 190 ++++++++++++++ _meta/design.md | 254 +++++++++++++++++++ _meta/tree-format.md | 230 +++++++++++++++++ deploy/README.md | 115 +++++++++ deploy/openmediavault/docker-compose.yml | 58 +++++ deploy/systemd/pyrtan-demo.service | 38 +++ examples/github/github-release.toml | 62 +++++ examples/hacker-news/hacker-news-item.toml | 65 +++++ examples/hacker-news/hacker-news.toml | 78 ++++++ examples/site-status/site-status.toml | 61 +++++ examples/weather/weather.toml | 128 ++++++++++ pyproject.toml | 42 ++++ src/pyrtan/__init__.py | 34 +++ src/pyrtan/cli.py | 337 +++++++++++++++++++++++++ src/pyrtan/conditions.py | 144 +++++++++++ src/pyrtan/extract.py | 120 +++++++++ src/pyrtan/html.py | 253 +++++++++++++++++++ src/pyrtan/hub.py | 264 ++++++++++++++++++++ src/pyrtan/intent.py | 164 ++++++++++++ src/pyrtan/page.py | 128 ++++++++++ src/pyrtan/registry.py | 158 ++++++++++++ src/pyrtan/runtime.py | 409 ++++++++++++++++++++++++++++++ src/pyrtan/service.py | 262 ++++++++++++++++++++ src/pyrtan/template.py | 170 +++++++++++++ src/pyrtan/trace.py | 74 ++++++ src/pyrtan/transport.py | 146 +++++++++++ src/pyrtan/tree.py | 565 ++++++++++++++++++++++++++++++++++++++++++ tests/conftest.py | 26 ++ tests/fixtures/examples.json | 17 ++ tests/test_cli.py | 66 +++++ tests/test_conditions.py | 60 +++++ tests/test_examples.py | 83 +++++++ tests/test_extract.py | 51 ++++ tests/test_html.py | 68 +++++ tests/test_intent.py | 59 +++++ tests/test_runtime.py | 257 +++++++++++++++++++ tests/test_service_hub.py | 213 ++++++++++++++++ tests/test_template.py | 55 ++++ tests/test_tree.py | 148 +++++++++++ uv.lock | 193 +++++++++++++++ 45 files changed, 6600 insertions(+)
diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..d1c438b --- /dev/null +++ b/.dockerignore@@ -0,0 +1,12 @@+.git+.venv+.pytest_cache+__pycache__+*.pyc+dist+build+tests+deploy+_meta+.env+.env.*diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2af4a18 --- /dev/null +++ b/.gitignore@@ -0,0 +1,9 @@+.venv/+__pycache__/+*.pyc+.pytest_cache/+dist/+build/+*.egg-info/+.env+.env.*diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..ffac9e0 --- /dev/null +++ b/CONTRIBUTING.md@@ -0,0 +1,44 @@+# Contributing++Pyrtan grows mostly by trees, and a tree is a text file. If a tree you want+does not exist, write it; if one gives a wrong answer, the trace shows where.++## Adding or fixing a tree++1. `pyrtan new my-tree --dir examples/my-tree` and edit the file. Keep the+ `hosts` list to exactly the hosts it needs.+2. `pyrtan check examples/my-tree` until it is clean. Warnings are worth+ reading; an `info` about a `decide` without `else` is a choice, not a+ fault.+3. Record responses for the tests:+ `pyrtan run "the sentence" --trees examples --record tests/fixtures/my-tree.json`.+ Trim the recording to what the test needs, and never commit anything+ with a credential in it.+4. Add a test in `tests/test_examples.py` that runs the sentence against the+ recording and asserts the `say`, the `output`, and the branches taken.+5. `uv run pytest`.++A tree that reads a page should prefer an API or a feed when the site offers+one, and should decide on the status code before it decides on the body.++## Changing the runtime++The runtime is small on purpose, and every node kind is paid for by every+tree author who has to learn it. A new kind needs a case that `observe`,+`decide`, `set`, `each` and `delegate` cannot express together, written down+in the pull request. The same goes for a new filter or condition.++Nothing in `src/pyrtan` may call a model, execute code from a tree, or reach+a host outside a tree's `hosts`. Those are the three rules the design rests+on; see `_meta/design.md`.++## License of contributions++Pyrtan is AGPL-3.0-or-later. By opening a pull request you agree that your+contribution is licensed under the same terms.++## Style++Python 3.12+, standard library only at run time. Tests are pytest. A+docstring at the top of each module says what it is for and what it refuses.+Commit messages say what changed and why, in the imperative.diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..6889a90 --- /dev/null +++ b/Dockerfile@@ -0,0 +1,29 @@+# Pyrtan: one image for a service or a hub. The command decides which.+#+# docker run -p 8701:8701 -v ./trees:/trees pyrtan serve /trees --host 0.0.0.0+# docker run -p 8700:8700 pyrtan hub --host 0.0.0.0+#+# The examples are baked in at /examples so the image demos on its own.+FROM python:3.12-slim AS build+WORKDIR /src+COPY pyproject.toml README.md LICENSE ./+COPY src ./src+RUN pip install --no-cache-dir --prefix=/install .++FROM python:3.12-slim+LABEL org.opencontainers.image.title="pyrtan" \+ org.opencontainers.image.description="A swarm of small services that act on the web through hardcoded, inspectable decision trees." \+ org.opencontainers.image.source="https://github.com/b-i-t-8/pyrtan" \+ org.opencontainers.image.licenses="AGPL-3.0-or-later"+COPY --from=build /install /usr/local+COPY examples /examples+RUN useradd --system --uid 10001 --home /nonexistent --shell /usr/sbin/nologin pyrtan \+ && mkdir -p /trees && chown pyrtan:pyrtan /trees+USER pyrtan+VOLUME ["/trees"]+ENV PYTHONUNBUFFERED=1 PYRTAN_TREES=/trees+EXPOSE 8700 8701+HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \+ CMD python -c "import os,sys,urllib.request;p=os.environ.get('PYRTAN_PORT','8701');sys.exit(0 if urllib.request.urlopen(f'http://127.0.0.1:{p}/health',timeout=4).status==200 else 1)"+ENTRYPOINT ["pyrtan"]+CMD ["serve", "/examples", "--host", "0.0.0.0", "--port", "8701"]diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..be3f7b2 --- /dev/null +++ b/LICENSE@@ -0,0 +1,661 @@+ GNU AFFERO GENERAL PUBLIC LICENSE+ Version 3, 19 November 2007++ Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>+ Everyone is permitted to copy and distribute verbatim copies+ of this license document, but changing it is not allowed.++ Preamble++ The GNU Affero General Public License is a free, copyleft license for+software and other kinds of works, specifically designed to ensure+cooperation with the community in the case of network server software.++ The licenses for most software and other practical works are designed+to take away your freedom to share and change the works. By contrast,+our General Public Licenses are intended to guarantee your freedom to+share and change all versions of a program--to make sure it remains free+software for all its users.++ When we speak of free software, we are referring to freedom, not+price. Our General Public Licenses are designed to make sure that you+have the freedom to distribute copies of free software (and charge for+them if you wish), that you receive source code or can get it if you+want it, that you can change the software or use pieces of it in new+free programs, and that you know you can do these things.++ Developers that use our General Public Licenses protect your rights+with two steps: (1) assert copyright on the software, and (2) offer+you this License which gives you legal permission to copy, distribute+and/or modify the software.++ A secondary benefit of defending all users' freedom is that+improvements made in alternate versions of the program, if they+receive widespread use, become available for other developers to+incorporate. Many developers of free software are heartened and+encouraged by the resulting cooperation. However, in the case of+software used on network servers, this result may fail to come about.+The GNU General Public License permits making a modified version and+letting the public access it on a server without ever releasing its+source code to the public.++ The GNU Affero General Public License is designed specifically to+ensure that, in such cases, the modified source code becomes available+to the community. It requires the operator of a network server to+provide the source code of the modified version running there to the+users of that server. Therefore, public use of a modified version, on+a publicly accessible server, gives the public access to the source+code of the modified version.++ An older license, called the Affero General Public License and+published by Affero, was designed to accomplish similar goals. This is+a different license, not a version of the Affero GPL, but Affero has+released a new version of the Affero GPL which permits relicensing under+this license.++ The precise terms and conditions for copying, distribution and+modification follow.++ TERMS AND CONDITIONS++ 0. Definitions.++ "This License" refers to version 3 of the GNU Affero General Public License.++ "Copyright" also means copyright-like laws that apply to other kinds of+works, such as semiconductor masks.++ "The Program" refers to any copyrightable work licensed under this+License. Each licensee is addressed as "you". "Licensees" and+"recipients" may be individuals or organizations.++ To "modify" a work means to copy from or adapt all or part of the work+in a fashion requiring copyright permission, other than the making of an+exact copy. The resulting work is called a "modified version" of the+earlier work or a work "based on" the earlier work.++ A "covered work" means either the unmodified Program or a work based+on the Program.++ To "propagate" a work means to do anything with it that, without+permission, would make you directly or secondarily liable for+infringement under applicable copyright law, except executing it on a+computer or modifying a private copy. Propagation includes copying,+distribution (with or without modification), making available to the+public, and in some countries other activities as well.++ To "convey" a work means any kind of propagation that enables other+parties to make or receive copies. Mere interaction with a user through+a computer network, with no transfer of a copy, is not conveying.++ An interactive user interface displays "Appropriate Legal Notices"+to the extent that it includes a convenient and prominently visible+feature that (1) displays an appropriate copyright notice, and (2)+tells the user that there is no warranty for the work (except to the+extent that warranties are provided), that licensees may convey the+work under this License, and how to view a copy of this License. If+the interface presents a list of user commands or options, such as a+menu, a prominent item in the list meets this criterion.++ 1. Source Code.++ The "source code" for a work means the preferred form of the work+for making modifications to it. "Object code" means any non-source+form of a work.++ A "Standard Interface" means an interface that either is an official+standard defined by a recognized standards body, or, in the case of+interfaces specified for a particular programming language, one that+is widely used among developers working in that language.++ The "System Libraries" of an executable work include anything, other+than the work as a whole, that (a) is included in the normal form of+packaging a Major Component, but which is not part of that Major+Component, and (b) serves only to enable use of the work with that+Major Component, or to implement a Standard Interface for which an+implementation is available to the public in source code form. A+"Major Component", in this context, means a major essential component+(kernel, window system, and so on) of the specific operating system+(if any) on which the executable work runs, or a compiler used to+produce the work, or an object code interpreter used to run it.++ The "Corresponding Source" for a work in object code form means all+the source code needed to generate, install, and (for an executable+work) run the object code and to modify the work, including scripts to+control those activities. However, it does not include the work's+System Libraries, or general-purpose tools or generally available free+programs which are used unmodified in performing those activities but+which are not part of the work. For example, Corresponding Source+includes interface definition files associated with source files for+the work, and the source code for shared libraries and dynamically+linked subprograms that the work is specifically designed to require,+such as by intimate data communication or control flow between those+subprograms and other parts of the work.++ The Corresponding Source need not include anything that users+can regenerate automatically from other parts of the Corresponding+Source.++ The Corresponding Source for a work in source code form is that+same work.++ 2. Basic Permissions.++ All rights granted under this License are granted for the term of+copyright on the Program, and are irrevocable provided the stated+conditions are met. This License explicitly affirms your unlimited+permission to run the unmodified Program. The output from running a+covered work is covered by this License only if the output, given its+content, constitutes a covered work. This License acknowledges your+rights of fair use or other equivalent, as provided by copyright law.++ You may make, run and propagate covered works that you do not+convey, without conditions so long as your license otherwise remains+in force. You may convey covered works to others for the sole purpose+of having them make modifications exclusively for you, or provide you+with facilities for running those works, provided that you comply with+the terms of this License in conveying all material for which you do+not control copyright. Those thus making or running the covered works+for you must do so exclusively on your behalf, under your direction+and control, on terms that prohibit them from making any copies of+your copyrighted material outside their relationship with you.++ Conveying under any other circumstances is permitted solely under+the conditions stated below. Sublicensing is not allowed; section 10+makes it unnecessary.++ 3. Protecting Users' Legal Rights From Anti-Circumvention Law.++ No covered work shall be deemed part of an effective technological+measure under any applicable law fulfilling obligations under article+11 of the WIPO copyright treaty adopted on 20 December 1996, or+similar laws prohibiting or restricting circumvention of such+measures.++ When you convey a covered work, you waive any legal power to forbid+circumvention of technological measures to the extent such circumvention+is effected by exercising rights under this License with respect to+the covered work, and you disclaim any intention to limit operation or+modification of the work as a means of enforcing, against the work's+users, your or third parties' legal rights to forbid circumvention of+technological measures.++ 4. Conveying Verbatim Copies.++ You may convey verbatim copies of the Program's source code as you+receive it, in any medium, provided that you conspicuously and+appropriately publish on each copy an appropriate copyright notice;+keep intact all notices stating that this License and any+non-permissive terms added in accord with section 7 apply to the code;+keep intact all notices of the absence of any warranty; and give all+recipients a copy of this License along with the Program.++ You may charge any price or no price for each copy that you convey,+and you may offer support or warranty protection for a fee.++ 5. Conveying Modified Source Versions.++ You may convey a work based on the Program, or the modifications to+produce it from the Program, in the form of source code under the+terms of section 4, provided that you also meet all of these conditions:++ a) The work must carry prominent notices stating that you modified+ it, and giving a relevant date.++ b) The work must carry prominent notices stating that it is+ released under this License and any conditions added under section+ 7. This requirement modifies the requirement in section 4 to+ "keep intact all notices".++ c) You must license the entire work, as a whole, under this+ License to anyone who comes into possession of a copy. This+ License will therefore apply, along with any applicable section 7+ additional terms, to the whole of the work, and all its parts,+ regardless of how they are packaged. This License gives no+ permission to license the work in any other way, but it does not+ invalidate such permission if you have separately received it.++ d) If the work has interactive user interfaces, each must display+ Appropriate Legal Notices; however, if the Program has interactive+ interfaces that do not display Appropriate Legal Notices, your+ work need not make them do so.++ A compilation of a covered work with other separate and independent+works, which are not by their nature extensions of the covered work,+and which are not combined with it such as to form a larger program,+in or on a volume of a storage or distribution medium, is called an+"aggregate" if the compilation and its resulting copyright are not+used to limit the access or legal rights of the compilation's users+beyond what the individual works permit. Inclusion of a covered work+in an aggregate does not cause this License to apply to the other+parts of the aggregate.++ 6. Conveying Non-Source Forms.++ You may convey a covered work in object code form under the terms+of sections 4 and 5, provided that you also convey the+machine-readable Corresponding Source under the terms of this License,+in one of these ways:++ a) Convey the object code in, or embodied in, a physical product+ (including a physical distribution medium), accompanied by the+ Corresponding Source fixed on a durable physical medium+ customarily used for software interchange.++ b) Convey the object code in, or embodied in, a physical product+ (including a physical distribution medium), accompanied by a+ written offer, valid for at least three years and valid for as+ long as you offer spare parts or customer support for that product+ model, to give anyone who possesses the object code either (1) a+ copy of the Corresponding Source for all the software in the+ product that is covered by this License, on a durable physical+ medium customarily used for software interchange, for a price no+ more than your reasonable cost of physically performing this+ conveying of source, or (2) access to copy the+ Corresponding Source from a network server at no charge.++ c) Convey individual copies of the object code with a copy of the+ written offer to provide the Corresponding Source. This+ alternative is allowed only occasionally and noncommercially, and+ only if you received the object code with such an offer, in accord+ with subsection 6b.++ d) Convey the object code by offering access from a designated+ place (gratis or for a charge), and offer equivalent access to the+ Corresponding Source in the same way through the same place at no+ further charge. You need not require recipients to copy the+ Corresponding Source along with the object code. If the place to+ copy the object code is a network server, the Corresponding Source+ may be on a different server (operated by you or a third party)+ that supports equivalent copying facilities, provided you maintain+ clear directions next to the object code saying where to find the+ Corresponding Source. Regardless of what server hosts the+ Corresponding Source, you remain obligated to ensure that it is+ available for as long as needed to satisfy these requirements.++ e) Convey the object code using peer-to-peer transmission, provided+ you inform other peers where the object code and Corresponding+ Source of the work are being offered to the general public at no+ charge under subsection 6d.++ A separable portion of the object code, whose source code is excluded+from the Corresponding Source as a System Library, need not be+included in conveying the object code work.++ A "User Product" is either (1) a "consumer product", which means any+tangible personal property which is normally used for personal, family,+or household purposes, or (2) anything designed or sold for incorporation+into a dwelling. In determining whether a product is a consumer product,+doubtful cases shall be resolved in favor of coverage. For a particular+product received by a particular user, "normally used" refers to a+typical or common use of that class of product, regardless of the status+of the particular user or of the way in which the particular user+actually uses, or expects or is expected to use, the product. A product+is a consumer product regardless of whether the product has substantial+commercial, industrial or non-consumer uses, unless such uses represent+the only significant mode of use of the product.++ "Installation Information" for a User Product means any methods,+procedures, authorization keys, or other information required to install+and execute modified versions of a covered work in that User Product from+a modified version of its Corresponding Source. The information must+suffice to ensure that the continued functioning of the modified object+code is in no case prevented or interfered with solely because+modification has been made.++ If you convey an object code work under this section in, or with, or+specifically for use in, a User Product, and the conveying occurs as+part of a transaction in which the right of possession and use of the+User Product is transferred to the recipient in perpetuity or for a+fixed term (regardless of how the transaction is characterized), the+Corresponding Source conveyed under this section must be accompanied+by the Installation Information. But this requirement does not apply+if neither you nor any third party retains the ability to install+modified object code on the User Product (for example, the work has+been installed in ROM).++ The requirement to provide Installation Information does not include a+requirement to continue to provide support service, warranty, or updates+for a work that has been modified or installed by the recipient, or for+the User Product in which it has been modified or installed. Access to a+network may be denied when the modification itself materially and+adversely affects the operation of the network or violates the rules and+protocols for communication across the network.++ Corresponding Source conveyed, and Installation Information provided,+in accord with this section must be in a format that is publicly+documented (and with an implementation available to the public in+source code form), and must require no special password or key for+unpacking, reading or copying.++ 7. Additional Terms.++ "Additional permissions" are terms that supplement the terms of this+License by making exceptions from one or more of its conditions.+Additional permissions that are applicable to the entire Program shall+be treated as though they were included in this License, to the extent+that they are valid under applicable law. If additional permissions+apply only to part of the Program, that part may be used separately+under those permissions, but the entire Program remains governed by+this License without regard to the additional permissions.++ When you convey a copy of a covered work, you may at your option+remove any additional permissions from that copy, or from any part of+it. (Additional permissions may be written to require their own+removal in certain cases when you modify the work.) You may place+additional permissions on material, added by you to a covered work,+for which you have or can give appropriate copyright permission.++ Notwithstanding any other provision of this License, for material you+add to a covered work, you may (if authorized by the copyright holders of+that material) supplement the terms of this License with terms:++ a) Disclaiming warranty or limiting liability differently from the+ terms of sections 15 and 16 of this License; or++ b) Requiring preservation of specified reasonable legal notices or+ author attributions in that material or in the Appropriate Legal+ Notices displayed by works containing it; or++ c) Prohibiting misrepresentation of the origin of that material, or+ requiring that modified versions of such material be marked in+ reasonable ways as different from the original version; or++ d) Limiting the use for publicity purposes of names of licensors or+ authors of the material; or++ e) Declining to grant rights under trademark law for use of some+ trade names, trademarks, or service marks; or++ f) Requiring indemnification of licensors and authors of that+ material by anyone who conveys the material (or modified versions of+ it) with contractual assumptions of liability to the recipient, for+ any liability that these contractual assumptions directly impose on+ those licensors and authors.++ All other non-permissive additional terms are considered "further+restrictions" within the meaning of section 10. If the Program as you+received it, or any part of it, contains a notice stating that it is+governed by this License along with a term that is a further+restriction, you may remove that term. If a license document contains+a further restriction but permits relicensing or conveying under this+License, you may add to a covered work material governed by the terms+of that license document, provided that the further restriction does+not survive such relicensing or conveying.++ If you add terms to a covered work in accord with this section, you+must place, in the relevant source files, a statement of the+additional terms that apply to those files, or a notice indicating+where to find the applicable terms.++ Additional terms, permissive or non-permissive, may be stated in the+form of a separately written license, or stated as exceptions;+the above requirements apply either way.++ 8. Termination.++ You may not propagate or modify a covered work except as expressly+provided under this License. Any attempt otherwise to propagate or+modify it is void, and will automatically terminate your rights under+this License (including any patent licenses granted under the third+paragraph of section 11).++ However, if you cease all violation of this License, then your+license from a particular copyright holder is reinstated (a)+provisionally, unless and until the copyright holder explicitly and+finally terminates your license, and (b) permanently, if the copyright+holder fails to notify you of the violation by some reasonable means+prior to 60 days after the cessation.++ Moreover, your license from a particular copyright holder is+reinstated permanently if the copyright holder notifies you of the+violation by some reasonable means, this is the first time you have+received notice of violation of this License (for any work) from that+copyright holder, and you cure the violation prior to 30 days after+your receipt of the notice.++ Termination of your rights under this section does not terminate the+licenses of parties who have received copies or rights from you under+this License. If your rights have been terminated and not permanently+reinstated, you do not qualify to receive new licenses for the same+material under section 10.++ 9. Acceptance Not Required for Having Copies.++ You are not required to accept this License in order to receive or+run a copy of the Program. Ancillary propagation of a covered work+occurring solely as a consequence of using peer-to-peer transmission+to receive a copy likewise does not require acceptance. However,+nothing other than this License grants you permission to propagate or+modify any covered work. These actions infringe copyright if you do+not accept this License. Therefore, by modifying or propagating a+covered work, you indicate your acceptance of this License to do so.++ 10. Automatic Licensing of Downstream Recipients.++ Each time you convey a covered work, the recipient automatically+receives a license from the original licensors, to run, modify and+propagate that work, subject to this License. You are not responsible+for enforcing compliance by third parties with this License.++ An "entity transaction" is a transaction transferring control of an+organization, or substantially all assets of one, or subdividing an+organization, or merging organizations. If propagation of a covered+work results from an entity transaction, each party to that+transaction who receives a copy of the work also receives whatever+licenses to the work the party's predecessor in interest had or could+give under the previous paragraph, plus a right to possession of the+Corresponding Source of the work from the predecessor in interest, if+the predecessor has it or can get it with reasonable efforts.++ You may not impose any further restrictions on the exercise of the+rights granted or affirmed under this License. For example, you may+not impose a license fee, royalty, or other charge for exercise of+rights granted under this License, and you may not initiate litigation+(including a cross-claim or counterclaim in a lawsuit) alleging that+any patent claim is infringed by making, using, selling, offering for+sale, or importing the Program or any portion of it.++ 11. Patents.++ A "contributor" is a copyright holder who authorizes use under this+License of the Program or a work on which the Program is based. The+work thus licensed is called the contributor's "contributor version".++ A contributor's "essential patent claims" are all patent claims+owned or controlled by the contributor, whether already acquired or+hereafter acquired, that would be infringed by some manner, permitted+by this License, of making, using, or selling its contributor version,+but do not include claims that would be infringed only as a+consequence of further modification of the contributor version. For+purposes of this definition, "control" includes the right to grant+patent sublicenses in a manner consistent with the requirements of+this License.++ Each contributor grants you a non-exclusive, worldwide, royalty-free+patent license under the contributor's essential patent claims, to+make, use, sell, offer for sale, import and otherwise run, modify and+propagate the contents of its contributor version.++ In the following three paragraphs, a "patent license" is any express+agreement or commitment, however denominated, not to enforce a patent+(such as an express permission to practice a patent or covenant not to+sue for patent infringement). To "grant" such a patent license to a+party means to make such an agreement or commitment not to enforce a+patent against the party.++ If you convey a covered work, knowingly relying on a patent license,+and the Corresponding Source of the work is not available for anyone+to copy, free of charge and under the terms of this License, through a+publicly available network server or other readily accessible means,+then you must either (1) cause the Corresponding Source to be so+available, or (2) arrange to deprive yourself of the benefit of the+patent license for this particular work, or (3) arrange, in a manner+consistent with the requirements of this License, to extend the patent+license to downstream recipients. "Knowingly relying" means you have+actual knowledge that, but for the patent license, your conveying the+covered work in a country, or your recipient's use of the covered work+in a country, would infringe one or more identifiable patents in that+country that you have reason to believe are valid.++ If, pursuant to or in connection with a single transaction or+arrangement, you convey, or propagate by procuring conveyance of, a+covered work, and grant a patent license to some of the parties+receiving the covered work authorizing them to use, propagate, modify+or convey a specific copy of the covered work, then the patent license+you grant is automatically extended to all recipients of the covered+work and works based on it.++ A patent license is "discriminatory" if it does not include within+the scope of its coverage, prohibits the exercise of, or is+conditioned on the non-exercise of one or more of the rights that are+specifically granted under this License. You may not convey a covered+work if you are a party to an arrangement with a third party that is+in the business of distributing software, under which you make payment+to the third party based on the extent of your activity of conveying+the work, and under which the third party grants, to any of the+parties who would receive the covered work from you, a discriminatory+patent license (a) in connection with copies of the covered work+conveyed by you (or copies made from those copies), or (b) primarily+for and in connection with specific products or compilations that+contain the covered work, unless you entered into that arrangement,+or that patent license was granted, prior to 28 March 2007.++ Nothing in this License shall be construed as excluding or limiting+any implied license or other defenses to infringement that may+otherwise be available to you under applicable patent law.++ 12. No Surrender of Others' Freedom.++ If conditions are imposed on you (whether by court order, agreement or+otherwise) that contradict the conditions of this License, they do not+excuse you from the conditions of this License. If you cannot convey a+covered work so as to satisfy simultaneously your obligations under this+License and any other pertinent obligations, then as a consequence you may+not convey it at all. For example, if you agree to terms that obligate you+to collect a royalty for further conveying from those to whom you convey+the Program, the only way you could satisfy both those terms and this+License would be to refrain entirely from conveying the Program.++ 13. Remote Network Interaction; Use with the GNU General Public License.++ Notwithstanding any other provision of this License, if you modify the+Program, your modified version must prominently offer all users+interacting with it remotely through a computer network (if your version+supports such interaction) an opportunity to receive the Corresponding+Source of your version by providing access to the Corresponding Source+from a network server at no charge, through some standard or customary+means of facilitating copying of software. This Corresponding Source+shall include the Corresponding Source for any work covered by version 3+of the GNU General Public License that is incorporated pursuant to the+following paragraph.++ Notwithstanding any other provision of this License, you have+permission to link or combine any covered work with a work licensed+under version 3 of the GNU General Public License into a single+combined work, and to convey the resulting work. The terms of this+License will continue to apply to the part which is the covered work,+but the work with which it is combined will remain governed by version+3 of the GNU General Public License.++ 14. Revised Versions of this License.++ The Free Software Foundation may publish revised and/or new versions of+the GNU Affero General Public License from time to time. Such new versions+will be similar in spirit to the present version, but may differ in detail to+address new problems or concerns.++ Each version is given a distinguishing version number. If the+Program specifies that a certain numbered version of the GNU Affero General+Public License "or any later version" applies to it, you have the+option of following the terms and conditions either of that numbered+version or of any later version published by the Free Software+Foundation. If the Program does not specify a version number of the+GNU Affero General Public License, you may choose any version ever published+by the Free Software Foundation.++ If the Program specifies that a proxy can decide which future+versions of the GNU Affero General Public License can be used, that proxy's+public statement of acceptance of a version permanently authorizes you+to choose that version for the Program.++ Later license versions may give you additional or different+permissions. However, no additional obligations are imposed on any+author or copyright holder as a result of your choosing to follow a+later version.++ 15. Disclaimer of Warranty.++ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY+APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY+OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR+PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM+IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF+ALL NECESSARY SERVICING, REPAIR OR CORRECTION.++ 16. Limitation of Liability.++ IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD+PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),+EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF+SUCH DAMAGES.++ 17. Interpretation of Sections 15 and 16.++ If the disclaimer of warranty and limitation of liability provided+above cannot be given local legal effect according to their terms,+reviewing courts shall apply local law that most closely approximates+an absolute waiver of all civil liability in connection with the+Program, unless a warranty or assumption of liability accompanies a+copy of the Program in return for a fee.++ END OF TERMS AND CONDITIONS++ How to Apply These Terms to Your New Programs++ If you develop a new program, and you want it to be of the greatest+possible use to the public, the best way to achieve this is to make it+free software which everyone can redistribute and change under these terms.++ To do so, attach the following notices to the program. It is safest+to attach them to the start of each source file to most effectively+state the exclusion of warranty; and each file should have at least+the "copyright" line and a pointer to where the full notice is found.++ <one line to give the program's name and a brief idea of what it does.>+ Copyright (C) <year> <name of author>++ This program is free software: you can redistribute it and/or modify+ it under the terms of the GNU Affero General Public License as published by+ the Free Software Foundation, either version 3 of the License, or+ (at your option) any later version.++ This program is distributed in the hope that it will be useful,+ but WITHOUT ANY WARRANTY; without even the implied warranty of+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the+ GNU Affero General Public License for more details.++ You should have received a copy of the GNU Affero General Public License+ along with this program. If not, see <https://www.gnu.org/licenses/>.++Also add information on how to contact you by electronic and paper mail.++ If your software can interact with users remotely through a computer+network, you should also make sure that it provides a way for users to+get its source. For example, if your program is a web application, its+interface could display a "Source" link that leads users to an archive+of the code. There are many ways you could offer source, and different+solutions will be better for different programs; see section 13 for the+specific requirements.++ You should also get your employer (if you work as a programmer) or school,+if any, to sign a "copyright disclaimer" for the program, if necessary.+For more information on this, and how to apply and follow the GNU AGPL, see+<https://www.gnu.org/licenses/>.diff --git a/README.md b/README.md new file mode 100644 index 0000000..d71a881 --- /dev/null +++ b/README.md@@ -0,0 +1,190 @@+# Pyrtan++**Open-source total automation: a swarm of small services that act on the web+through hardcoded, inspectable decision trees.**++Pyrtan looks like an assistant from the outside. You give it a sentence; it+does something on the web and tells you what happened. Inside there is no+model. Every service is a set of **trees**: declarative files that say which+sentences they answer to, which hosts they may touch, what they observe, and+exactly how they decide what to do next. Every run produces a **trace** that+names each observation and the condition that chose each branch.++That is the trade. A model generalises to sentences nobody wrote a tree for;+Pyrtan does not, and says so. In return, Pyrtan is deterministic, costs+nothing per run, works offline against recorded responses, cannot reach a+host its author did not list, and can always explain itself.++Trees are close in spirit to Claude's skills, with two differences. They are+much narrower: a tree does one thing against one web property. And they are+live: a service reloads a tree the moment its file changes, a tree can hand+part of its work to any other tree in the swarm, and every decision that+meets a value no branch covers is recorded as a **gap**, which is how a tree+tells its author where to grow next.++```+$ pyrtan run "what's the weather in Oslo" --trees examples+trace: weather+ geocode (observe): GET https://geocoding-api.open-meteo.com/v1/search?name=Oslo&… -> 200; hit={…}, lat=59.91, lon=10.75, name=Oslo, country=Norway -> found+ found (decide) [exists True]: state.hit = {…} -> forecast+ forecast (observe): GET https://api.open-meteo.com/v1/forecast?latitude=59.91&… -> 200; temp=4.2, humidity=80, wind=12.3, code=61, unit=°C -> have-temp+ have-temp (decide) [type 'number']: state.temp = 4.2 -> sky+ sky (decide) [between [51, 67]]: state.code = 61 -> rain+ rain (set): sky=raining -> reply+ reply (done): Oslo, Norway: 4.2°C, raining, humidity 80%, wind 12.3 km/h.++Oslo, Norway: 4.2°C, raining, humidity 80%, wind 12.3 km/h.+```++## Install++Python 3.12 or later. No runtime dependencies. Not yet on PyPI; install+from the repository:++```+uv tool install git+https://github.com/b-i-t-8/pyrtan # or: pip install git+https://github.com/b-i-t-8/pyrtan+pyrtan --version+```++From a checkout, `uv sync` then `uv run pyrtan …`, and `uv run pytest`.++## A tree++```toml+name = "github-release"+description = "The latest release of a GitHub repository."+hosts = ["api.github.com"]+start = "fetch"++[[triggers]]+pattern = "[what's the] latest release of {repo:word}"++[inputs.repo]+type = "str"+pattern = "^[A-Za-z0-9_.\\-]+/[A-Za-z0-9_.\\-]+$"++[nodes.fetch]+kind = "observe"+http = { method = "GET", url = "https://api.github.com/repos/{{ inputs.repo }}/releases/latest" }+into = "resp"+extract = [{ into = "tag", json = "json.tag_name" }, { into = "url", json = "json.html_url" }]+next = "status"++[nodes.status]+kind = "decide"+on = "resp.status"+branches = [+ { when = { eq = 200 }, then = "reply" },+ { when = { eq = 404 }, then = "none" },+ { when = { in = [403, 429] }, then = "rate-limited" },+ { else = "unexpected" },+]++[nodes.reply]+kind = "done"+say = "{{ inputs.repo }}: {{ state.tag }} {{ state.url }}"+output = { repo = "{{ inputs.repo }}", tag = "{{ state.tag }}", url = "{{ state.url }}" }++[nodes.none]+kind = "fail"+reason = "no-release"+say = "{{ inputs.repo }} has no releases, or does not exist."++[nodes.rate-limited]+kind = "fail"+reason = "rate-limited"+say = "GitHub is rate-limiting unauthenticated requests."++[nodes.unexpected]+kind = "fail"+reason = "unexpected-response"+say = "GitHub answered {{ state.resp.status }}."+```++Node kinds: `observe` (read; retried), `act` (change; never retried),+`decide` (one value, plain conditions, one branch), `set`, `each` (walk a+list with a limit, through another tree or a local sub-flow), `delegate`+(hand a sentence or a tree name to the swarm), `ask` (pause for an input),+`done` and `fail`. Trees do not loop; `pyrtan check` refuses a cycle. The+whole format is in [`_meta/tree-format.md`](_meta/tree-format.md).++## The command++| Command | What it does |+| :-- | :-- |+| `pyrtan check DIR` | Validates every tree: references, conditions, selectors, hosts, templates, reachability, cycles. Exit 1 on any error. |+| `pyrtan list DIR` | What is loaded and the sentences it answers to. |+| `pyrtan run "…" --trees DIR` | Answers a sentence in this process, printing the trace. `--replay FIXTURE` answers HTTP from a recording; `--record FIXTURE` makes one. |+| `pyrtan run --tree NAME --input k=v` | Runs a named tree. |+| `pyrtan serve DIR --port 8701` | Hosts the trees as a service: `/health`, `/capabilities`, `/intent`, `/run`, `/resume`, `/gaps`. Reloads trees when their files change. |+| `pyrtan hub --port 8700` | Routes between services. Services register with `--hub URL`; peers carry a `link` of `up`, `stale` or `down`. |+| `pyrtan ask "…" --at URL` | Answers a sentence through a running service or hub. |+| `pyrtan gaps --at URL` | Every decision that met a value with no branch: the list of what to write next. |+| `pyrtan new NAME` | Scaffolds a tree file. |++## A swarm++```+pyrtan hub --port 8700+pyrtan serve examples/weather --port 8701 --hub http://127.0.0.1:8700+pyrtan serve examples/hacker-news --port 8702 --hub http://127.0.0.1:8700+pyrtan ask "top 3 stories on hacker news"+```++Open <http://127.0.0.1:8700/> for the demo page: a sentence box, the+answer, the output and the trace, and everything the swarm can answer. To+run the demo on a NAS or any Docker host, see [`deploy/README.md`](deploy/README.md).++A service and a hub answer the same requests, so a caller never needs to+know which it is talking to. A swarm starts as one process holding every+tree and splits into many without the callers changing. A tree that+`delegate`s a sentence it cannot answer itself sends it to its hub, which+routes it to whichever peer can; the peer's steps come back inside the+caller's trace.++## What Pyrtan refuses++- **No model in the loop.** Nothing in this repository calls an LLM. A model+ may one day sit *beside* the swarm, proposing branches for recorded gaps;+ it will not sit inside a run deciding anything.+- **No host a tree did not name.** Every HTTP node's host is checked against+ the tree's `hosts` at load time where it is literal and at run time where+ it is templated.+- **No code in trees.** Templates are paths and filters; conditions are+ comparisons. Branching is only ever a `decide` node, visible in the trace.+- **No loops.** A path through a tree is finite by construction.+- **No silent retries of actions.** An `observe` is retried; an `act` is not.++## Layout++```+src/pyrtan/ the package: tree, intent, template, conditions, extract,+ html, transport, runtime, trace, registry, service, hub, cli+examples/ trees that run against real public APIs, and offline against+ tests/fixtures/examples.json+tests/ pytest; a swarm of two services behind a hub runs on localhost+_meta/ the design record and the tree format reference+deploy/ Dockerfile at the root; a compose stack for OpenMediaVault+ and a systemd unit, with the steps for each+```++## Status++0.1.0. The core, the service, the hub and the command are built and tested.+Not yet built: a browser adapter (for pages that need a script to render),+authentication between services, persistent gap storage, and a tree+marketplace. See [`_meta/design.md`](_meta/design.md) for the sequencing.++Pyrtan is a product of [Hillvon.io](https://hillvon.io).++## License++Pyrtan is free software under the GNU Affero General Public License,+version 3 or (at your option) any later version: `AGPL-3.0-or-later`. The+full text is in [LICENSE](LICENSE).++In short: you may use, change and share it. If you distribute a modified+Pyrtan, or run one as a network service that other people use, you must+offer those users its source under the same license. Trees you write and+run with Pyrtan are your own; they are data the program reads, not part of+the program.diff --git a/_meta/design.md b/_meta/design.md new file mode 100644 index 0000000..2510377 --- /dev/null +++ b/_meta/design.md@@ -0,0 +1,254 @@+# Design — Pyrtan: a swarm of services that act on the web through hardcoded decision trees++| | |+|---|---|+| **Status** | **Built, v0.1.** The core, the service, the hub and the command exist and are tested (111 tests, including a two-service swarm behind a hub on localhost). No deployment, no users, no browser adapter yet. |+| **Date** | 2026-10-24 |+| **Decision** | Pyrtan is an open-source automation tool: many small services, each holding declarative decision trees that read and act on the web, routed by a hub, with no model in the loop and a complete trace of every run. |+| **Where** | This repository: `src/pyrtan`, `examples/`, `tests/`. AGPL-3.0-or-later. |++## 0. The brief, and the reading this design commits to++The brief: *"an open source total automation tool; start over from scratch;+a swarm of services that are AI-LLM-like but with hardcoded decision trees+of the web, similar to Claude skills but much more focused and dynamic."*++Each phrase was read as follows, and the reading is the design.++**"LLM-like."** The interface, not the mechanism. A person or a program+gives Pyrtan a sentence and gets back an answer in words, a structured+output, and an explanation. Inside there is no model. The explanation is+not a model's account of itself; it is the literal path through the tree.++**"Hardcoded decision trees of the web."** A tree is a file an author+writes, naming the hosts it may touch, the requests it makes, the values it+extracts, and the comparisons that choose the next step. "Of the web" means+the tree encodes how one web property behaves: what a 404 means here, which+JSON path holds the title, which weather code means rain. The knowledge a+model would hold implicitly, a tree holds explicitly, for one site.++**"Swarm of services."** A tree is held by a service; a service is a small+process that answers `/intent` and `/run`; a hub routes between services+and reports which are up. The swarm is the unit of deployment and of+ownership: one team can own the trees for one property, run them on their+own box, and register with a shared hub.++**"Similar to Claude skills but much more focused and dynamic."** A skill+is a prose instruction package a model reads and interprets. A tree is+narrower (one thing, one property, no interpretation) and livelier in three+specific ways: a service reloads a tree when its file changes; a tree can+delegate to any other tree in the swarm by sentence or by name, resolved at+run time; and a decision that meets a value no branch covers is recorded as+a *gap*, so the set of trees grows where reality disagreed with its author.++**"Total automation."** Read as the ambition, not as a claim. v0.1 reads+APIs and pages and can POST. What it does not yet do is in Section 7.++## 1. What this design refuses++Four things a reader might expect, and will not find.++- **A model inside a run.** Nothing in `src/pyrtan` calls one. The entire+ value of a tree is that two runs with the same observations take the same+ path, and that the path can be read. A model anywhere in the path makes+ the trace a story rather than a record. A model may later sit *beside*+ the swarm, reading gaps and proposing branches for a person to accept;+ that is an author's tool, not a runtime.+- **Code in trees.** Templates are paths and filters. Conditions are+ comparisons. There is no expression language, no `if` in a template, no+ script node. Everything that branches is a `decide` node, which the trace+ shows. The cost is verbosity (the weather tree spends seven `set` nodes+ turning a code into a word); the benefit is that a tree can be audited by+ someone who does not program.+- **Reaching a host the author did not list.** `hosts` is checked at load+ time for literal URLs and at run time for templated ones. A tree that+ takes a host from its input must say `hosts = ["*"]` out loud.+- **Loops.** A tree is a directed acyclic graph; `pyrtan check` names the+ cycle and refuses. Repetition is `each` over a list with a limit, so+ every run is finite and every trace is complete.++## 2. Invariants++Everything after this section is arranged so that none of these can be+traded away by a convenient feature.++1. **Determinism given observations.** The same tree, inputs and responses+ produce the same path, outcome and trace. Tested by running every+ example against a recorded fixture.+2. **The trace is complete.** Every node visited appears, with what it+ observed and, for a decision, which condition held. Delegated runs+ appear inside the caller's trace one level deeper.+3. **Hosts are closed.** No request leaves to a host outside the tree's+ list, whether the URL was literal or built at run time.+4. **Reads retry; writes do not.** An `observe` may be retried on transport+ failure; an `act` is attempted once. The checker refuses `retries` on an+ act and refuses a write method on an observe.+5. **Absence is decided, not pasted.** A template whose value is missing+ fails the node, except in `done` and `fail`. A tree cannot build a URL+ from an empty string by accident; it must `decide` on `exists`.+6. **A caller cannot tell a service from a hub.** Both answer `/intent`,+ `/run`, `/resume`, `/capabilities`, `/health` and `/gaps` with the same+ shapes, so a swarm can start as one process and split later.++## 3. Architecture as built++```+sentence ──► intent.py ──► registry.py ──► runtime.py ──► Outcome + Trace+ (triggers) (trees, reload) │+ ├─ template.py {{ paths | filters }}+ ├─ conditions.py when = { eq, between, … }+ ├─ extract.py json / regex / css+ │ └─ html.py parser + selector subset+ └─ transport.py urllib | recorded | recording++service.py one process, one registry, HTTP endpoints, hot reload, gaps+hub.py peers, link up/stale/down, routing by capability, forwarding+cli.py check · list · run · ask · serve · hub · gaps · new+```++**The tree** (`tree.py`) is TOML, loaded into dataclasses and checked on+load. `check()` returns problems at three levels; an error refuses the+load. It verifies references, conditions, regexes, selectors, input+declarations against template use, hosts against literal URLs,+reachability, and acyclicity. A `decide` without `else` is an `info`: it is+the author's statement that an unforeseen value should stop the run and be+reported, which is often right.++**Intent matching** (`intent.py`) compiles trigger patterns to regexes.+Specificity is the count of literal characters; the most specific match+wins, and a tie is an `ambiguous` outcome. A pattern with no literal+characters is refused because it would match everything.++**The runtime** (`runtime.py`) is one loop over node kinds. State is a+dictionary; scope adds `inputs`, `item`, `index`, `tree`, `now` and `env`.+`each` runs another tree per item (through the registry, then the+resolver) or a local sub-flow (the same tree from another start node, with+shared state). `delegate` does the same for one sentence or name. `ask`+returns a snapshot the caller can `resume`; only a top-level run can ask,+because a paused sub-run would need a call stack the snapshot does not+carry.++**Transport** (`transport.py`) is a protocol with three implementations:+the standard library's client, a recorded fixture that raises on any+request it did not foresee, and a recorder that writes such fixtures.+Every example tree is tested offline through the second, and+`pyrtan run --record` produces fixtures through the third.++**The service** (`service.py`) wraps a registry in the standard library's+threading HTTP server. It reloads trees from disk on every request that+needs them (a stat per file; cheap), keeps the gaps it has seen, and, when+given a hub, registers itself and forwards unknown trees there.++**The hub** (`hub.py`) holds no trees. It polls peers' `/capabilities`,+derives each peer's `link` from the age of its last good answer (`up` under+30 s, `stale` under 90 s, `down` after), routes a sentence to the peer+whose tree matches best, and forwards. The `link` field is the one thing an+operator reads; it is an idea carried over from an earlier product of+the same name, kept because it was right.++**No dependencies.** The runtime imports only the standard library:+`tomllib`, `html.parser`, `urllib`, `http.server`, `re`, `json`. This was a+deliberate choice over a web framework and an HTML library. A tool that+asks to be installed next to someone's existing automation should not+bring a dependency tree with it, and the HTML selector subset trees+actually need is small enough to own (`html.py`, about 250 lines, tested).+A browser adapter will be an optional extra, not a requirement.++## 4. Paths considered and not taken++**Rust.** The earlier product of this name chose Rust for a 6 MB resident+footprint on site boxes. Pyrtan's services run wherever an operator already+has Python, and its contributors are people who write trees, not people+who write runtimes. Python with no dependencies installs anywhere, and+`uv` makes a checkout usable in one command. If a service ever needs to+run on a constrained box, the tree format is language-neutral and a second+runtime can read the same files.++**YAML or JSON for trees.** TOML has comments, which JSON lacks, and a+standard-library parser, which YAML lacks. Its one trap, top-level keys+after a table header, is caught by the loader with a hint.++**An expression language in templates.** Rejected per Section 1. The+weather tree is longer for it; it is also readable by a non-programmer end+to end.++**A model fallback for unmatched sentences.** The obvious feature, and the+one that would quietly turn the product into the thing it is defined+against. Not built. A sentence no tree answers returns `no-match`, and the+honest response to a frequent `no-match` is a new tree.++**A message bus between services.** NATS or similar would give the swarm+events and fan-out. v0.1 has `act.emit`, which records an event on the+service; nothing consumes it yet. Request/response over HTTP covers every+case the examples needed, and a bus would be a dependency and an+operational component before there is a swarm large enough to need one.++**Persisting gaps.** Gaps live in a service's memory and are lost on+restart. Enough to prove the loop (run, see gap, add branch, reload), not+enough for a fleet. Deferred to Section 7.++## 5. What the examples show++Five trees, all against real public APIs, all tested offline:++| Tree | Shows |+| :-- | :-- |+| `hacker-news` + `hacker-news-item` | `each` over a list through a second tree; a decision on status before one on content; a self-post with no URL handled by `set` rather than by an empty string |+| `weather` | Two hops where the second depends on the first; `exists` deciding on absence; a code-to-word table as seven decisions, not an expression |+| `site-status` | A host from the sentence, so `hosts = ["*"]` and a run-time host check; `between` on status ranges; a client error reported as "up but refusing" rather than "down" |+| `github-release` | Headers; a decision that names rate-limiting as its own branch |+| `picnic` (in the tests) | One service delegating a sentence through the hub to another, and the other's steps returned inside its trace |++## 6. Sequencing++1. **v0.1, built.** Trees, checker, runtime, trace, service, hub, command,+ examples, tests. This document.+2. **v0.2: first real swarm.** Three or more services on separate boxes+ behind one hub, with a status page off `/peers`. Gaps persisted to a+ file per service and collected by the hub. A `pyrtan watch` that+ re-runs a set of sentences on a schedule and reports when a path changes+ (the way to learn a site has changed before a person does).+3. **v0.3: browser adapter.** `observe` with `browser = { url, wait_for,+ actions }` on Playwright as an optional extra, for pages that need a+ script to render, with the same host rule and the same extraction.+4. **v0.4: trees as a shared thing.** A directory of trees people can pull+ by name, with the checker run on contribution, and `version` meaning+ something.+5. **Beside the swarm, never inside it:** an authoring aid that reads gaps+ and drafts the branch for a person to accept.++## 7. Open questions++- **Authentication between services.** None in v0.1; the swarm is assumed+ to sit on a trusted network behind an operator's ingress. The first+ swarm that crosses a network boundary needs a shared secret or mTLS at+ the hub, and `env` already carries per-service secrets for upstream APIs.+- **What a tree's `version` governs.** A hub could prefer the highest+ version of a name across peers, or refuse to route to a peer whose+ version is below the caller's. Undecided until two services hold the+ same tree at different versions.+- **Whether `ask` belongs in a swarm at all.** A pause needs a place to+ keep the snapshot; v0.1 hands it back to the caller. That is right for a+ command and awkward for a service behind a hub. A `pending` store on the+ hub is the likely answer.+- **How far `html.py` should go.** The selector subset covers every example+ and every test. Sites that need `:not()`, sibling combinators or+ attribute case-folding will push on it; the alternative is an optional+ dependency on a full selector engine, which the no-dependency rule+ allows as an extra but not as a requirement.++## 8. What this design does not claim++It does not claim to answer sentences nobody wrote a tree for. It does not+claim trees are quick to write: the weather tree is 15 nodes for a two-call+API, and that is the price of leaving nothing implicit. It does not claim+the swarm is secure across a network boundary (Section 7). It does not+claim the examples will keep working as the sites they read change; it+claims that when they stop, the trace will say exactly where, and a gap+will say what was seen.++## The name++The name Pyrtan was used earlier for an unrelated building-management+product. None of its code is in this repository. The one idea carried over+is that a fleet's health is one pollable field, which is the hub's `link`.diff --git a/_meta/tree-format.md b/_meta/tree-format.md new file mode 100644 index 0000000..2705f56 --- /dev/null +++ b/_meta/tree-format.md@@ -0,0 +1,230 @@+# The tree format++A tree is one TOML file. This is the whole of it.++## Top level++| Key | Required | Meaning |+| :-- | :-- | :-- |+| `name` | yes | Lowercase letters, digits and dashes. The tree's identity in the swarm. |+| `description` | should | One line. Shown by `pyrtan list` and in `/capabilities`. |+| `version` | no | Positive integer, default 1. |+| `hosts` | if any node speaks HTTP | The hosts the tree may reach: exact names, `*.example.com`, or `*` for a tree whose host comes from its input. |+| `start` | yes | The first node. |+| `tags` | no | Free strings, for people and for the hub's listing. |+| `triggers` | no | Sentences the tree answers to (below). |+| `inputs` | no | What the tree needs (below). |+| `nodes` | yes | The nodes (below). |++TOML puts top-level keys before the first `[table]`; `start` after+`[inputs.x]` lands inside `inputs.x`, and the loader says so.++## Triggers++```toml+[[triggers]]+pattern = "[what's the] weather in {place}"++[[triggers]]+pattern = "top {count:int} stories on hacker news"+inputs = { count = 1 } # fixed inputs this sentence implies+```++A slot is `{name}` (any text), `{name:int}`, `{name:float}` or `{name:word}`+(no spaces). Every slot must be a declared input. `[words]` are optional;+`(a|b)` is a required choice; `|` separates alternatives inside either.+Matching ignores case, surrounding punctuation and repeated spaces; a slot's+text keeps its case. A pattern needs at least one literal word.++When several trees match a sentence, the trigger with the most literal+characters wins. An exact tie is answered as `ambiguous`, naming the+candidates, rather than resolved by load order.++## Inputs++```toml+[inputs.count]+type = "int" # str | int | float | bool | list | dict+default = 1 # an input with a default is optional+min = 1+max = 30+one_of = [1, 5, 10]+pattern = "^[a-z]+$" # for str+description = "How many"+required = false # optional with no default: present as null+```++Inputs are coerced (`"5"` to 5, `"yes"` to true) and checked before the+first node runs; a bad input is a `bad-input` error, never a half run.++## Nodes++Every node has `kind`. Every non-terminal node except `decide` has `next`.+Node ids are lowercase letters, digits and dashes.++### `observe`: read++```toml+[nodes.fetch]+kind = "observe"+http = { method = "GET", url = "https://api.example.com/x/{{ inputs.id }}", headers = { Accept = "application/json" } }+into = "resp" # state.resp = { status, ok, headers, body, json, url, elapsed_ms }+extract = [+ { into = "title", json = "json.title" },+ { into = "price", regex = "\\$([0-9.]+)", group = 1 },+ { into = "heading", css = "h1" },+ { into = "links", css = "a.story", attr = "href", all = true },+ { into = "rows", css = "table tr", all = true, fields = { name = "td:nth-child(1)", href = { css = "a", attr = "href" } } },+]+timeout = 10 # seconds+retries = 1 # on transport failure only; never on a status code+next = "status"+```++`method` must be GET, HEAD or OPTIONS. Instead of `http`, `from = "resp"`+extracts from state already held. An extraction that finds nothing sets+nothing; decide on absence with `{ exists = false }`.++Selectors support tag, `#id`, `.class`, `[attr]`, `[attr=v]`, `[attr^=v]`,+`[attr$=v]`, `[attr*=v]`, `:first-child`, `:last-child`, `:nth-child(n)`,+`:contains(text)`, the descendant and `>` combinators, and `,` groups.+Anything else fails `pyrtan check`.++### `act`: change++Same shape as `observe` with any method, or an event:++```toml+[nodes.notify]+kind = "act"+emit = { event = "order.placed", data = { id = "{{ state.order.id }}" } }+into = "sent"+next = "reply"+```++An act is never retried. A GET with no body in an act is a warning: it is a+read, and `observe` would retry it safely.++### `decide`: branch++```toml+[nodes.status]+kind = "decide"+on = "resp.status" # a state path; "resp.status" means "state.resp.status"+branches = [+ { when = { eq = 200 }, then = "reply" },+ { when = { between = [500, 599] }, then = "down" },+ { else = "unexpected" },+]+```++Conditions, all of which must hold within one `when`: `eq`, `ne`, `lt`,+`lte`, `gt`, `gte`, `between = [lo, hi]`, `matches = "regex"`, `contains`,+`startswith`, `endswith`, `in = [...]`, `exists = true|false`,+`empty = true|false`, `type = "str|int|float|number|bool|list|dict|null"`.+Numbers compare loosely with numeric strings; booleans compare strictly.++The first branch whose condition holds is taken. `else` must be last. A+`decide` with no `else` that meets a value no branch covers records a+**gap** (`tree`, `node`, `on`, `observed`) and ends the run with reason+`gap`. `pyrtan gaps --at URL` lists them.++### `set`: compute++```toml+[nodes.self-post]+kind = "set"+set = { url = "https://news.ycombinator.com/item?id={{ inputs.id }}", n = "{{ state.ids | length }}" }+next = "reply"+```++### `each`: walk a list++```toml+[nodes.stories]+kind = "each"+over = "ids"+limit = "{{ inputs.count }}"+tree = "hacker-news-item" # run this tree per item …+with = { id = "{{ item }}" }+into = "stories" # … collecting each output (null where one failed)+stop_on_fail = false+next = "reply"+```++Or `start = "node"` instead of `tree`, to run a sub-flow of this tree per+item with `{{ item }}` and `{{ index }}` in scope, ending at the sub-flow's+first `done` (its `output` is collected) or `fail`. The sub-flow shares the+run's state. An `each` without `limit` is a warning.++### `delegate`: ask the swarm++```toml+[nodes.weather]+kind = "delegate"+intent = "weather in {{ inputs.place }}" # or: tree = "weather", with = { place = "…" }+into = "w" # state.w = { status, say, output, reason, tree }+next = "check"+```++A delegate is resolved locally first, then through the service's hub. The+delegated run's steps appear in the caller's trace one level deeper. A+delegated tree cannot `ask`.++### `ask`: pause for a person++```toml+[nodes.which]+kind = "ask"+prompt = "Which city?"+into = "inputs.city" # or a state path+next = "geocode"+```++The run returns `asked` with the prompt and a snapshot; `/resume` (or+`pyrtan run` interactively) continues it. Only a top-level run can ask.++### `done` and `fail`: finish++```toml+[nodes.reply]+kind = "done"+say = "{{ state.name }}: {{ state.temp }}°"+output = { place = "{{ state.name }}", temperature = "{{ state.temp }}" }++[nodes.unknown-place]+kind = "fail"+reason = "unknown-place" # a short code, for callers+say = "I could not find {{ inputs.place }}."+```++In `say` and `output` a missing value renders as empty rather than failing+the run, since the run is over.++## Templates++`{{ path }}` with optional filters: `{{ inputs.city | url }}`. Roots:+`inputs`, `state`, `item`, `index` (inside `each`), `tree` (`name`,+`version`), `now` (`iso`, `date`, `epoch`, `year`), `env` (values the+service was started with, for tokens). Paths take `.key`, `[0]`, `[-1]`,+`["key"]`.++Filters: `url`, `json`, `int`, `float`, `str`, `lower`, `upper`, `strip`,+`title`, `first`, `last`, `length`.++A string that is exactly one placeholder keeps the value's type (a list+stays a list). A placeholder that resolves to nothing fails the node, except+in `done` and `fail`, so a tree must decide on absence rather than paste an+empty string into a URL.++## Outcomes++| `status` | Meaning | Carries |+| :-- | :-- | :-- |+| `done` | a `done` node was reached | `say`, `output` |+| `failed` | a `fail` node was reached | `reason`, `say` |+| `asked` | an `ask` node paused the run | `question`, `snapshot` |+| `error` | the run could not proceed | `reason`: `no-match`, `ambiguous`, `no-such-tree`, `bad-input`, `host-not-allowed`, `transport`, `gap`, `template`, `too-deep`, `too-many-steps`, `not-a-list`, `each-failed`, `ask-in-delegate`, `ask-in-subflow`, `dead-end` |++Every outcome carries `trace`: `steps` (node, kind, summary, observed,+chosen, next, elapsed_ms, depth) and `gaps`.diff --git a/deploy/README.md b/deploy/README.md new file mode 100644 index 0000000..53307b1 --- /dev/null +++ b/deploy/README.md@@ -0,0 +1,115 @@+# Running the demo on OpenMediaVault++Two ways. Docker through OMV's Compose plugin is the one that fits the box;+the systemd unit is for running it bare on the host.++Replace `<nas-ip>` below with your NAS's address on the LAN. Both end with+the demo page at `http://<nas-ip>:8700/` (Docker) or `http://<nas-ip>:8701/`+(systemd): a sentence box, the answer, the+output, and the trace, plus the list of everything the swarm can answer.++## Docker, through the Compose plugin++The stack in [`openmediavault/docker-compose.yml`](openmediavault/docker-compose.yml)+runs four containers: a **hub** on port 8700, two example services+(**news**: Hacker News and GitHub; **weather**: Open-Meteo and site status)+that register with the hub over the compose network, and **mine**, which+serves a folder of your own trees from a shared folder and reloads them+when you edit a file.++1. **Install the plugin**, if it is not there: in OMV, *System › Plugins*,+ install `openmediavault-compose` (needs omv-extras on OMV 6/7). It+ installs Docker.+2. **Pick a shared folder** for the files. Say the share is `appdata` on+ the data disk; on the host that is a path like+ `/srv/dev-disk-by-uuid-…/appdata`. Find it under *Storage › Shared+ Folders*, or run `ls /srv` over SSH.+3. **Clone the repository there**, over SSH on the NAS:++ ```+ cd /srv/dev-disk-by-uuid-…/appdata+ git clone https://github.com/b-i-t-8/pyrtan.git+ mkdir -p pyrtan/trees+ ```++ `pyrtan/trees` is the folder **mine** watches. Copy an example in to+ start: `cp pyrtan/examples/site-status/site-status.toml pyrtan/trees/`.+4. **Add the stack**: *Services › Compose › Files › Add*. Name it `pyrtan`,+ paste the compose file, and replace both `CHANGE-ME` paths with the path+ from step 2 (the checkout at `…/appdata/pyrtan`, the trees folder at+ `…/appdata/pyrtan/trees`). Save.+5. **Up**: select the file, press *Up*. The first time it builds the image+ on the NAS, which takes a minute or two.+6. **Open** `http://<nas-ip>:8700/`. The page should say `4/4 peers+ up` within ten seconds (the hub polls every ten). Try "top 3 stories on+ hacker news".++To update: `git pull` in the checkout, then *Down* and *Up* with *Build*+ticked (or `docker compose -p pyrtan up -d --build` over SSH). To check a+tree you dropped into `trees/` before it is live:+`docker compose -p pyrtan exec mine pyrtan check /trees`. To see gaps the+demo has met: `http://<nas-ip>:8700/gaps`.++The services do not expose their own ports; everything goes through the+hub on 8700. Nothing authenticates: this is a demo for your LAN. Do not+forward 8700 to the internet.++### Without the plugin++Over SSH, from the checkout:++```+cd /srv/dev-disk-by-uuid-…/appdata/pyrtan+cp deploy/openmediavault/docker-compose.yml .+sed -i "s#/srv/dev-disk-by-uuid-CHANGE-ME/pyrtan#$PWD#g" docker-compose.yml+docker compose up -d --build+```++### One container only++If you want a single process and no swarm:++```+docker build -t pyrtan:local .+docker run -d --name pyrtan -p 8701:8701 --restart unless-stopped pyrtan:local+```++It serves the examples at `http://<nas-ip>:8701/`.++## Bare, with systemd++For a host with no Docker. Pyrtan needs Python 3.12+ and OMV 7 ships 3.11,+so `uv` fetches an interpreter. Everything is installed under `/opt/pyrtan`+and run by a system user with no home directory.++```+curl -LsSf https://astral.sh/uv/install.sh | sudo env UV_INSTALL_DIR=/usr/local/bin sh+sudo env UV_TOOL_DIR=/opt/pyrtan/tools UV_TOOL_BIN_DIR=/opt/pyrtan/bin \+ UV_PYTHON_INSTALL_DIR=/opt/pyrtan/python \+ uv tool install --python 3.12 git+https://github.com/b-i-t-8/pyrtan+sudo useradd --system --no-create-home --shell /usr/sbin/nologin pyrtan+sudo mkdir -p /var/lib/pyrtan/trees+sudo cp -r /path/to/pyrtan/examples/* /var/lib/pyrtan/trees/+sudo chown -R pyrtan:pyrtan /var/lib/pyrtan+sudo cp deploy/systemd/pyrtan-demo.service /etc/systemd/system/+sudo systemctl daemon-reload+sudo systemctl enable --now pyrtan-demo+curl http://127.0.0.1:8701/health+```++The unit runs one process holding every tree in `/var/lib/pyrtan/trees` on+port 8701, hardened to read its trees and reach the network and nothing+else. Logs: `journalctl -u pyrtan-demo -f`. To update, run the+same `uv tool install` line with `--force`, then+`sudo systemctl restart pyrtan-demo`.++## What the demo shows++- A sentence routed by the hub to the service whose tree answers it, and+ the trace of every observation and decision on the way back.+- A tree in one service delegating to a tree in another: ask+ "top 3 stories on hacker news" and watch `hacker-news` on **news** call+ `hacker-news-item` three times.+- Hot reload: edit a file in `trees/` from the share and ask again.+- Gaps: a decision that met a value no branch covers, at `/gaps`.+- Health as one field: `/peers` on the hub shows each service's `link`.diff --git a/deploy/openmediavault/docker-compose.yml b/deploy/openmediavault/docker-compose.yml new file mode 100644 index 0000000..2fad1c3 --- /dev/null +++ b/deploy/openmediavault/docker-compose.yml@@ -0,0 +1,58 @@+# Pyrtan demo swarm for OpenMediaVault (or any Docker host).+#+# One hub, reachable from the LAN, and two services behind it that each+# hold part of the examples, so the demo shows routing between services+# rather than one process. A third service holds a trees/ folder you can+# edit on the NAS: drop a .toml in and it is live on the next request.+#+# Edit the two CHANGE-ME paths (the checkout, and a trees folder) to match+# your shared folder, then in OMV: Services > Compose > Files > Add (paste+# this) > Up. Steps in ../README.md.+#+# Open http://<nas-ip>:8700/ for the demo page.++name: pyrtan++x-pyrtan: &pyrtan+ # Built on the NAS from a checkout of the repository (no image is+ # published yet). The context is the repository root.+ build:+ context: /srv/dev-disk-by-uuid-CHANGE-ME/pyrtan+ image: pyrtan:local+ restart: unless-stopped+ read_only: true+ security_opt: [no-new-privileges:true]+ cap_drop: [ALL]+ tmpfs: [/tmp]+ logging:+ driver: json-file+ options: { max-size: "5m", max-file: "3" }++services:+ hub:+ <<: *pyrtan+ command: ["hub", "--host", "0.0.0.0", "--port", "8700", "--poll", "10"]+ environment:+ PYRTAN_PORT: "8700"+ ports:+ - "8700:8700"++ news:+ <<: *pyrtan+ command: ["serve", "/examples/hacker-news", "/examples/github", "--host", "0.0.0.0", "--port", "8701", "--name", "news", "--hub", "http://hub:8700", "--advertise", "http://news:8701"]+ depends_on: [hub]++ weather:+ <<: *pyrtan+ command: ["serve", "/examples/weather", "/examples/site-status", "--host", "0.0.0.0", "--port", "8701", "--name", "weather", "--hub", "http://hub:8700", "--advertise", "http://weather:8701"]+ depends_on: [hub]++ # Your own trees. Edit files in this folder from a share; the service+ # reloads them without a restart. `pyrtan check` them first:+ # docker compose -p pyrtan exec mine pyrtan check /trees+ mine:+ <<: *pyrtan+ command: ["serve", "/trees", "--host", "0.0.0.0", "--port", "8701", "--name", "mine", "--hub", "http://hub:8700", "--advertise", "http://mine:8701"]+ volumes:+ - /srv/dev-disk-by-uuid-CHANGE-ME/pyrtan/trees:/trees:ro+ depends_on: [hub]diff --git a/deploy/systemd/pyrtan-demo.service b/deploy/systemd/pyrtan-demo.service new file mode 100644 index 0000000..3246d60 --- /dev/null +++ b/deploy/systemd/pyrtan-demo.service@@ -0,0 +1,38 @@+# Pyrtan demo as one systemd service, without Docker: a single process+# holding every example tree, serving the demo page on port 8701.+#+# sudo cp pyrtan-demo.service /etc/systemd/system/+# sudo systemctl daemon-reload && sudo systemctl enable --now pyrtan-demo+#+# Assumes pyrtan was installed under /opt/pyrtan as deploy/README.md+# describes, so the command lives at /opt/pyrtan/bin/pyrtan. Trees are read+# from /var/lib/pyrtan/trees; edit TREES to taste.++[Unit]+Description=Pyrtan demo swarm (single process)+After=network-online.target+Wants=network-online.target++[Service]+Type=simple+User=pyrtan+Group=pyrtan+Environment=PYTHONUNBUFFERED=1+Environment=TREES=/var/lib/pyrtan/trees+ExecStart=/opt/pyrtan/bin/pyrtan serve ${TREES} --host 0.0.0.0 --port 8701 --name demo+Restart=on-failure+RestartSec=3+# Hardening: the process needs the network and read access to its trees, nothing else.+NoNewPrivileges=true+ProtectSystem=strict+ProtectHome=true+ReadOnlyPaths=/opt/pyrtan /var/lib/pyrtan+PrivateTmp=true+ProtectKernelTunables=true+ProtectControlGroups=true+RestrictSUIDSGID=true+LockPersonality=true+MemoryMax=256M++[Install]+WantedBy=multi-user.targetdiff --git a/examples/github/github-release.toml b/examples/github/github-release.toml new file mode 100644 index 0000000..1ce20ef --- /dev/null +++ b/examples/github/github-release.toml@@ -0,0 +1,62 @@+# Latest release of a GitHub repository, from the public API. Shows an+# optional token from the service's env and a decision on the status code.++name = "github-release"+version = 1+description = "The latest release of a GitHub repository."+tags = ["github", "read-only"]+hosts = ["api.github.com"]+start = "fetch"++[[triggers]]+pattern = "[what's the] latest release of {repo:word}"+[[triggers]]+pattern = "latest {repo:word} release"++[inputs.repo]+type = "str"+pattern = "^[A-Za-z0-9_.\\-]+/[A-Za-z0-9_.\\-]+$"+description = "owner/name"++[nodes.fetch]+kind = "observe"+http = { method = "GET", url = "https://api.github.com/repos/{{ inputs.repo }}/releases/latest", headers = { Accept = "application/vnd.github+json" } }+into = "resp"+extract = [+ { into = "tag", json = "json.tag_name" },+ { into = "name", json = "json.name" },+ { into = "published", json = "json.published_at" },+ { into = "url", json = "json.html_url" },+ { into = "prerelease", json = "json.prerelease" },+]+next = "status"++[nodes.status]+kind = "decide"+on = "resp.status"+branches = [+ { when = { eq = 200 }, then = "reply" },+ { when = { eq = 404 }, then = "none" },+ { when = { in = [403, 429] }, then = "rate-limited" },+ { else = "unexpected" },+]++[nodes.reply]+kind = "done"+say = "{{ inputs.repo }}: {{ state.tag }} ({{ state.published }}) {{ state.url }}"+output = { repo = "{{ inputs.repo }}", tag = "{{ state.tag }}", name = "{{ state.name }}", published = "{{ state.published }}", url = "{{ state.url }}", prerelease = "{{ state.prerelease }}" }++[nodes.none]+kind = "fail"+reason = "no-release"+say = "{{ inputs.repo }} has no releases, or does not exist."++[nodes.rate-limited]+kind = "fail"+reason = "rate-limited"+say = "GitHub is rate-limiting unauthenticated requests ({{ state.resp.status }})."++[nodes.unexpected]+kind = "fail"+reason = "unexpected-response"+say = "GitHub answered {{ state.resp.status }}."diff --git a/examples/hacker-news/hacker-news-item.toml b/examples/hacker-news/hacker-news-item.toml new file mode 100644 index 0000000..19e50d5 --- /dev/null +++ b/examples/hacker-news/hacker-news-item.toml@@ -0,0 +1,65 @@+name = "hacker-news-item"+version = 1+description = "One Hacker News item by id: title, url, score, author."+tags = ["news", "read-only"]+hosts = ["hacker-news.firebaseio.com"]+start = "fetch"++[[triggers]]+pattern = "hacker news item {id:int}"++[inputs.id]+type = "int"+min = 1+description = "The item id"++[nodes.fetch]+kind = "observe"+http = { method = "GET", url = "https://hacker-news.firebaseio.com/v0/item/{{ inputs.id }}.json" }+into = "resp"+extract = [+ { into = "title", json = "json.title" },+ { into = "url", json = "json.url" },+ { into = "score", json = "json.score" },+ { into = "by", json = "json.by" },+ { into = "type", json = "json.type" },+]+next = "kind"++[nodes.kind]+kind = "decide"+on = "type"+branches = [+ { when = { eq = "story" }, then = "has-url" },+ { when = { eq = "job" }, then = "has-url" },+ { when = { exists = false }, then = "missing" },+ { else = "not-a-story" },+]++[nodes.has-url]+kind = "decide"+on = "url"+branches = [+ { when = { exists = true }, then = "reply" },+ { else = "self-post" },+]++[nodes.self-post]+kind = "set"+set = { url = "https://news.ycombinator.com/item?id={{ inputs.id }}" }+next = "reply"++[nodes.reply]+kind = "done"+say = "{{ state.title }} ({{ state.score }} points, by {{ state.by }}) {{ state.url }}"+output = { id = "{{ inputs.id }}", title = "{{ state.title }}", url = "{{ state.url }}", score = "{{ state.score }}", by = "{{ state.by }}" }++[nodes.missing]+kind = "fail"+reason = "no-such-item"+say = "There is no Hacker News item {{ inputs.id }}."++[nodes.not-a-story]+kind = "fail"+reason = "not-a-story"+say = "Item {{ inputs.id }} is a {{ state.type }}, not a story."diff --git a/examples/hacker-news/hacker-news.toml b/examples/hacker-news/hacker-news.toml new file mode 100644 index 0000000..37ba031 --- /dev/null +++ b/examples/hacker-news/hacker-news.toml@@ -0,0 +1,78 @@+# Hacker News, through its public Firebase API. Two trees: this one answers+# the question, and `hacker-news-item` fetches one story, so that `each`+# can reuse it.++name = "hacker-news"+version = 1+description = "Top stories on Hacker News, from its public API."+tags = ["news", "read-only"]+hosts = ["hacker-news.firebaseio.com"]+start = "fetch-ids"++[[triggers]]+pattern = "[what's the] top story on hacker news"+inputs = { count = 1 }++[[triggers]]+pattern = "top {count:int} stories on hacker news"++[inputs.count]+type = "int"+default = 1+min = 1+max = 30+description = "How many stories to return"++[nodes.fetch-ids]+kind = "observe"+http = { method = "GET", url = "https://hacker-news.firebaseio.com/v0/topstories.json" }+into = "ids_resp"+extract = [{ into = "ids", json = "json" }]+next = "check"++[nodes.check]+kind = "decide"+on = "ids_resp.status"+branches = [+ { when = { eq = 200 }, then = "any" },+ { when = { gte = 500 }, then = "hn-down" },+ { else = "unexpected" },+]++[nodes.any]+kind = "decide"+on = "ids"+branches = [+ { when = { empty = true }, then = "nothing" },+ { when = { type = "list" }, then = "stories" },+ { else = "unexpected" },+]++[nodes.stories]+kind = "each"+over = "ids"+limit = "{{ inputs.count }}"+tree = "hacker-news-item"+with = { id = "{{ item }}" }+into = "stories"+next = "reply"++[nodes.reply]+kind = "done"+say = "Top of Hacker News: {{ state.stories[0].title }} — {{ state.stories[0].url }}"+output = { stories = "{{ state.stories }}" }++[nodes.nothing]+kind = "fail"+reason = "no-stories"+say = "Hacker News returned an empty front page."++[nodes.hn-down]+kind = "fail"+reason = "upstream-down"+say = "Hacker News answered {{ state.ids_resp.status }}; try again later."++[nodes.unexpected]+kind = "fail"+reason = "unexpected-response"+say = "Hacker News answered {{ state.ids_resp.status }} with something this tree does not recognise."diff --git a/examples/site-status/site-status.toml b/examples/site-status/site-status.toml new file mode 100644 index 0000000..49a59c3 --- /dev/null +++ b/examples/site-status/site-status.toml@@ -0,0 +1,61 @@+# Is a site up? The host comes from the sentence, so it cannot be checked+# at load time; the runtime refuses anything outside `hosts`, and this tree+# allows any host on purpose, with the wildcard spelled out.++name = "site-status"+version = 1+description = "Whether a web site answers, and how quickly."+tags = ["ops", "read-only"]+hosts = ["*"]+start = "probe"++[[triggers]]+pattern = "is {host:word} up"+[[triggers]]+pattern = "is {host:word} down"+[[triggers]]+pattern = "[check] status of {host:word}"++[inputs.host]+type = "str"+pattern = "(?i)^[a-z0-9.\\-]+(:[0-9]+)?$"+description = "A host name, with no scheme"++[nodes.probe]+kind = "observe"+http = { method = "GET", url = "https://{{ inputs.host | lower }}/" }+into = "resp"+timeout = 8+retries = 1+next = "status"++[nodes.status]+kind = "decide"+on = "resp.status"+branches = [+ { when = { between = [200, 399] }, then = "up" },+ { when = { between = [400, 499] }, then = "refusing" },+ { when = { gte = 500 }, then = "erroring" },+ { else = "odd" },+]++[nodes.up]+kind = "done"+say = "{{ inputs.host }} is up: {{ state.resp.status }} in {{ state.resp.elapsed_ms }} ms."+output = { host = "{{ inputs.host }}", up = true, status = "{{ state.resp.status }}", elapsed_ms = "{{ state.resp.elapsed_ms }}" }++[nodes.refusing]+kind = "done"+say = "{{ inputs.host }} answers but refuses the front page ({{ state.resp.status }}); the server is up."+output = { host = "{{ inputs.host }}", up = true, status = "{{ state.resp.status }}", note = "client error on /" }++[nodes.erroring]+kind = "fail"+reason = "server-error"+say = "{{ inputs.host }} is answering {{ state.resp.status }}: up, but broken."+output = { host = "{{ inputs.host }}", up = false, status = "{{ state.resp.status }}" }++[nodes.odd]+kind = "fail"+reason = "unexpected-status"+say = "{{ inputs.host }} answered {{ state.resp.status }}, which this tree has no branch for."diff --git a/examples/weather/weather.toml b/examples/weather/weather.toml new file mode 100644 index 0000000..032d624 --- /dev/null +++ b/examples/weather/weather.toml@@ -0,0 +1,128 @@+# Weather for a place, from Open-Meteo (no key needed). Two hops: geocode+# the name, then fetch the forecast for the coordinates. Each hop has its+# own decision so a failure says which hop failed.++name = "weather"+version = 1+description = "Current weather for a place, from Open-Meteo."+tags = ["weather", "read-only"]+hosts = ["geocoding-api.open-meteo.com", "api.open-meteo.com"]+start = "geocode"++[[triggers]]+pattern = "[what's the] weather in {place}"+[[triggers]]+pattern = "[what's the] weather for {place}"+[[triggers]]+pattern = "how [hot|cold|warm] is it in {place}"++[inputs.place]+type = "str"+description = "A town or city name"++[inputs.units]+type = "str"+default = "celsius"+one_of = ["celsius", "fahrenheit"]++[nodes.geocode]+kind = "observe"+http = { method = "GET", url = "https://geocoding-api.open-meteo.com/v1/search?name={{ inputs.place | url }}&count=1&language=en&format=json" }+into = "geo"+extract = [+ { into = "hit", json = "json.results[0]" },+ { into = "lat", json = "json.results[0].latitude" },+ { into = "lon", json = "json.results[0].longitude" },+ { into = "name", json = "json.results[0].name" },+ { into = "country", json = "json.results[0].country" },+]+next = "found"++[nodes.found]+kind = "decide"+on = "hit"+branches = [+ { when = { exists = true }, then = "forecast" },+ { else = "unknown-place" },+]++[nodes.forecast]+kind = "observe"+http = { method = "GET", url = "https://api.open-meteo.com/v1/forecast?latitude={{ state.lat }}&longitude={{ state.lon }}¤t=temperature_2m,relative_humidity_2m,wind_speed_10m,weather_code&temperature_unit={{ inputs.units }}" }+into = "fc"+extract = [+ { into = "temp", json = "json.current.temperature_2m" },+ { into = "humidity", json = "json.current.relative_humidity_2m" },+ { into = "wind", json = "json.current.wind_speed_10m" },+ { into = "code", json = "json.current.weather_code" },+ { into = "unit", json = "json.current_units.temperature_2m" },+]+next = "have-temp"++[nodes.have-temp]+kind = "decide"+on = "temp"+branches = [+ { when = { type = "number" }, then = "sky" },+ { else = "no-forecast" },+]++# WMO weather codes, folded into words a person would use.+[nodes.sky]+kind = "decide"+on = "code"+branches = [+ { when = { eq = 0 }, then = "clear" },+ { when = { between = [1, 3] }, then = "cloudy" },+ { when = { between = [45, 48] }, then = "fog" },+ { when = { between = [51, 67] }, then = "rain" },+ { when = { between = [71, 77] }, then = "snow" },+ { when = { between = [80, 82] }, then = "rain" },+ { when = { between = [85, 86] }, then = "snow" },+ { when = { between = [95, 99] }, then = "storm" },+ { else = "unknown-sky" },+]++[nodes.clear]+kind = "set"+set = { sky = "clear" }+next = "reply"+[nodes.cloudy]+kind = "set"+set = { sky = "cloudy" }+next = "reply"+[nodes.fog]+kind = "set"+set = { sky = "foggy" }+next = "reply"+[nodes.rain]+kind = "set"+set = { sky = "raining" }+next = "reply"+[nodes.snow]+kind = "set"+set = { sky = "snowing" }+next = "reply"+[nodes.storm]+kind = "set"+set = { sky = "stormy" }+next = "reply"+[nodes.unknown-sky]+kind = "set"+set = { sky = "weather code {{ state.code }}" }+next = "reply"++[nodes.reply]+kind = "done"+say = "{{ state.name }}, {{ state.country }}: {{ state.temp }}{{ state.unit }}, {{ state.sky }}, humidity {{ state.humidity }}%, wind {{ state.wind }} km/h."+output = { place = "{{ state.name }}", country = "{{ state.country }}", temperature = "{{ state.temp }}", unit = "{{ state.unit }}", sky = "{{ state.sky }}", humidity = "{{ state.humidity }}", wind_kmh = "{{ state.wind }}" }++[nodes.unknown-place]+kind = "fail"+reason = "unknown-place"+say = "I could not find a place called {{ inputs.place }}."++[nodes.no-forecast]+kind = "fail"+reason = "no-forecast"+say = "Open-Meteo found {{ state.name }} but returned no current temperature (status {{ state.fc.status }})."diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..f9ceebc --- /dev/null +++ b/pyproject.toml@@ -0,0 +1,42 @@+[project]+name = "pyrtan"+version = "0.1.0"+description = "Open-source total automation: a swarm of small services that act on the web through hardcoded, inspectable decision trees."+readme = "README.md"+license = "AGPL-3.0-or-later"+license-files = ["LICENSE"]+requires-python = ">=3.12"+dependencies = []+keywords = ["automation", "decision-tree", "swarm", "agents", "web"]+classifiers = [+ "Development Status :: 3 - Alpha",+ "Intended Audience :: Developers",+ "Programming Language :: Python :: 3",+ "Programming Language :: Python :: 3.12",+ "Programming Language :: Python :: 3.13",+ "Topic :: Software Development :: Libraries",+ "Topic :: Internet :: WWW/HTTP",+]++[project.urls]+Repository = "https://github.com/b-i-t-8/pyrtan"++[project.scripts]+pyrtan = "pyrtan.cli:main"++[project.optional-dependencies]+browser = ["playwright>=1.45"]++[dependency-groups]+dev = ["pytest>=8"]++[build-system]+requires = ["hatchling"]+build-backend = "hatchling.build"++[tool.hatch.build.targets.wheel]+packages = ["src/pyrtan"]++[tool.pytest.ini_options]+testpaths = ["tests"]+addopts = "-q"diff --git a/src/pyrtan/__init__.py b/src/pyrtan/__init__.py new file mode 100644 index 0000000..68cfaf5 --- /dev/null +++ b/src/pyrtan/__init__.py@@ -0,0 +1,34 @@+"""Pyrtan: a swarm of small services that act on the web through hardcoded,+inspectable decision trees.++The public surface is small on purpose:++- :func:`pyrtan.load_tree` reads one tree file.+- :class:`pyrtan.Registry` holds the trees a service knows about.+- :class:`pyrtan.Runner` executes a tree against a transport and returns an+ :class:`pyrtan.Outcome` with a full :class:`pyrtan.Trace`.+"""++from .tree import Tree, load_tree, load_tree_text, TreeError+from .registry import Registry+from .runtime import Runner, Outcome+from .trace import Trace, Step+from .transport import Transport, UrllibTransport, RecordedTransport, Response++__all__ = [+ "Tree",+ "TreeError",+ "load_tree",+ "load_tree_text",+ "Registry",+ "Runner",+ "Outcome",+ "Trace",+ "Step",+ "Transport",+ "UrllibTransport",+ "RecordedTransport",+ "Response",+]++__version__ = "0.1.0"diff --git a/src/pyrtan/cli.py b/src/pyrtan/cli.py new file mode 100644 index 0000000..cd5bb0c --- /dev/null +++ b/src/pyrtan/cli.py@@ -0,0 +1,337 @@+"""The ``pyrtan`` command.++ pyrtan check [PATH ...] validate trees+ pyrtan list [PATH ...] what is loaded and what it answers to+ pyrtan run TEXT [--trees PATH] answer a sentence, in this process+ pyrtan run --tree NAME --input k=v run a named tree+ pyrtan ask TEXT --at URL answer a sentence through a service or hub+ pyrtan serve [PATH ...] [--port N] host trees as a service+ pyrtan hub [--peer URL ...] route between services+ pyrtan gaps [PATH ... | --at URL] branches waiting to be written+ pyrtan new NAME [--dir PATH] scaffold a tree file+"""++from __future__ import annotations++import argparse+import json+import os+import sys+from pathlib import Path+from typing import Any++from . import __version__+from .registry import Registry, default_paths+from .runtime import Outcome, Runner+from .service import RemoteResolver, http_json, outcome_from_dict, serve+from .transport import RecordedTransport, RecordingTransport, UrllibTransport+from .tree import check, load_tree++TEMPLATE = '''# {name}: say what this tree does, in one line, below.++name = "{name}"+version = 1+description = ""+tags = []+hosts = ["example.com"]+start = "fetch"++[[triggers]]+pattern = "{words}"++[nodes.fetch]+kind = "observe"+http = {{ method = "GET", url = "https://example.com/" }}+into = "resp"+extract = [{{ into = "title", css = "title" }}]+next = "status"++[nodes.status]+kind = "decide"+on = "resp.status"+branches = [+ {{ when = {{ eq = 200 }}, then = "reply" }},+ {{ else = "unexpected" }},+]++[nodes.reply]+kind = "done"+say = "{{{{ state.title }}}}"+output = {{ title = "{{{{ state.title }}}}" }}++[nodes.unexpected]+kind = "fail"+reason = "unexpected-response"+say = "example.com answered {{{{ state.resp.status }}}}."+'''+++def main(argv: list[str] | None = None) -> int:+ parser = argparse.ArgumentParser(prog="pyrtan", description="A swarm of small services that act on the web through hardcoded, inspectable decision trees.")+ parser.add_argument("--version", action="version", version=f"pyrtan {__version__}")+ sub = parser.add_subparsers(dest="command", required=True)++ p = sub.add_parser("check", help="validate trees; exit 1 if any has an error")+ p.add_argument("paths", nargs="*")+ p.add_argument("--quiet", action="store_true", help="only print problems")++ p = sub.add_parser("list", help="what is loaded and what it answers to")+ p.add_argument("paths", nargs="*")+ p.add_argument("--json", action="store_true")++ p = sub.add_parser("run", help="answer a sentence or run a named tree in this process")+ p.add_argument("text", nargs="?", help="the sentence to answer")+ p.add_argument("--trees", action="append", default=[], help="tree file or directory (repeatable; default $PYRTAN_TREES or ./trees)")+ p.add_argument("--tree", help="run this tree by name instead of matching text")+ p.add_argument("--input", action="append", default=[], metavar="K=V", help="an input for --tree (repeatable; JSON values accepted)")+ p.add_argument("--json", action="store_true", help="print the outcome as JSON")+ p.add_argument("--no-trace", action="store_true", help="do not print the trace")+ p.add_argument("--replay", help="answer HTTP from this fixture instead of the network")+ p.add_argument("--record", help="save every HTTP response to this fixture")+ p.add_argument("--hub", help="forward trees not held here to this hub or service")+ p.add_argument("--env", action="append", default=[], metavar="K=V", help="a value trees may read as {{ env.K }} (repeatable)")++ p = sub.add_parser("ask", help="answer a sentence through a running service or hub")+ p.add_argument("text")+ p.add_argument("--at", default=os.environ.get("PYRTAN_AT", "http://127.0.0.1:8700"), help="service or hub URL (default $PYRTAN_AT or the local hub)")+ p.add_argument("--json", action="store_true")+ p.add_argument("--no-trace", action="store_true")++ p = sub.add_parser("serve", help="host trees as a service")+ p.add_argument("paths", nargs="*")+ p.add_argument("--host", default="127.0.0.1")+ p.add_argument("--port", type=int, default=8701)+ p.add_argument("--name", default=None, help="what this service calls itself (default: the first directory's name)")+ p.add_argument("--hub", help="register with this hub, and forward unknown trees to it")+ p.add_argument("--advertise", help="the URL the hub should reach this service at, if not host:port")+ p.add_argument("--env", action="append", default=[], metavar="K=V")+ p.add_argument("--quiet", action="store_true")++ p = sub.add_parser("hub", help="route between services")+ p.add_argument("--host", default="127.0.0.1")+ p.add_argument("--port", type=int, default=8700)+ p.add_argument("--peer", action="append", default=[], help="a service URL to start with (repeatable; services may also register themselves)")+ p.add_argument("--poll", type=float, default=10.0, help="seconds between health polls")+ p.add_argument("--quiet", action="store_true")++ p = sub.add_parser("gaps", help="decisions that met a value with no branch")+ p.add_argument("--at", help="a running service or hub")+ p.add_argument("--json", action="store_true")++ p = sub.add_parser("new", help="scaffold a tree file")+ p.add_argument("name")+ p.add_argument("--dir", default=".")++ args = parser.parse_args(argv)+ return {"check": cmd_check, "list": cmd_list, "run": cmd_run, "ask": cmd_ask, "serve": cmd_serve, "hub": cmd_hub, "gaps": cmd_gaps, "new": cmd_new}[args.command](args)+++# ---------------------------------------------------------------- commands+++def _paths(given: list[str]) -> list[Path]:+ return [Path(p) for p in given] if given else default_paths()+++def _files(paths: list[Path]) -> list[Path]:+ out: list[Path] = []+ for p in paths:+ if p.is_dir():+ out.extend(f for f in sorted(p.rglob("*.toml")) if f.name != "pyproject.toml")+ elif p.exists():+ out.append(p)+ else:+ print(f"pyrtan: {p}: no such file or directory", file=sys.stderr)+ return out+++def cmd_check(args: argparse.Namespace) -> int:+ worst = 0+ files = _files(_paths(args.paths))+ if not files:+ print("pyrtan: nothing to check", file=sys.stderr)+ return 2+ for path in files:+ try:+ tree = load_tree(path)+ except Exception as e: # TreeError or TOML trouble: already explained+ print(f"{path}: error: {e}")+ worst = max(worst, 1)+ continue+ problems = check(tree)+ errors = [p for p in problems if p.level == "error"]+ if errors:+ worst = max(worst, 1)+ if not args.quiet or problems:+ summary = f"{len(tree.nodes)} nodes, {len(tree.triggers)} triggers"+ print(f"{path}: {tree.name}: {summary}" + ("" if not problems else f", {len(problems)} note(s)"))+ for p in problems:+ print(f" {p}")+ return worst+++def cmd_list(args: argparse.Namespace) -> int:+ reg = Registry.from_paths(_paths(args.paths))+ if args.json:+ print(json.dumps({"trees": reg.capabilities(), "errors": reg.errors}, indent=2, ensure_ascii=False))+ return 0+ for tree in reg.trees():+ print(f"{tree.name} v{tree.version} {tree.description}")+ for t in tree.triggers:+ print(f" {t.pattern}")+ for path, err in reg.errors.items():+ print(f"{path}: {err}", file=sys.stderr)+ return 0 if not reg.errors else 1+++def _kv(items: list[str]) -> dict[str, Any]:+ out: dict[str, Any] = {}+ for item in items:+ if "=" not in item:+ raise SystemExit(f"pyrtan: expected K=V, got {item!r}")+ k, v = item.split("=", 1)+ try:+ out[k] = json.loads(v)+ except json.JSONDecodeError:+ out[k] = v+ return out+++def cmd_run(args: argparse.Namespace) -> int:+ if not args.text and not args.tree:+ print("pyrtan run: give a sentence, or --tree NAME", file=sys.stderr)+ return 2+ reg = Registry.from_paths(_paths(args.trees))+ for path, err in reg.errors.items():+ print(f"pyrtan: {path}: {err}", file=sys.stderr)+ transport: Any = RecordedTransport.from_file(args.replay) if args.replay else UrllibTransport()+ recorder = None+ if args.record:+ recorder = RecordingTransport(transport)+ transport = recorder+ runner = Runner(reg, transport, resolver=RemoteResolver(args.hub) if args.hub else None, env=_kv(args.env))+ if args.tree:+ outcome = runner.run_tree(args.tree, _kv(args.input))+ else:+ outcome = runner.run_intent(args.text)+ outcome = _answer_questions(outcome, runner.resume)+ if recorder is not None:+ recorder.save(args.record)+ return _report(outcome, args.json, not args.no_trace)+++def _answer_questions(outcome: Outcome, resume) -> Outcome:+ while outcome.status == "asked":+ if not sys.stdin.isatty():+ return outcome+ try:+ answer = input(f"{outcome.question} ")+ except EOFError:+ return outcome+ outcome = resume(outcome.snapshot, answer)+ return outcome+++def cmd_ask(args: argparse.Namespace) -> int:+ at = args.at.rstrip("/")+ try:+ status, body = http_json("POST", f"{at}/intent", {"text": args.text, "trace": not args.no_trace})+ except Exception as e:+ print(f"pyrtan: {at}: {e}", file=sys.stderr)+ return 3+ if status != 200 or not isinstance(body, dict):+ print(f"pyrtan: {at} answered {status}: {body}", file=sys.stderr)+ return 3+ outcome = outcome_from_dict(body)++ def remote_resume(snapshot: dict[str, Any], answer: Any) -> Outcome:+ st, bd = http_json("POST", f"{at}/resume", {"snapshot": snapshot, "answer": answer, "trace": not args.no_trace})+ return outcome_from_dict(bd) if isinstance(bd, dict) else Outcome("error", "", reason="remote", say=f"{at} answered {st}")++ outcome = _answer_questions(outcome, remote_resume)+ return _report(outcome, args.json, not args.no_trace)+++def _report(outcome: Outcome, as_json: bool, with_trace: bool) -> int:+ if as_json:+ print(json.dumps(outcome.to_dict(with_trace=with_trace), indent=2, ensure_ascii=False, default=str))+ else:+ if with_trace and outcome.trace is not None:+ print(outcome.trace.render())+ print()+ if outcome.status == "done":+ print(outcome.say if outcome.say is not None else json.dumps(outcome.output, ensure_ascii=False))+ elif outcome.status == "asked":+ print(f"asked: {outcome.question}")+ else:+ print(f"{outcome.status} ({outcome.reason}): {outcome.say or ''}".rstrip(": "))+ return {"done": 0, "failed": 1, "asked": 4}.get(outcome.status, 3)+++def cmd_serve(args: argparse.Namespace) -> int:+ paths = _paths(args.paths)+ name = args.name or (paths[0].resolve().name if paths else "pyrtan")+ service, server = serve(paths, args.host, args.port, name=name, hub=args.hub, env=_kv(args.env), advertise=args.advertise, quiet=args.quiet)+ print(f"pyrtan service {name!r}: {len(service.registry)} tree(s) at {server.url}" + (f", registered with {args.hub}" if args.hub else ""), flush=True)+ for path, err in service.registry.errors.items():+ print(f" not loaded: {path}: {err}", file=sys.stderr)+ try:+ server.serve_forever()+ except KeyboardInterrupt:+ pass+ finally:+ server.stop()+ return 0+++def cmd_hub(args: argparse.Namespace) -> int:+ from .hub import serve_hub++ hub, server = serve_hub(args.host, args.port, peers=args.peer, poll_every=args.poll, quiet=args.quiet)+ print(f"pyrtan hub at {server.url}: {len(hub.peers)} peer(s)", flush=True)+ try:+ server.serve_forever()+ except KeyboardInterrupt:+ pass+ finally:+ hub.stop()+ server.stop()+ return 0+++def cmd_gaps(args: argparse.Namespace) -> int:+ if not args.at:+ print("pyrtan gaps: give --at URL of a running service or hub", file=sys.stderr)+ return 2+ try:+ status, body = http_json("GET", f"{args.at.rstrip('/')}/gaps")+ except Exception as e:+ print(f"pyrtan: {args.at}: {e}", file=sys.stderr)+ return 3+ gaps = (body or {}).get("gaps", []) if isinstance(body, dict) else []+ if args.json:+ print(json.dumps({"gaps": gaps}, indent=2, ensure_ascii=False))+ return 0+ if not gaps:+ print("no gaps: every decision so far had a branch")+ return 0+ for g in gaps:+ peer = f" ({g['peer']})" if g.get("peer") else ""+ print(f"{g['tree']}.{g['node']}: {g['on']} = {json.dumps(g['observed'], ensure_ascii=False)}{peer}")+ return 0+++def cmd_new(args: argparse.Namespace) -> int:+ name = args.name+ path = Path(args.dir) / f"{name}.toml"+ if path.exists():+ print(f"pyrtan: {path} exists", file=sys.stderr)+ return 1+ path.parent.mkdir(parents=True, exist_ok=True)+ path.write_text(TEMPLATE.format(name=name, words=name.replace("-", " ")), encoding="utf-8")+ print(f"wrote {path}; edit it, then: pyrtan check {path}")+ return 0+++if __name__ == "__main__":+ sys.exit(main())diff --git a/src/pyrtan/conditions.py b/src/pyrtan/conditions.py new file mode 100644 index 0000000..135e426 --- /dev/null +++ b/src/pyrtan/conditions.py@@ -0,0 +1,144 @@+"""Conditions for ``decide`` branches.++A condition is a small table with one or more tests; all tests must hold.+Every test is a plain comparison a reader can check by eye, which is the+point: the branch taken is always explainable from the observed value and+the condition text.++ { eq = 200 }+ { gte = 500 }+ { matches = "^https://" }+ { contains = "error" }+ { in = ["open", "pending"] }+ { exists = true }+ { empty = false }+ { between = [200, 299] }+ { type = "list" }+"""++from __future__ import annotations++import re+from typing import Any++from .template import MISSING++KNOWN = {"eq", "ne", "lt", "lte", "gt", "gte", "matches", "contains", "in", "exists", "empty", "between", "type", "startswith", "endswith"}++TYPE_NAMES = {+ "str": str,+ "string": str,+ "int": int,+ "float": float,+ "number": (int, float),+ "bool": bool,+ "list": list,+ "dict": dict,+ "map": dict,+ "null": type(None),+ "none": type(None),+}+++class ConditionError(ValueError):+ pass+++def validate(cond: Any) -> None:+ if not isinstance(cond, dict) or not cond:+ raise ConditionError("a condition is a non-empty table")+ unknown = set(cond) - KNOWN+ if unknown:+ raise ConditionError(f"unknown condition keys: {sorted(unknown)}")+ if "matches" in cond:+ try:+ re.compile(cond["matches"])+ except re.error as e:+ raise ConditionError(f"bad regex in matches: {e}") from e+ if "between" in cond and (not isinstance(cond["between"], list) or len(cond["between"]) != 2):+ raise ConditionError("between takes a two-item list")+ if "type" in cond and cond["type"] not in TYPE_NAMES:+ raise ConditionError(f"unknown type {cond['type']!r}; one of {sorted(TYPE_NAMES)}")+ if "in" in cond and not isinstance(cond["in"], list):+ raise ConditionError("in takes a list")+++def holds(cond: dict[str, Any], value: Any) -> bool:+ """True when every test in ``cond`` holds for ``value``."""+ for key, expected in cond.items():+ if not _test(key, expected, value):+ return False+ return True+++def _is_empty(value: Any) -> bool:+ if value is MISSING or value is None:+ return True+ if isinstance(value, (str, list, dict, tuple)):+ return len(value) == 0+ return False+++def _num(value: Any) -> float | None:+ if isinstance(value, bool):+ return None+ if isinstance(value, (int, float)):+ return float(value)+ if isinstance(value, str):+ try:+ return float(value.strip())+ except ValueError:+ return None+ return None+++def _test(key: str, expected: Any, value: Any) -> bool:+ if key == "exists":+ return (value is not MISSING and value is not None) == bool(expected)+ if key == "empty":+ return _is_empty(value) == bool(expected)+ if value is MISSING:+ return False+ if key == "eq":+ return _loose_eq(value, expected)+ if key == "ne":+ return not _loose_eq(value, expected)+ if key in ("lt", "lte", "gt", "gte"):+ a, b = _num(value), _num(expected)+ if a is None or b is None:+ return False+ return {"lt": a < b, "lte": a <= b, "gt": a > b, "gte": a >= b}[key]+ if key == "between":+ a, lo, hi = _num(value), _num(expected[0]), _num(expected[1])+ if a is None or lo is None or hi is None:+ return False+ return lo <= a <= hi+ if key == "matches":+ return value is not None and re.search(expected, str(value)) is not None+ if key == "contains":+ if isinstance(value, (list, tuple)):+ return any(_loose_eq(item, expected) for item in value)+ if isinstance(value, dict):+ return expected in value+ return str(expected) in str(value)+ if key == "startswith":+ return str(value).startswith(str(expected))+ if key == "endswith":+ return str(value).endswith(str(expected))+ if key == "in":+ return any(_loose_eq(value, option) for option in expected)+ if key == "type":+ return isinstance(value, TYPE_NAMES[expected]) and not (expected in ("int", "number", "float") and isinstance(value, bool))+ raise ConditionError(f"unknown condition key {key!r}")+++def _loose_eq(a: Any, b: Any) -> bool:+ """Equality that lets ``"200"`` match ``200`` but keeps bools strict."""+ if isinstance(a, bool) or isinstance(b, bool):+ return a is b or (isinstance(a, bool) and isinstance(b, bool) and a == b)+ if a == b:+ return True+ na, nb = _num(a), _num(b)+ if na is not None and nb is not None and (isinstance(a, str) or isinstance(b, str)):+ return na == nb+ return Falsediff --git a/src/pyrtan/extract.py b/src/pyrtan/extract.py new file mode 100644 index 0000000..c57b194 --- /dev/null +++ b/src/pyrtan/extract.py@@ -0,0 +1,120 @@+"""Pulling values out of what was observed.++An ``extract`` entry names where the value goes (``into``) and one way of+finding it:++ { into = "title", json = "json.items[0].title" }+ { into = "price", regex = "\\$([0-9.]+)", group = 1 }+ { into = "heading", css = "h1" } # text of first match+ { into = "links", css = "a.story", attr = "href", all = true }+ { into = "rows", css = "table tr", all = true, fields = { name = "td:nth-child(1)", qty = "td:nth-child(2)" } }++The source is the observation's state (``json``, ``body``, ``status`` …) or+``from`` when given. Extraction never raises on a miss; the value is simply+absent, and a ``decide`` node downstream says what that means.+"""++from __future__ import annotations++import re+from typing import Any++from . import html as htmlmod+from .template import MISSING, resolve+++class ExtractError(ValueError):+ pass+++WAYS = {"json", "regex", "css", "path"}+++def validate(entry: Any) -> None:+ if not isinstance(entry, dict):+ raise ExtractError("an extract entry is a table")+ if "into" not in entry or not isinstance(entry["into"], str):+ raise ExtractError("extract entry needs a string 'into'")+ ways = WAYS & set(entry)+ if len(ways) != 1:+ raise ExtractError(f"extract entry {entry['into']!r} needs exactly one of {sorted(WAYS)}")+ if "regex" in entry:+ try:+ re.compile(entry["regex"])+ except re.error as e:+ raise ExtractError(f"bad regex in extract {entry['into']!r}: {e}") from e+ if "css" in entry:+ try:+ htmlmod.compile_selector(entry["css"])+ except htmlmod.SelectorError as e:+ raise ExtractError(f"bad selector in extract {entry['into']!r}: {e}") from e+ for sub in (entry.get("fields") or {}).values():+ try:+ htmlmod.compile_selector(sub if isinstance(sub, str) else sub["css"])+ except (htmlmod.SelectorError, KeyError, TypeError) as e:+ raise ExtractError(f"bad field selector in extract {entry['into']!r}: {e}") from e+++def extract(entry: dict[str, Any], source: dict[str, Any], doc_cache: dict[int, htmlmod.Element] | None = None) -> Any:+ """Return the extracted value, or :data:`MISSING`."""+ if "json" in entry or "path" in entry:+ path = entry.get("json", entry.get("path"))+ return resolve(path, source)+ if "regex" in entry:+ text = _text_source(entry, source)+ if text is MISSING:+ return MISSING+ pattern = re.compile(entry["regex"], re.DOTALL if entry.get("dotall") else 0)+ group = entry.get("group", 1 if pattern.groups else 0)+ if entry.get("all"):+ return [m.group(group) for m in pattern.finditer(text)]+ m = pattern.search(text)+ return m.group(group) if m else MISSING+ if "css" in entry:+ text = _text_source(entry, source)+ if text is MISSING:+ return MISSING+ key = id(text)+ if doc_cache is not None and key in doc_cache:+ doc = doc_cache[key]+ else:+ doc = htmlmod.parse(text)+ if doc_cache is not None:+ doc_cache[key] = doc+ found = doc.select(entry["css"])+ if entry.get("all"):+ return [_element_value(entry, el) for el in found]+ if not found:+ return MISSING+ return _element_value(entry, found[0])+ raise ExtractError(f"extract entry {entry.get('into')!r} has no way")+++def _text_source(entry: dict[str, Any], source: dict[str, Any]) -> Any:+ frm = entry.get("from", "body")+ value = resolve(frm, source) if isinstance(frm, str) else MISSING+ if value is MISSING or value is None:+ return MISSING+ return value if isinstance(value, str) else str(value)+++def _element_value(entry: dict[str, Any], el: htmlmod.Element) -> Any:+ fields = entry.get("fields")+ if fields:+ out: dict[str, Any] = {}+ for name, spec in fields.items():+ if isinstance(spec, str):+ sub = el.select_one(spec)+ out[name] = sub.text if sub else None+ else:+ sub = el.select_one(spec["css"])+ if sub is None:+ out[name] = None+ elif "attr" in spec:+ out[name] = sub.get(spec["attr"])+ else:+ out[name] = sub.text+ return out+ if "attr" in entry:+ return el.get(entry["attr"])+ return el.textdiff --git a/src/pyrtan/html.py b/src/pyrtan/html.py new file mode 100644 index 0000000..1611e7b --- /dev/null +++ b/src/pyrtan/html.py@@ -0,0 +1,253 @@+"""A small HTML document model with a CSS selector subset, on the standard+library's parser.++The subset is what trees actually need to read a page: tag, ``#id``,+``.class``, ``[attr]``, ``[attr=value]``, ``[attr^=v]``, ``[attr$=v]``,+``[attr*=v]``, ``:first-child``, ``:nth-child(n)``, ``:contains(text)``,+the descendant (space) and child (``>``) combinators, and ``,`` groups.+Anything else is a :class:`SelectorError` at tree check time, not a silent+empty match at run time.+"""++from __future__ import annotations++import re+from dataclasses import dataclass, field+from html.parser import HTMLParser+from typing import Iterator++VOID = {"area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source", "track", "wbr"}+INLINE = {"a", "abbr", "b", "bdi", "bdo", "cite", "code", "data", "dfn", "em", "i", "kbd", "label", "mark", "q", "s", "samp", "small", "span", "strong", "sub", "sup", "time", "u", "var", "del", "ins"}+++@dataclass+class Element:+ tag: str+ attrs: dict[str, str] = field(default_factory=dict)+ children: list["Element | str"] = field(default_factory=list)+ parent: "Element | None" = field(default=None, repr=False, compare=False)++ @property+ def text(self) -> str:+ """All descendant text, whitespace collapsed. Inline elements join+ their neighbours directly; any other element boundary counts as a+ space, so two table cells never run together."""+ parts: list[str] = []+ for child in self.children:+ if isinstance(child, str):+ parts.append(child)+ elif child.tag in INLINE:+ parts.append(child.text)+ else:+ parts.append(" " + child.text + " ")+ return re.sub(r"\s+", " ", "".join(parts)).strip()++ @property+ def classes(self) -> list[str]:+ return self.attrs.get("class", "").split()++ def get(self, name: str, default: str | None = None) -> str | None:+ return self.attrs.get(name, default)++ def elements(self) -> Iterator["Element"]:+ for child in self.children:+ if isinstance(child, Element):+ yield child++ def descendants(self) -> Iterator["Element"]:+ for child in self.elements():+ yield child+ yield from child.descendants()++ def select(self, selector: str) -> list["Element"]:+ return select(self, selector)++ def select_one(self, selector: str) -> "Element | None":+ found = select(self, selector)+ return found[0] if found else None++ def to_dict(self) -> dict:+ return {"tag": self.tag, "attrs": self.attrs, "text": self.text}+++class _Builder(HTMLParser):+ def __init__(self) -> None:+ super().__init__(convert_charrefs=True)+ self.root = Element("document")+ self.stack = [self.root]++ def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:+ el = Element(tag.lower(), {k.lower(): (v if v is not None else "") for k, v in attrs}, parent=self.stack[-1])+ self.stack[-1].children.append(el)+ if tag.lower() not in VOID:+ self.stack.append(el)++ def handle_startendtag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:+ el = Element(tag.lower(), {k.lower(): (v if v is not None else "") for k, v in attrs}, parent=self.stack[-1])+ self.stack[-1].children.append(el)++ def handle_endtag(self, tag: str) -> None:+ tag = tag.lower()+ for i in range(len(self.stack) - 1, 0, -1):+ if self.stack[i].tag == tag:+ del self.stack[i:]+ return++ def handle_data(self, data: str) -> None:+ if data:+ self.stack[-1].children.append(data)+++def parse(text: str) -> Element:+ b = _Builder()+ b.feed(text)+ b.close()+ return b.root+++class SelectorError(ValueError):+ pass+++_TOKEN = re.compile(+ r"""+ (?P<tag>[a-zA-Z][a-zA-Z0-9\-]*|\*)+ | \#(?P<id>[\w\-]+)+ | \.(?P<cls>[\w\-]+)+ | \[(?P<attr>[\w\-:]+)(?:(?P<op>[\^\$\*]?=)(?P<q>["']?)(?P<val>[^\]"']*)(?P=q))?\]+ | :(?P<pseudo>first-child|last-child|nth-child\(\d+\)|contains\((?P<cq>["']?)(?P<ctext>[^)"']*)(?P=cq)\))+ """,+ re.VERBOSE,+)+++@dataclass+class _Simple:+ tag: str | None = None+ id: str | None = None+ classes: list[str] = field(default_factory=list)+ attrs: list[tuple[str, str, str | None]] = field(default_factory=list)+ pseudos: list[str] = field(default_factory=list)++ def matches(self, el: Element) -> bool:+ if self.tag and self.tag != "*" and el.tag != self.tag:+ return False+ if self.id and el.attrs.get("id") != self.id:+ return False+ if self.classes and not set(self.classes) <= set(el.classes):+ return False+ for name, op, val in self.attrs:+ if name not in el.attrs:+ return False+ have = el.attrs[name]+ if op == "=" and have != val:+ return False+ if op == "^=" and not have.startswith(val or ""):+ return False+ if op == "$=" and not have.endswith(val or ""):+ return False+ if op == "*=" and (val or "") not in have:+ return False+ for p in self.pseudos:+ if not _pseudo(p, el):+ return False+ return True+++def _pseudo(p: str, el: Element) -> bool:+ if p.startswith("contains:"):+ return p[len("contains:"):] in el.text+ siblings = list(el.parent.elements()) if el.parent else [el]+ if p == "first-child":+ return siblings[0] is el+ if p == "last-child":+ return siblings[-1] is el+ if p.startswith("nth-child("):+ n = int(p[len("nth-child("):-1])+ return 0 < n <= len(siblings) and siblings[n - 1] is el+ raise SelectorError(f"unknown pseudo-class :{p}")+++def _parse_compound(text: str) -> _Simple:+ simple = _Simple()+ pos = 0+ text = text.strip()+ if not text:+ raise SelectorError("empty selector part")+ while pos < len(text):+ m = _TOKEN.match(text, pos)+ if not m:+ raise SelectorError(f"cannot parse selector near {text[pos:]!r}")+ pos = m.end()+ if m.group("tag"):+ if simple.tag:+ raise SelectorError(f"two tags in one compound selector: {text!r}")+ simple.tag = m.group("tag").lower()+ elif m.group("id"):+ simple.id = m.group("id")+ elif m.group("cls"):+ simple.classes.append(m.group("cls"))+ elif m.group("attr"):+ simple.attrs.append((m.group("attr").lower(), m.group("op") or "", m.group("val")))+ elif m.group("pseudo"):+ p = m.group("pseudo")+ if p.startswith("contains("):+ simple.pseudos.append("contains:" + m.group("ctext"))+ else:+ simple.pseudos.append(p)+ return simple+++def compile_selector(selector: str) -> list[list[tuple[str, _Simple]]]:+ """Compile to groups of (combinator, compound) pairs. Raises on anything+ outside the supported subset."""+ groups = []+ for alt in selector.split(","):+ alt = alt.strip()+ if not alt:+ raise SelectorError(f"empty alternative in {selector!r}")+ parts = re.split(r"\s*(>)\s*|\s+", alt)+ chain: list[tuple[str, _Simple]] = []+ comb = " "+ for part in parts:+ if part is None or part == "":+ continue+ if part == ">":+ comb = ">"+ continue+ chain.append((comb, _parse_compound(part)))+ comb = " "+ if not chain:+ raise SelectorError(f"nothing to match in {selector!r}")+ groups.append(chain)+ return groups+++def select(root: Element, selector: str) -> list[Element]:+ compiled = compile_selector(selector)+ out: list[Element] = []+ seen: set[int] = set()+ for el in root.descendants():+ for chain in compiled:+ if _chain_matches(el, chain) and id(el) not in seen:+ seen.add(id(el))+ out.append(el)+ break+ return out+++def _chain_matches(el: Element, chain: list[tuple[str, _Simple]]) -> bool:+ comb, simple = chain[-1]+ if not simple.matches(el):+ return False+ if len(chain) == 1:+ return True+ rest = chain[:-1]+ if comb == ">":+ return el.parent is not None and el.parent.tag != "document" and _chain_matches(el.parent, rest)+ anc = el.parent+ while anc is not None and anc.tag != "document":+ if _chain_matches(anc, rest):+ return True+ anc = anc.parent+ return Falsediff --git a/src/pyrtan/hub.py b/src/pyrtan/hub.py new file mode 100644 index 0000000..8480497 --- /dev/null +++ b/src/pyrtan/hub.py@@ -0,0 +1,264 @@+"""The hub: where a swarm's services find each other.++A hub holds no trees. It keeps a list of peers, polls each one's+``/capabilities`` and ``/health``, and answers the same ``/intent`` and+``/run`` a service does by forwarding to the peer that holds the matching+tree. Each peer carries a ``link`` of ``up``, ``stale`` or ``down``: the one+field an operator reads to know whether the swarm is whole.++Endpoints:++``POST /register`` ``{"url": "http://host:port", "name": "..."}``+``GET /peers`` every peer with its link and trees+``GET /capabilities`` the union of every peer's trees+``GET /health`` ``{"status": "ok", "peers": n, "up": n, "trees": n}``+``POST /intent`` forwarded to the peer whose tree matches best+``POST /run`` forwarded to the peer holding ``tree``+``POST /resume`` forwarded to the peer named in the snapshot+``GET /gaps`` every peer's gaps, with the peer that saw them+``GET /`` the demo page; ``GET /api`` is the JSON index++A service that holds several trees and a hub that routes between services+answer the same requests, so a client need not know which it is talking to,+and a swarm can start as one process and split later without the callers+changing.+"""++from __future__ import annotations++import threading+import time+from dataclasses import dataclass, field+from typing import Any++from .intent import Trigger, best_matches+from .page import PAGE+from .service import Server, http_json++STALE_AFTER = 30.0+DOWN_AFTER = 90.0+++@dataclass+class Peer:+ url: str+ name: str = ""+ trees: list[dict[str, Any]] = field(default_factory=list)+ last_ok: float = 0.0+ last_error: str | None = None+ registered: float = field(default_factory=time.time)++ @property+ def link(self) -> str:+ if self.last_ok == 0.0:+ return "down" if self.last_error else "pending"+ age = time.time() - self.last_ok+ if age < STALE_AFTER:+ return "up"+ if age < DOWN_AFTER:+ return "stale"+ return "down"++ def to_dict(self) -> dict[str, Any]:+ return {"url": self.url, "name": self.name, "link": self.link, "trees": [t["name"] for t in self.trees], "last_ok": self.last_ok, "last_error": self.last_error}+++class Hub:+ def __init__(self, *, name: str = "hub", poll_every: float = 10.0) -> None:+ self.name = name+ self.poll_every = poll_every+ self.peers: dict[str, Peer] = {}+ self.started = time.time()+ self._lock = threading.RLock()+ self._stop = threading.Event()+ self._thread: threading.Thread | None = None++ # ------------------------------------------------------------ peers++ def add_peer(self, url: str, name: str = "") -> Peer:+ url = url.rstrip("/")+ with self._lock:+ peer = self.peers.get(url)+ if peer is None:+ peer = Peer(url, name)+ self.peers[url] = peer+ elif name:+ peer.name = name+ self.refresh(peer)+ return peer++ def refresh(self, peer: Peer) -> None:+ try:+ status, caps = http_json("GET", f"{peer.url}/capabilities", timeout=5.0)+ except Exception as e: # urllib raises URLError subclasses of OSError+ status, caps = 0, None+ peer.last_error = str(e)+ if status == 200 and isinstance(caps, dict):+ with self._lock:+ peer.trees = list(caps.get("trees", []))+ peer.name = caps.get("name") or peer.name+ peer.last_ok = time.time()+ peer.last_error = None+ elif status:+ peer.last_error = f"capabilities answered {status}"++ def refresh_all(self) -> None:+ for peer in list(self.peers.values()):+ self.refresh(peer)++ def start_polling(self) -> None:+ def loop() -> None:+ while not self._stop.wait(self.poll_every):+ self.refresh_all()++ self._thread = threading.Thread(target=loop, daemon=True)+ self._thread.start()++ def stop(self) -> None:+ self._stop.set()++ # ------------------------------------------------------------ lookup++ def candidates(self) -> list[tuple[tuple[str, str], Trigger]]:+ out: list[tuple[tuple[str, str], Trigger]] = []+ with self._lock:+ for peer in self.peers.values():+ if peer.link == "down":+ continue+ for tree in peer.trees:+ for pattern in tree.get("triggers", []):+ try:+ out.append(((peer.url, tree["name"]), Trigger(pattern)))+ except Exception:+ continue+ return out++ def holder(self, tree_name: str) -> Peer | None:+ with self._lock:+ best: Peer | None = None+ for peer in self.peers.values():+ if any(t.get("name") == tree_name for t in peer.trees):+ if best is None or _rank(peer) < _rank(best):+ best = peer+ return best++ # ------------------------------------------------------------ handlers++ def health(self) -> dict[str, Any]:+ with self._lock:+ peers = list(self.peers.values())+ return {+ "status": "ok",+ "name": self.name,+ "peers": len(peers),+ "up": sum(1 for p in peers if p.link == "up"),+ "stale": sum(1 for p in peers if p.link == "stale"),+ "down": sum(1 for p in peers if p.link == "down"),+ "trees": sum(len(p.trees) for p in peers),+ "uptime_s": int(time.time() - self.started),+ }++ def capabilities(self) -> dict[str, Any]:+ trees: list[dict[str, Any]] = []+ with self._lock:+ for peer in self.peers.values():+ for t in peer.trees:+ trees.append({**t, "peer": peer.url, "link": peer.link})+ return {"name": self.name, "trees": trees}++ def intent(self, body: dict[str, Any]) -> tuple[int, dict[str, Any]]:+ text = body.get("text")+ if not isinstance(text, str) or not text.strip():+ return 200, {"status": "error", "reason": "bad-request", "say": "Send {\"text\": \"...\"}."}+ matches = best_matches(text, self.candidates()) # type: ignore[arg-type]+ if not matches:+ return 200, {"status": "error", "reason": "no-match", "say": f"No service in the swarm answers {text!r}."}+ tree_names = {m.tree[1] for m in matches} # type: ignore[index]+ if len(tree_names) > 1:+ return 200, {"status": "error", "reason": "ambiguous", "say": f"{text!r} matches more than one tree: {sorted(tree_names)}", "output": {"candidates": sorted(tree_names)}}+ peer_url, tree_name = matches[0].tree # type: ignore[misc]+ peer = self.holder(tree_name) or self.peers.get(peer_url)+ return self.forward(peer, "/run", {"tree": tree_name, "inputs": matches[0].inputs, "depth": body.get("depth", 0), "trace": body.get("trace", True)})++ def run(self, body: dict[str, Any]) -> tuple[int, dict[str, Any]]:+ tree = body.get("tree")+ if not isinstance(tree, str):+ return 200, {"status": "error", "reason": "bad-request", "say": "Send {\"tree\": \"...\", \"inputs\": {...}}."}+ peer = self.holder(tree)+ if peer is None:+ return 200, {"status": "error", "reason": "no-such-tree", "tree": tree, "say": f"No service in the swarm holds {tree!r}."}+ return self.forward(peer, "/run", body)++ def resume(self, body: dict[str, Any]) -> tuple[int, dict[str, Any]]:+ snapshot = body.get("snapshot") or {}+ peer = self.holder(str(snapshot.get("tree", "")))+ if peer is None:+ return 200, {"status": "error", "reason": "no-such-tree", "say": "No service holds the tree this snapshot came from."}+ return self.forward(peer, "/resume", body)++ def forward(self, peer: Peer | None, path: str, body: dict[str, Any]) -> tuple[int, dict[str, Any]]:+ if peer is None:+ return 200, {"status": "error", "reason": "no-peer"}+ try:+ status, answer = http_json("POST", f"{peer.url}{path}", body, timeout=120.0)+ except Exception as e:+ peer.last_error = str(e)+ return 200, {"status": "error", "reason": "peer-unreachable", "say": f"{peer.name or peer.url} did not answer: {e}", "output": {"peer": peer.url}}+ if status != 200 or not isinstance(answer, dict):+ return 200, {"status": "error", "reason": "peer-error", "say": f"{peer.name or peer.url} answered {status}", "output": {"peer": peer.url}}+ peer.last_ok = time.time()+ answer.setdefault("peer", peer.url)+ return 200, answer++ def gaps(self) -> dict[str, Any]:+ out: list[dict[str, Any]] = []+ for peer in list(self.peers.values()):+ try:+ status, body = http_json("GET", f"{peer.url}/gaps", timeout=5.0)+ except Exception:+ continue+ if status == 200 and isinstance(body, dict):+ out.extend({**g, "peer": peer.url} for g in body.get("gaps", []))+ return {"gaps": out}++ def route(self, method: str, path: str, body: dict[str, Any] | None) -> tuple[int, Any]:+ body = body or {}+ if method == "GET" and path == "/health":+ return 200, self.health()+ if method == "GET" and path == "/peers":+ with self._lock:+ return 200, {"peers": [p.to_dict() for p in self.peers.values()]}+ if method == "GET" and path == "/capabilities":+ return 200, self.capabilities()+ if method == "GET" and path == "/gaps":+ return 200, self.gaps()+ if method == "GET" and path in ("/", "/index.html"):+ return 200, PAGE+ if method == "GET" and path == "/api":+ return 200, {"pyrtan": "hub", "name": self.name, "endpoints": ["/health", "/peers", "/capabilities", "/intent", "/run", "/resume", "/gaps", "/register"]}+ if method == "POST" and path == "/register":+ url = body.get("url")+ if not isinstance(url, str) or not url.startswith(("http://", "https://")):+ return 400, {"status": "error", "reason": "bad-request", "say": "Send {\"url\": \"http://host:port\"}."}+ peer = self.add_peer(url, str(body.get("name", "")))+ return 200, {"status": "ok", "peer": peer.to_dict()}+ if method == "POST" and path == "/intent":+ return self.intent(body)+ if method == "POST" and path == "/run":+ return self.run(body)+ if method == "POST" and path == "/resume":+ return self.resume(body)+ return 404, {"status": "error", "reason": "not-found", "say": f"No {method} {path} here."}+++def _rank(peer: Peer) -> int:+ return {"up": 0, "pending": 1, "stale": 2, "down": 3}[peer.link]+++def serve_hub(host: str = "127.0.0.1", port: int = 8700, *, name: str = "hub", peers: list[str] | None = None, poll_every: float = 10.0, quiet: bool = False) -> tuple[Hub, Server]:+ hub = Hub(name=name, poll_every=poll_every)+ server = Server(hub.route, host, port, quiet)+ for url in peers or []:+ hub.add_peer(url)+ hub.start_polling()+ return hub, serverdiff --git a/src/pyrtan/intent.py b/src/pyrtan/intent.py new file mode 100644 index 0000000..5684231 --- /dev/null +++ b/src/pyrtan/intent.py@@ -0,0 +1,164 @@+"""Triggers: how a sentence finds the tree that answers it.++A trigger is a pattern with slots::++ "weather in {city}"+ "top {count:int} stories on hacker news"+ "is {host:word} up"+ "what's [the] latest release of {repo}"++A slot is ``{name}`` (any text), ``{name:int}``, ``{name:float}`` or+``{name:word}`` (no spaces). Square brackets mark optional words, round+brackets a required choice, and ``|`` separates alternatives inside either:+``"how (hot|cold) is it in {place}"``. Matching is+case-insensitive, ignores surrounding punctuation, and collapses whitespace.++When several triggers match, the most specific wins: the one with the most+literal text. A tie is reported as ambiguity rather than resolved by luck.+"""++from __future__ import annotations++import re+from dataclasses import dataclass, field+from typing import Any++SLOT = re.compile(r"\{([A-Za-z_][A-Za-z0-9_]*)(?::(int|float|word|text))?\}")+SLOT_RE = {+ "int": r"-?\d+",+ "float": r"-?\d+(?:\.\d+)?",+ "word": r"\S+",+ "text": r".+?",+ None: r".+?",+}+CAST = {"int": int, "float": float, "word": str, "text": str, None: str}+++class TriggerError(ValueError):+ pass+++def normalise(text: str) -> str:+ text = text.strip()+ text = re.sub(r"^[\s\W_]+|[\s?!.,;:]+$", "", text)+ text = re.sub(r"\s+", " ", text)+ return text+++@dataclass+class Trigger:+ pattern: str+ fixed_inputs: dict[str, Any] = field(default_factory=dict)+ regex: re.Pattern[str] = field(init=False, repr=False)+ slots: dict[str, str | None] = field(init=False, default_factory=dict)+ specificity: int = field(init=False, default=0)++ def __post_init__(self) -> None:+ self.regex, self.slots, self.specificity = compile_pattern(self.pattern)++ def match(self, text: str) -> dict[str, Any] | None:+ m = self.regex.fullmatch(normalise(text))+ if not m:+ return None+ inputs: dict[str, Any] = dict(self.fixed_inputs)+ for name, kind in self.slots.items():+ raw = m.group(name)+ if raw is None:+ continue+ try:+ inputs[name] = CAST[kind](raw.strip())+ except ValueError:+ return None+ return inputs+++def compile_pattern(pattern: str) -> tuple[re.Pattern[str], dict[str, str | None], int]:+ if not pattern or not pattern.strip():+ raise TriggerError("a trigger pattern cannot be empty")+ slots: dict[str, str | None] = {}+ out: list[str] = []+ literal_chars = 0+ pos = 0+ depth = 0+ text = pattern.strip().lower()+ while pos < len(text):+ ch = text[pos]+ if ch == "{":+ m = SLOT.match(text, pos)+ if not m:+ raise TriggerError(f"bad slot in trigger {pattern!r} at {pos}")+ name, kind = m.group(1), m.group(2)+ if name in slots:+ raise TriggerError(f"slot {name!r} appears twice in {pattern!r}")+ slots[name] = kind+ out.append(f"(?P<{name}>{SLOT_RE[kind]})")+ pos = m.end()+ elif ch == "[":+ depth += 1+ out.append(r"(?:\s*")+ pos += 1+ elif ch == "]":+ if depth == 0:+ raise TriggerError(f"unbalanced ] in trigger {pattern!r}")+ depth -= 1+ out.append(r")?")+ pos += 1+ elif ch == "(":+ depth += 1+ out.append(r"(?:")+ pos += 1+ elif ch == ")":+ if depth == 0:+ raise TriggerError(f"unbalanced ) in trigger {pattern!r}")+ depth -= 1+ out.append(r")")+ pos += 1+ elif ch == "|" and depth > 0:+ out.append("|")+ pos += 1+ elif ch.isspace():+ while pos < len(text) and text[pos].isspace():+ pos += 1+ # Space is optional around optional groups and slots so that+ # "[the] weather" matches "weather" without a leading space.+ if out and out[-1] in (r"(?:\s*", r")?", r"(?:", r")", "|"):+ out.append(r"\s*")+ elif pos < len(text) and text[pos] in "[(|":+ out.append(r"\s*")+ else:+ out.append(r"\s+")+ else:+ out.append(re.escape(ch))+ literal_chars += 1+ pos += 1+ if depth != 0:+ raise TriggerError(f"unbalanced [ in trigger {pattern!r}")+ if literal_chars == 0:+ raise TriggerError(f"trigger {pattern!r} has no literal words; it would match anything")+ try:+ regex = re.compile("".join(out), re.IGNORECASE)+ except re.error as e:+ raise TriggerError(f"trigger {pattern!r} does not compile: {e}") from e+ return regex, slots, literal_chars+++@dataclass+class Match:+ tree: str+ trigger: Trigger+ inputs: dict[str, Any]+ specificity: int+++def best_matches(text: str, candidates: list[tuple[str, Trigger]]) -> list[Match]:+ """All matches at the highest specificity. One item is a clear winner;+ several is ambiguity; none is no match."""+ matches: list[Match] = []+ for tree, trig in candidates:+ inputs = trig.match(text)+ if inputs is not None:+ matches.append(Match(tree, trig, inputs, trig.specificity))+ if not matches:+ return []+ top = max(m.specificity for m in matches)+ return [m for m in matches if m.specificity == top]diff --git a/src/pyrtan/page.py b/src/pyrtan/page.py new file mode 100644 index 0000000..17edb61 --- /dev/null +++ b/src/pyrtan/page.py@@ -0,0 +1,128 @@+"""The demo page: one HTML file a service or hub serves at ``/``.++It lists what the swarm can answer, takes a sentence, posts it to+``/intent`` on the same origin, and shows the answer, the output and the+trace. No external assets, no build step; it exists so that someone can+point a browser at a running Pyrtan and understand it in a minute.+"""++from __future__ import annotations++PAGE = r"""<!doctype html>+<html lang="en">+<head>+<meta charset="utf-8">+<meta name="viewport" content="width=device-width, initial-scale=1">+<title>Pyrtan</title>+<style>+ :root { --bg:#fbfaf7; --fg:#1d1d1b; --mute:#6b6a64; --line:#e3e1da; --card:#ffffff; --ok:#1f7a4d; --bad:#a3382b; --ask:#8a6400; --accent:#2b5db8; }+ @media (prefers-color-scheme: dark) { :root { --bg:#141513; --fg:#ecebe6; --mute:#9d9c95; --line:#2b2c29; --card:#1c1d1b; --ok:#5fcf93; --bad:#f08a7a; --ask:#e2b84a; --accent:#7fa6f0; } }+ * { box-sizing:border-box }+ body { margin:0; background:var(--bg); color:var(--fg); font:16px/1.5 system-ui,-apple-system,"Segoe UI",sans-serif }+ main { max-width:880px; margin:0 auto; padding:32px 16px 64px }+ h1 { font-size:22px; margin:0 0 4px } h1 small { color:var(--mute); font-weight:normal; font-size:14px; margin-left:8px }+ p.lede { color:var(--mute); margin:0 0 24px }+ form { display:flex; gap:8px; margin:0 0 16px }+ input[type=text] { flex:1; font:inherit; padding:10px 12px; border:1px solid var(--line); border-radius:8px; background:var(--card); color:var(--fg) }+ button { font:inherit; padding:10px 16px; border:0; border-radius:8px; background:var(--accent); color:#fff; cursor:pointer }+ button:disabled { opacity:.6; cursor:wait }+ .card { background:var(--card); border:1px solid var(--line); border-radius:10px; padding:14px 16px; margin:0 0 14px }+ .status { font-weight:600 } .done{color:var(--ok)} .failed,.error{color:var(--bad)} .asked{color:var(--ask)}+ .say { font-size:18px; margin:6px 0 }+ pre { margin:8px 0 0; padding:10px 12px; background:var(--bg); border:1px solid var(--line); border-radius:8px; overflow:auto; font:13px/1.45 ui-monospace,SFMono-Regular,Menlo,monospace; white-space:pre-wrap }+ details summary { cursor:pointer; color:var(--mute) }+ ul.tries { list-style:none; padding:0; margin:0; display:flex; flex-wrap:wrap; gap:8px }+ ul.tries li a { display:inline-block; padding:6px 10px; border:1px solid var(--line); border-radius:999px; background:var(--card); color:var(--fg); text-decoration:none; font-size:14px }+ table { width:100%; border-collapse:collapse; font-size:14px } td,th { text-align:left; padding:6px 8px; border-top:1px solid var(--line); vertical-align:top } th { color:var(--mute); font-weight:600; border-top:0 }+ code { font:13px ui-monospace,SFMono-Regular,Menlo,monospace }+ .mute { color:var(--mute) } .gap { color:var(--ask) }+ footer { color:var(--mute); font-size:13px; margin-top:32px }+</style>+</head>+<body>+<main>+ <h1>Pyrtan <small id="who">…</small></h1>+ <p class="lede">A swarm of small services that act on the web through hardcoded, inspectable decision trees. Type a sentence a tree answers to; the answer comes back with the exact path that produced it. No model is involved.</p>++ <form id="ask">+ <input type="text" id="text" placeholder="what's the weather in Oslo" autofocus autocomplete="off">+ <button type="submit" id="go">Ask</button>+ </form>+ <div class="card" style="margin-bottom:24px">+ <div class="mute" style="margin-bottom:8px">Try one</div>+ <ul class="tries" id="tries"></ul>+ </div>++ <div id="out"></div>++ <h2 style="font-size:17px;margin:32px 0 8px">What this swarm can answer</h2>+ <div class="card"><table id="caps"><thead><tr><th>Tree</th><th>Answers to</th><th>From</th></tr></thead><tbody></tbody></table></div>++ <footer>The same endpoints the page uses: <code>POST /intent {"text": "…"}</code>, <code>POST /run {"tree": "…", "inputs": {…}}</code>, <code>GET /capabilities</code>, <code>GET /health</code>, <code>GET /gaps</code>. <a href="https://github.com/b-i-t-8/pyrtan">Source</a>.</footer>+</main>+<script>+const $ = (s) => document.querySelector(s);+const esc = (s) => String(s).replace(/[&<>"]/g, c => ({"&":"&","<":"<",">":">",'"':"""}[c]));+const examples = {+ "weather": "what's the weather in Oslo",+ "hacker-news": "top 3 stories on hacker news",+ "site-status": "is example.com up",+ "github-release": "latest release of astral-sh/uv",+ "hacker-news-item": "hacker news item 1",+};+function sample(t) {+ if (examples[t.name]) return examples[t.name];+ const pat = (t.triggers || [])[0]; if (!pat) return null;+ return pat.replace(/\[[^\]]*\]/g, "").replace(/\((\w+)\|[^)]*\)/g, "$1").replace(/\{(\w+)(?::\w+)?\}/g, (m, n) => (t.inputs && t.inputs[n] && t.inputs[n].default != null) ? t.inputs[n].default : "…").replace(/\s+/g, " ").trim();+}+async function load() {+ try {+ const h = await (await fetch("health")).json();+ $("#who").textContent = (h.name ? h.name + " · " : "") + (h.peers != null ? `${h.up}/${h.peers} peers up, ${h.trees} trees` : `${(h.trees||[]).length} trees`);+ } catch (e) { $("#who").textContent = "unreachable"; }+ const c = await (await fetch("capabilities")).json();+ const body = $("#caps tbody"); const tries = $("#tries");+ for (const t of c.trees || []) {+ const tr = document.createElement("tr");+ tr.innerHTML = `<td><code>${esc(t.name)}</code><div class="mute">${esc(t.description||"")}</div></td><td>${(t.triggers||[]).map(p => `<div><code>${esc(p)}</code></div>`).join("")}</td><td class="mute">${esc((t.hosts||[]).join(", "))}${t.peer ? `<div>${esc(t.peer)} · ${esc(t.link||"")}</div>` : ""}</td>`;+ body.appendChild(tr);+ const s = sample(t); if (s && !s.includes("…")) { const li = document.createElement("li"); li.innerHTML = `<a href="#" data-q="${esc(s)}">${esc(s)}</a>`; tries.appendChild(li); }+ }+ tries.addEventListener("click", (e) => { const a = e.target.closest("a[data-q]"); if (!a) return; e.preventDefault(); $("#text").value = a.dataset.q; ask(); });+}+function renderTrace(tr) {+ if (!tr || !tr.steps) return "";+ const lines = tr.steps.map(s => `${" ".repeat((s.depth||0)+1)}${s.node} (${s.kind})${s.chosen ? " [" + s.chosen + "]" : ""}: ${s.summary}${s.next ? " -> " + s.next : ""}`);+ for (const g of tr.gaps || []) lines.push(` gap: ${g.node} saw ${g.on} = ${JSON.stringify(g.observed)} and had no branch for it`);+ return `trace: ${tr.tree}\n` + lines.join("\n");+}+async function ask(snapshot, answer) {+ const text = $("#text").value.trim(); if (!text && !snapshot) return;+ $("#go").disabled = true;+ const t0 = performance.now();+ let r;+ try {+ const res = snapshot ? await fetch("resume", {method:"POST", headers:{"Content-Type":"application/json"}, body: JSON.stringify({snapshot, answer})})+ : await fetch("intent", {method:"POST", headers:{"Content-Type":"application/json"}, body: JSON.stringify({text})});+ r = await res.json();+ } catch (e) { r = {status:"error", reason:"unreachable", say:String(e)}; }+ const ms = Math.round(performance.now() - t0);+ const out = $("#out");+ const card = document.createElement("div"); card.className = "card";+ let html = `<div><span class="status ${esc(r.status)}">${esc(r.status)}</span>${r.reason ? ` <span class="mute">(${esc(r.reason)})</span>` : ""} <span class="mute">· ${esc(r.tree||"")} · ${ms} ms${r.peer ? " · " + esc(r.peer) : ""}</span></div>`;+ if (r.say) html += `<div class="say">${esc(r.say)}</div>`;+ if (r.status === "asked") html += `<form class="resume"><input type="text" placeholder="${esc(r.question||"")}" autofocus><button>Answer</button></form>`;+ if (r.output != null) html += `<details open><summary>output</summary><pre>${esc(JSON.stringify(r.output, null, 2))}</pre></details>`;+ if (r.trace) html += `<details open><summary>trace</summary><pre>${esc(renderTrace(r.trace))}</pre></details>`;+ card.innerHTML = html;+ out.prepend(card);+ const rf = card.querySelector("form.resume");+ if (rf) rf.addEventListener("submit", (e) => { e.preventDefault(); ask(r.snapshot, rf.querySelector("input").value); });+ $("#go").disabled = false;+}+$("#ask").addEventListener("submit", (e) => { e.preventDefault(); ask(); });+load();+</script>+</body>+</html>+"""diff --git a/src/pyrtan/registry.py b/src/pyrtan/registry.py new file mode 100644 index 0000000..7c3c56f --- /dev/null +++ b/src/pyrtan/registry.py@@ -0,0 +1,158 @@+"""The trees a service knows about, and how a sentence finds one.++A registry loads every ``*.toml`` under one or more directories, reloads a+file when its modification time changes (so trees can be edited while a+service runs), and matches intents against the triggers of everything+loaded.+"""++from __future__ import annotations++import os+import threading+from dataclasses import dataclass, field+from pathlib import Path+from typing import Any, Iterable++from .intent import Match, Trigger, best_matches+from .tree import Tree, TreeError, load_tree+++class Ambiguous(LookupError):+ def __init__(self, text: str, matches: list[Match]) -> None:+ self.text = text+ self.matches = matches+ names = ", ".join(f"{m.tree} ({m.trigger.pattern!r})" for m in matches)+ super().__init__(f"{text!r} matches more than one tree equally well: {names}")+++class NoMatch(LookupError):+ pass+++@dataclass+class _Loaded:+ tree: Tree+ path: Path | None+ mtime: float+++@dataclass+class Registry:+ directories: list[Path] = field(default_factory=list)+ _trees: dict[str, _Loaded] = field(default_factory=dict, init=False, repr=False)+ _lock: threading.RLock = field(default_factory=threading.RLock, init=False, repr=False)+ errors: dict[str, str] = field(default_factory=dict, init=False)++ @classmethod+ def from_paths(cls, paths: Iterable[str | Path]) -> "Registry":+ reg = cls()+ for p in paths:+ p = Path(p)+ if p.is_dir():+ reg.directories.append(p)+ else:+ reg.add_file(p)+ reg.reload()+ return reg++ # ------------------------------------------------------------ loading++ def add(self, tree: Tree) -> None:+ with self._lock:+ self._trees[tree.name] = _Loaded(tree, None, 0.0)++ def add_file(self, path: str | Path) -> Tree:+ path = Path(path)+ tree = load_tree(path)+ with self._lock:+ self._trees[tree.name] = _Loaded(tree, path, path.stat().st_mtime)+ self.errors.pop(str(path), None)+ return tree++ def reload(self) -> None:+ """Load new files, reload changed ones, drop deleted ones."""+ with self._lock:+ seen: set[Path] = set()+ for d in self.directories:+ for path in sorted(d.rglob("*.toml")):+ if path.name == "pyproject.toml":+ continue+ seen.add(path)+ try:+ mtime = path.stat().st_mtime+ except OSError:+ continue+ current = next((l for l in self._trees.values() if l.path == path), None)+ if current is not None and current.mtime == mtime:+ continue+ try:+ tree = load_tree(path)+ except TreeError as e:+ self.errors[str(path)] = str(e)+ continue+ self.errors.pop(str(path), None)+ if current is not None and current.tree.name != tree.name:+ del self._trees[current.tree.name]+ self._trees[tree.name] = _Loaded(tree, path, mtime)+ for name, loaded in list(self._trees.items()):+ if loaded.path is not None and loaded.path not in seen and any(loaded.path.is_relative_to(d) for d in self.directories):+ if not loaded.path.exists():+ del self._trees[name]++ # ------------------------------------------------------------ lookup++ def names(self) -> list[str]:+ with self._lock:+ return sorted(self._trees)++ def trees(self) -> list[Tree]:+ with self._lock:+ return [l.tree for _, l in sorted(self._trees.items())]++ def get(self, name: str) -> Tree | None:+ with self._lock:+ loaded = self._trees.get(name)+ return loaded.tree if loaded else None++ def __contains__(self, name: object) -> bool:+ return isinstance(name, str) and self.get(name) is not None++ def __len__(self) -> int:+ return len(self._trees)++ def candidates(self) -> list[tuple[str, Trigger]]:+ return [(t.name, trig) for t in self.trees() for trig in t.triggers]++ def match(self, text: str) -> Match:+ """The one tree that answers ``text``, or :class:`NoMatch` /+ :class:`Ambiguous`."""+ matches = best_matches(text, self.candidates())+ if not matches:+ raise NoMatch(f"no tree answers {text!r}")+ if len(matches) > 1:+ # Same tree matched twice at equal specificity: take the first.+ if len({m.tree for m in matches}) == 1:+ return matches[0]+ raise Ambiguous(text, matches)+ return matches[0]++ def capabilities(self) -> list[dict[str, Any]]:+ return [t.capability() for t in self.trees()]++ def gather(self, root: str | Path) -> int:+ """Add a directory and load it. Returns how many trees are loaded."""+ root = Path(root)+ if root not in self.directories:+ self.directories.append(root)+ self.reload()+ return len(self)+++def default_paths() -> list[Path]:+ """Where ``pyrtan`` looks when no path is given: ``$PYRTAN_TREES`` (colon+ separated) or ``./trees``."""+ env = os.environ.get("PYRTAN_TREES")+ if env:+ return [Path(p) for p in env.split(os.pathsep) if p]+ return [Path("trees")]diff --git a/src/pyrtan/runtime.py b/src/pyrtan/runtime.py new file mode 100644 index 0000000..8d480a8 --- /dev/null +++ b/src/pyrtan/runtime.py@@ -0,0 +1,409 @@+"""Executing a tree.++The :class:`Runner` walks a tree from ``start`` to a terminal node, keeping a+state table, a trace of every step, and the tree's host allow-list in front+of every request. It returns an :class:`Outcome`.++An outcome's ``status`` is one of:++``done``+ A ``done`` node was reached; ``say`` and ``output`` are set.+``failed``+ A ``fail`` node was reached; ``reason`` names which, and ``say`` explains.+``asked``+ An ``ask`` node paused the run; ``question`` holds the prompt and+ ``snapshot`` what :meth:`Runner.resume` needs to continue.+``error``+ The run could not proceed: a transport failure, an unhandled observation+ (a ``decide`` with no matching branch, recorded as a gap), a host outside+ the allow-list, a missing template value, or a limit reached.++Nothing here calls a model. The runner is the whole of Pyrtan's "thinking";+the trace is the whole of its explanation.+"""++from __future__ import annotations++import datetime as _dt+import time+from dataclasses import dataclass, field+from typing import Any, Protocol++from . import conditions+from .extract import extract+from .registry import Ambiguous, NoMatch, Registry+from .template import MISSING, TemplateError, render, resolve+from .trace import Step, Trace+from .transport import Transport, TransportError, UrllibTransport+from .tree import Node, Tree, TreeError++MAX_STEPS = 500+MAX_DEPTH = 8+DEFAULT_TIMEOUT = 10.0+DEFAULT_RETRIES = 1+++class Resolver(Protocol):+ """Where ``delegate`` and ``each ... tree`` go when a tree is not local."""++ def run_tree(self, name: str, inputs: dict[str, Any], depth: int) -> "Outcome": ...++ def run_intent(self, text: str, depth: int) -> "Outcome": ...+++@dataclass+class Outcome:+ status: str+ tree: str+ say: str | None = None+ output: Any = None+ reason: str | None = None+ question: str | None = None+ snapshot: dict[str, Any] | None = None+ trace: Trace | None = None+ inputs: dict[str, Any] = field(default_factory=dict)++ @property+ def ok(self) -> bool:+ return self.status == "done"++ def to_dict(self, *, with_trace: bool = True) -> dict[str, Any]:+ d: dict[str, Any] = {"status": self.status, "tree": self.tree, "inputs": self.inputs}+ if self.say is not None:+ d["say"] = self.say+ if self.output is not None:+ d["output"] = self.output+ if self.reason is not None:+ d["reason"] = self.reason+ if self.question is not None:+ d["question"] = self.question+ if self.snapshot is not None:+ d["snapshot"] = self.snapshot+ if with_trace and self.trace is not None:+ d["trace"] = self.trace.to_dict()+ return d+++class _Halt(Exception):+ def __init__(self, outcome: Outcome) -> None:+ self.outcome = outcome+++@dataclass+class Runner:+ registry: Registry+ transport: Transport = field(default_factory=UrllibTransport)+ resolver: Resolver | None = None+ env: dict[str, Any] = field(default_factory=dict)+ max_steps: int = MAX_STEPS+ max_depth: int = MAX_DEPTH+ emitted: list[dict[str, Any]] = field(default_factory=list)++ # ------------------------------------------------------------ entry points++ def run_intent(self, text: str, depth: int = 0) -> Outcome:+ try:+ match = self.registry.match(text)+ except NoMatch:+ if self.resolver is not None:+ return self.resolver.run_intent(text, depth)+ return Outcome("error", "", reason="no-match", say=f"Nothing here answers {text!r}.")+ except Ambiguous as e:+ return Outcome("error", "", reason="ambiguous", say=str(e), output={"candidates": [m.tree for m in e.matches]})+ return self.run_tree(match.tree, match.inputs, depth)++ def run_tree(self, name: str, inputs: dict[str, Any] | None = None, depth: int = 0) -> Outcome:+ tree = self.registry.get(name)+ if tree is None:+ if self.resolver is not None:+ return self.resolver.run_tree(name, inputs or {}, depth)+ return Outcome("error", name, reason="no-such-tree", say=f"No tree named {name!r}.")+ return self.run(tree, inputs or {}, depth=depth)++ def run(self, tree: Tree, inputs: dict[str, Any] | None = None, *, depth: int = 0, start: str | None = None, state: dict[str, Any] | None = None, scope_extra: dict[str, Any] | None = None, trace: Trace | None = None) -> Outcome:+ trace = trace or Trace(tree.name)+ if depth > self.max_depth:+ return Outcome("error", tree.name, reason="too-deep", say=f"Delegation deeper than {self.max_depth} levels.", trace=trace)+ try:+ bound = tree.bind_inputs(inputs or {})+ except TreeError as e:+ return Outcome("error", tree.name, reason="bad-input", say=str(e), trace=trace)+ state = dict(state) if state is not None else {}+ scope: dict[str, Any] = {"inputs": bound, "state": state, "tree": {"name": tree.name, "version": tree.version}, "now": _now(), "env": self.env}+ if scope_extra:+ scope.update(scope_extra)+ try:+ return self._walk(tree, start or tree.start, scope, trace, depth)+ except _Halt as h:+ h.outcome.inputs = bound+ h.outcome.trace = h.outcome.trace or trace+ return h.outcome++ def resume(self, snapshot: dict[str, Any], answer: Any) -> Outcome:+ """Continue a run paused by ``ask``. The snapshot is what the asked+ outcome carried; the answer goes where the ask node said."""+ tree = self.registry.get(snapshot["tree"])+ if tree is None:+ return Outcome("error", snapshot.get("tree", ""), reason="no-such-tree")+ node = tree.node(snapshot["node"])+ into: str = node.get("into")+ inputs = dict(snapshot["inputs"])+ state = dict(snapshot["state"])+ if into.startswith("inputs."):+ key = into[len("inputs."):]+ try:+ inputs[key] = tree.inputs[key].coerce(answer)+ except TreeError as e:+ return Outcome("error", tree.name, reason="bad-input", say=str(e))+ else:+ _put(state, into[len("state."):] if into.startswith("state.") else into, answer)+ trace = Trace(tree.name)+ trace.steps = [Step(**s) for s in snapshot.get("trace", [])]+ trace.add(Step(node.id, "ask", f"answered: {_brief(answer)}", next=node.next))+ return self.run(tree, inputs, start=node.next, state=state, trace=trace)++ # ------------------------------------------------------------ the walk++ def _walk(self, tree: Tree, start: str, scope: dict[str, Any], trace: Trace, depth: int) -> Outcome:+ state: dict[str, Any] = scope["state"]+ node_id = start+ steps = 0+ while True:+ steps += 1+ if steps > self.max_steps:+ raise _Halt(Outcome("error", tree.name, reason="too-many-steps", say=f"More than {self.max_steps} steps.", trace=trace))+ node = tree.node(node_id)+ started = time.monotonic()+ handler = getattr(self, f"_do_{node.kind}")+ try:+ result = handler(tree, node, scope, trace, depth)+ except TemplateError as e:+ trace.add(Step(node.id, node.kind, f"template: {e}", depth=depth))+ raise _Halt(Outcome("error", tree.name, reason="template", say=f"{node.id}: {e}", trace=trace))+ if isinstance(result, Outcome):+ result.trace = trace+ raise _Halt(result)+ step, next_id = result+ step.elapsed_ms = int((time.monotonic() - started) * 1000)+ step.depth = depth+ step.next = next_id+ trace.add(step)+ if next_id is None:+ raise _Halt(Outcome("error", tree.name, reason="dead-end", say=f"{node.id} has nowhere to go.", trace=trace))+ node_id = next_id++ # ------------------------------------------------------------ node kinds++ def _do_observe(self, tree: Tree, node: Node, scope: dict[str, Any], trace: Trace, depth: int):+ return self._observe_or_act(tree, node, scope, trace, retry=True)++ def _do_act(self, tree: Tree, node: Node, scope: dict[str, Any], trace: Trace, depth: int):+ emit = node.get("emit")+ if emit is not None:+ event = {"event": render(emit["event"], scope), "data": render(emit.get("data", {}), scope), "tree": tree.name}+ self.emitted.append(event)+ _put(scope["state"], node.get("into", "last"), event)+ return Step(node.id, "act", f"emitted {event['event']}", observed=event["data"]), node.next+ return self._observe_or_act(tree, node, scope, trace, retry=False)++ def _observe_or_act(self, tree: Tree, node: Node, scope: dict[str, Any], trace: Trace, *, retry: bool):+ state = scope["state"]+ into = node.get("into", "last")+ http = node.get("http")+ if http is not None:+ req = render(http, scope)+ method = str(req.get("method", "GET")).upper()+ url = str(req["url"])+ if not tree.host_allowed(url):+ return Outcome("error", tree.name, reason="host-not-allowed", say=f"{node.id}: {url} is outside this tree's hosts {tree.hosts}.")+ headers = {str(k): str(v) for k, v in (req.get("headers") or {}).items()}+ body = req.get("body")+ if body is not None and not isinstance(body, str):+ import json as _json++ body = _json.dumps(body)+ headers.setdefault("Content-Type", "application/json")+ timeout = float(node.get("timeout", DEFAULT_TIMEOUT))+ attempts = 1 + int(node.get("retries", DEFAULT_RETRIES)) if retry else 1+ last_error: str | None = None+ for attempt in range(attempts):+ try:+ resp = self.transport.request(method, url, headers, body, timeout)+ break+ except TransportError as e:+ last_error = str(e)+ if attempt + 1 < attempts:+ time.sleep(min(0.2 * (attempt + 1), 1.0))+ else:+ trace.add(Step(node.id, node.kind, f"{method} {url} failed: {last_error}"))+ return Outcome("error", tree.name, reason="transport", say=f"{node.id}: {last_error}")+ observed = resp.to_state()+ _put(state, into, observed)+ source = observed+ summary = f"{method} {url} -> {resp.status}"+ else:+ frm = node.get("from")+ source_val = resolve(_state_path(frm), scope)+ if source_val is MISSING:+ source = {}+ elif isinstance(source_val, dict):+ source = source_val+ else:+ source = {"value": source_val, "body": source_val if isinstance(source_val, str) else None}+ summary = f"from {frm}"+ extracted: dict[str, Any] = {}+ cache: dict[int, Any] = {}+ for entry in node.get("extract", []) or []:+ value = extract(entry, source, cache)+ if value is MISSING:+ extracted[entry["into"]] = None+ else:+ _put(state, entry["into"], value)+ extracted[entry["into"]] = value+ if extracted:+ summary += "; " + ", ".join(f"{k}={_brief(v)}" for k, v in extracted.items())+ return Step(node.id, node.kind, summary, observed=extracted or None), node.next++ def _do_decide(self, tree: Tree, node: Node, scope: dict[str, Any], trace: Trace, depth: int):+ on = _state_path(node.get("on"))+ value = resolve(on, scope)+ for i, br in enumerate(node.get("branches")):+ if "else" in br:+ return Step(node.id, "decide", f"{on} = {_brief(value)}", observed=_plain(value), chosen="else"), br["else"]+ if conditions.holds(br["when"], value):+ return Step(node.id, "decide", f"{on} = {_brief(value)}", observed=_plain(value), chosen=_cond_text(br["when"])), br["then"]+ trace.gap(node.id, on, _plain(value))+ trace.add(Step(node.id, "decide", f"{on} = {_brief(value)}: no branch", observed=_plain(value), depth=depth))+ return Outcome("error", tree.name, reason="gap", say=f"{node.id}: no branch for {on} = {_brief(value)}.", output={"gap": trace.gaps[-1]})++ def _do_set(self, tree: Tree, node: Node, scope: dict[str, Any], trace: Trace, depth: int):+ state = scope["state"]+ values = {}+ for key, tmpl in node.get("set").items():+ value = render(tmpl, scope)+ _put(state, key, value)+ values[key] = value+ return Step(node.id, "set", ", ".join(f"{k}={_brief(v)}" for k, v in values.items()), observed=values), node.next++ def _do_each(self, tree: Tree, node: Node, scope: dict[str, Any], trace: Trace, depth: int):+ state = scope["state"]+ items = resolve(_state_path(node.get("over")), scope)+ if items is MISSING or items is None:+ items = []+ if not isinstance(items, list):+ return Outcome("error", tree.name, reason="not-a-list", say=f"{node.id}: {node.get('over')} is not a list.")+ limit = node.get("limit")+ if limit is not None:+ limit = int(render(limit, scope))+ items = items[:limit]+ results: list[Any] = []+ failures = 0+ for index, item in enumerate(items):+ with_inputs = render(node.get("with", {}), {**scope, "item": item, "index": index})+ if "tree" in node.spec:+ sub = self.run_tree(node.get("tree"), with_inputs, depth + 1)+ else:+ sub = self.run(tree, scope["inputs"], depth=depth + 1, start=node.get("start"), state=state, scope_extra={"item": item, "index": index}, trace=trace)+ if sub.status == "done":+ results.append(sub.output if sub.output is not None else sub.say)+ else:+ failures += 1+ results.append(None)+ if node.get("stop_on_fail", False):+ return Outcome("error", tree.name, reason="each-failed", say=f"{node.id}: item {index} failed: {sub.say or sub.reason}")+ if sub.trace is not None and sub.trace is not trace:+ for s in sub.trace.steps:+ s.depth = depth + 1+ trace.steps.append(s)+ trace.gaps.extend(sub.trace.gaps)+ _put(state, node.get("into"), results)+ summary = f"{len(items)} item(s)" + (f", {failures} failed" if failures else "")+ return Step(node.id, "each", summary), node.next++ def _do_delegate(self, tree: Tree, node: Node, scope: dict[str, Any], trace: Trace, depth: int):+ state = scope["state"]+ with_inputs = render(node.get("with", {}), scope)+ if "intent" in node.spec:+ text = render(node.get("intent"), scope)+ sub = self.run_intent(str(text), depth + 1)+ summary = f"intent {text!r} -> {sub.tree or '?'}: {sub.status}"+ else:+ sub = self.run_tree(node.get("tree"), with_inputs, depth + 1)+ summary = f"tree {node.get('tree')}: {sub.status}"+ if sub.trace is not None and sub.trace is not trace:+ for s in sub.trace.steps:+ s.depth = depth + 1+ trace.steps.append(s)+ trace.gaps.extend(sub.trace.gaps)+ _put(state, node.get("into"), {"status": sub.status, "say": sub.say, "output": sub.output, "reason": sub.reason, "tree": sub.tree})+ if sub.status == "asked":+ return Outcome("error", tree.name, reason="ask-in-delegate", say=f"{node.id}: {sub.tree} asked a question; delegated trees cannot pause.")+ return Step(node.id, "delegate", summary), node.next++ def _do_ask(self, tree: Tree, node: Node, scope: dict[str, Any], trace: Trace, depth: int):+ if depth > 0:+ return Outcome("error", tree.name, reason="ask-in-subflow", say=f"{node.id}: only a top-level run can ask.")+ prompt = render(node.get("prompt"), scope)+ trace.add(Step(node.id, "ask", f"asked: {prompt}", depth=depth))+ snapshot = {"tree": tree.name, "node": node.id, "inputs": scope["inputs"], "state": scope["state"], "trace": [s.to_dict() for s in trace.steps]}+ return Outcome("asked", tree.name, question=str(prompt), snapshot=snapshot)++ def _do_done(self, tree: Tree, node: Node, scope: dict[str, Any], trace: Trace, depth: int):+ say = node.get("say")+ output = node.get("output")+ say = render(say, scope, strict=False) if say is not None else None+ output = render(output, scope, strict=False) if output is not None else None+ trace.add(Step(node.id, "done", str(say) if say else "done", depth=depth))+ return Outcome("done", tree.name, say=str(say) if say is not None else None, output=output)++ def _do_fail(self, tree: Tree, node: Node, scope: dict[str, Any], trace: Trace, depth: int):+ say = node.get("say")+ say = render(say, scope, strict=False) if say is not None else None+ reason = str(node.get("reason"))+ trace.add(Step(node.id, "fail", f"{reason}: {say or ''}".rstrip(": "), depth=depth))+ return Outcome("failed", tree.name, reason=reason, say=str(say) if say is not None else None, output=render(node.get("output"), scope, strict=False) if node.get("output") is not None else None)+++# ---------------------------------------------------------------- helpers+++def _state_path(path: str) -> str:+ path = path.strip()+ head = path.split(".")[0].split("[")[0]+ if head in ("inputs", "state", "item", "index", "tree", "now", "env"):+ return path+ return "state." + path+++def _put(state: dict[str, Any], path: str, value: Any) -> None:+ if path.startswith("state."):+ path = path[len("state."):]+ parts = path.split(".")+ cur = state+ for part in parts[:-1]:+ nxt = cur.get(part)+ if not isinstance(nxt, dict):+ nxt = {}+ cur[part] = nxt+ cur = nxt+ cur[parts[-1]] = value+++def _plain(value: Any) -> Any:+ return None if value is MISSING else value+++def _brief(value: Any, limit: int = 80) -> str:+ if value is MISSING:+ return "<missing>"+ text = repr(value) if not isinstance(value, str) else value+ return text if len(text) <= limit else text[: limit - 1] + "…"+++def _cond_text(cond: dict[str, Any]) -> str:+ return " and ".join(f"{k} {v!r}" for k, v in cond.items())+++def _now() -> dict[str, Any]:+ now = _dt.datetime.now(_dt.timezone.utc)+ return {"iso": now.isoformat(timespec="seconds"), "date": now.date().isoformat(), "epoch": int(now.timestamp()), "year": now.year}diff --git a/src/pyrtan/service.py b/src/pyrtan/service.py new file mode 100644 index 0000000..f641f1d --- /dev/null +++ b/src/pyrtan/service.py@@ -0,0 +1,262 @@+"""One member of the swarm: an HTTP service hosting a registry of trees.++Endpoints, all JSON:++``GET /health``+ ``{"status": "ok", "name": ..., "trees": [...], "errors": {...}}``+``GET /capabilities``+ What this service can do: every tree's name, triggers and inputs.+``POST /intent`` ``{"text": "..."}``+ Match a sentence to a tree and run it.+``POST /run`` ``{"tree": "...", "inputs": {...}}``+ Run a named tree.+``POST /resume`` ``{"snapshot": {...}, "answer": ...}``+ Continue a run that asked a question.+``GET /gaps``+ Decisions that met a value no branch covered, since the service started.+ Each is a branch waiting to be written.+``GET /``+ The demo page (:mod:`pyrtan.page`); ``GET /api`` is the JSON index.++A service may be given a hub (``--hub``). Then it registers itself on start,+and a ``delegate`` to a tree it does not hold is forwarded to the hub.++The server is the standard library's threading HTTP server. It is meant for+a swarm of small processes on a trusted network, behind whatever ingress+the operator already runs; it does not do TLS or authentication itself.+"""++from __future__ import annotations++import json+import threading+import time+import urllib.error+import urllib.request+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer+from pathlib import Path+from typing import Any, Callable++from .page import PAGE+from .registry import Registry+from .runtime import Outcome, Runner+from .trace import Trace+from .transport import Transport, UrllibTransport++MAX_BODY = 1 << 20+++def http_json(method: str, url: str, payload: Any = None, timeout: float = 15.0) -> tuple[int, Any]:+ data = json.dumps(payload).encode("utf-8") if payload is not None else None+ req = urllib.request.Request(url, data=data, method=method, headers={"Content-Type": "application/json", "Accept": "application/json"})+ try:+ with urllib.request.urlopen(req, timeout=timeout) as resp:+ return resp.status, json.loads(resp.read().decode("utf-8") or "null")+ except urllib.error.HTTPError as e:+ try:+ return e.code, json.loads(e.read().decode("utf-8") or "null")+ except Exception:+ return e.code, None+++class RemoteResolver:+ """Sends what a local runner cannot answer to a hub or another service."""++ def __init__(self, base_url: str) -> None:+ self.base_url = base_url.rstrip("/")++ def run_tree(self, name: str, inputs: dict[str, Any], depth: int) -> Outcome:+ status, body = http_json("POST", f"{self.base_url}/run", {"tree": name, "inputs": inputs, "depth": depth})+ return outcome_from_dict(body, name) if isinstance(body, dict) else Outcome("error", name, reason="remote", say=f"{self.base_url} answered {status}")++ def run_intent(self, text: str, depth: int) -> Outcome:+ status, body = http_json("POST", f"{self.base_url}/intent", {"text": text, "depth": depth})+ return outcome_from_dict(body, "") if isinstance(body, dict) else Outcome("error", "", reason="remote", say=f"{self.base_url} answered {status}")+++def outcome_from_dict(d: dict[str, Any], fallback_tree: str = "") -> Outcome:+ trace = None+ if isinstance(d.get("trace"), dict):+ from .trace import Step++ trace = Trace(d["trace"].get("tree", fallback_tree))+ trace.steps = [Step(**{k: v for k, v in s.items() if k in Step.__dataclass_fields__}) for s in d["trace"].get("steps", [])]+ trace.gaps = list(d["trace"].get("gaps", []))+ return Outcome(+ status=d.get("status", "error"),+ tree=d.get("tree") or fallback_tree,+ say=d.get("say"),+ output=d.get("output"),+ reason=d.get("reason"),+ question=d.get("question"),+ snapshot=d.get("snapshot"),+ trace=trace,+ inputs=d.get("inputs") or {},+ )+++class Service:+ def __init__(self, registry: Registry, *, name: str = "pyrtan", transport: Transport | None = None, hub: str | None = None, env: dict[str, Any] | None = None, advertise: str | None = None) -> None:+ self.registry = registry+ self.name = name+ self.hub = hub.rstrip("/") if hub else None+ self.advertise = advertise+ self.started = time.time()+ self.runner = Runner(registry, transport or UrllibTransport(), resolver=RemoteResolver(self.hub) if self.hub else None, env=env or {})+ self.gaps: list[dict[str, Any]] = []+ self.runs = 0+ self._lock = threading.Lock()++ # ------------------------------------------------------------ handlers++ def health(self) -> dict[str, Any]:+ self.registry.reload()+ return {"status": "ok", "name": self.name, "trees": self.registry.names(), "errors": self.registry.errors, "runs": self.runs, "gaps": len(self.gaps), "uptime_s": int(time.time() - self.started)}++ def capabilities(self) -> dict[str, Any]:+ self.registry.reload()+ return {"name": self.name, "trees": self.registry.capabilities()}++ def intent(self, body: dict[str, Any]) -> dict[str, Any]:+ text = body.get("text")+ if not isinstance(text, str) or not text.strip():+ return {"status": "error", "reason": "bad-request", "say": "Send {\"text\": \"...\"}."}+ self.registry.reload()+ return self._finish(self.runner.run_intent(text, int(body.get("depth", 0))), body)++ def run(self, body: dict[str, Any]) -> dict[str, Any]:+ tree = body.get("tree")+ if not isinstance(tree, str):+ return {"status": "error", "reason": "bad-request", "say": "Send {\"tree\": \"...\", \"inputs\": {...}}."}+ self.registry.reload()+ return self._finish(self.runner.run_tree(tree, body.get("inputs") or {}, int(body.get("depth", 0))), body)++ def resume(self, body: dict[str, Any]) -> dict[str, Any]:+ snapshot = body.get("snapshot")+ if not isinstance(snapshot, dict) or "tree" not in snapshot:+ return {"status": "error", "reason": "bad-request", "say": "Send {\"snapshot\": {...}, \"answer\": ...}."}+ return self._finish(self.runner.resume(snapshot, body.get("answer")), body)++ def _finish(self, outcome: Outcome, body: dict[str, Any]) -> dict[str, Any]:+ with self._lock:+ self.runs += 1+ if outcome.trace is not None:+ self.gaps.extend(outcome.trace.gaps)+ return outcome.to_dict(with_trace=bool(body.get("trace", True)))++ # ------------------------------------------------------------ hub++ def register(self, my_url: str) -> bool:+ if not self.hub:+ return False+ try:+ status, _ = http_json("POST", f"{self.hub}/register", {"url": my_url, "name": self.name})+ return status == 200+ except Exception:+ return False++ # ------------------------------------------------------------ routing++ def route(self, method: str, path: str, body: dict[str, Any] | None) -> tuple[int, Any]:+ body = body or {}+ if method == "GET" and path == "/health":+ return 200, self.health()+ if method == "GET" and path == "/capabilities":+ return 200, self.capabilities()+ if method == "GET" and path == "/gaps":+ return 200, {"gaps": self.gaps}+ if method == "GET" and path in ("/", "/index.html"):+ return 200, PAGE+ if method == "GET" and path == "/api":+ return 200, {"pyrtan": "service", "name": self.name, "endpoints": ["/health", "/capabilities", "/intent", "/run", "/resume", "/gaps"]}+ if method == "POST" and path == "/intent":+ return 200, self.intent(body)+ if method == "POST" and path == "/run":+ return 200, self.run(body)+ if method == "POST" and path == "/resume":+ return 200, self.resume(body)+ return 404, {"status": "error", "reason": "not-found", "say": f"No {method} {path} here."}+++def make_handler(route: Callable[[str, str, dict[str, Any] | None], tuple[int, Any]], quiet: bool = False):+ class Handler(BaseHTTPRequestHandler):+ server_version = "pyrtan/0.1"++ def log_message(self, fmt: str, *args: Any) -> None:+ if not quiet:+ super().log_message(fmt, *args)++ def _send(self, status: int, payload: Any) -> None:+ if isinstance(payload, str):+ data = payload.encode("utf-8")+ ctype = "text/html; charset=utf-8"+ else:+ data = json.dumps(payload, ensure_ascii=False).encode("utf-8")+ ctype = "application/json; charset=utf-8"+ self.send_response(status)+ self.send_header("Content-Type", ctype)+ self.send_header("Content-Length", str(len(data)))+ self.end_headers()+ self.wfile.write(data)++ def do_GET(self) -> None:+ status, payload = route("GET", self.path.split("?", 1)[0], None)+ self._send(status, payload)++ def do_POST(self) -> None:+ length = int(self.headers.get("Content-Length") or 0)+ if length > MAX_BODY:+ self._send(413, {"status": "error", "reason": "too-large"})+ return+ raw = self.rfile.read(length) if length else b""+ try:+ body = json.loads(raw.decode("utf-8")) if raw else {}+ except (json.JSONDecodeError, UnicodeDecodeError):+ self._send(400, {"status": "error", "reason": "bad-json"})+ return+ if not isinstance(body, dict):+ self._send(400, {"status": "error", "reason": "bad-json", "say": "The body must be a JSON object."})+ return+ status, payload = route("POST", self.path.split("?", 1)[0], body)+ self._send(status, payload)++ return Handler+++class Server:+ """A running HTTP server around a route function, for a service or a hub."""++ def __init__(self, route: Callable[[str, str, dict[str, Any] | None], tuple[int, Any]], host: str = "127.0.0.1", port: int = 0, quiet: bool = False) -> None:+ self.httpd = ThreadingHTTPServer((host, port), make_handler(route, quiet))+ self.httpd.daemon_threads = True+ self.thread: threading.Thread | None = None++ @property+ def port(self) -> int:+ return self.httpd.server_address[1]++ @property+ def url(self) -> str:+ host = self.httpd.server_address[0]+ return f"http://{host}:{self.port}"++ def start(self) -> "Server":+ self.thread = threading.Thread(target=self.httpd.serve_forever, daemon=True)+ self.thread.start()+ return self++ def serve_forever(self) -> None:+ self.httpd.serve_forever()++ def stop(self) -> None:+ self.httpd.shutdown()+ self.httpd.server_close()+++def serve(paths: list[str | Path], host: str = "127.0.0.1", port: int = 8701, *, name: str = "pyrtan", hub: str | None = None, env: dict[str, Any] | None = None, advertise: str | None = None, quiet: bool = False) -> tuple[Service, Server]:+ registry = Registry.from_paths(paths)+ service = Service(registry, name=name, hub=hub, env=env, advertise=advertise)+ server = Server(service.route, host, port, quiet)+ if hub:+ service.register(advertise or server.url)+ return service, serverdiff --git a/src/pyrtan/template.py b/src/pyrtan/template.py new file mode 100644 index 0000000..8288ac0 --- /dev/null +++ b/src/pyrtan/template.py@@ -0,0 +1,170 @@+"""``{{ path }}`` interpolation against the run's state.++Templates are deliberately tiny. A placeholder is a dotted path into the+state, optionally followed by one or more filters::++ {{ inputs.city }}+ {{ state.resp.json.results[0].name }}+ {{ inputs.city | url }}+ {{ state.count | int }}++There is no logic in templates. Branching belongs to ``decide`` nodes, where+it is visible in the trace; a template that could branch would hide it.+"""++from __future__ import annotations++import json+import re+import urllib.parse+from typing import Any++PLACEHOLDER = re.compile(r"\{\{\s*([^{}]*?)\s*\}\}")+PATH_TOKEN = re.compile(r"([A-Za-z_][A-Za-z0-9_\-]*)|\[(-?\d+)\]|\[\"([^\"]*)\"\]|\['([^']*)'\]")+++class TemplateError(ValueError):+ pass+++class Missing:+ """Sentinel for a path that does not resolve."""++ def __repr__(self) -> str:+ return "<missing>"+++MISSING = Missing()+++def resolve(path: str, scope: dict[str, Any]) -> Any:+ """Resolve a dotted path like ``state.resp.json.items[0].title``.++ Returns :data:`MISSING` when any step does not exist, rather than raising,+ so that ``exists`` conditions can be expressed.+ """+ pos = 0+ cur: Any = scope+ path = path.strip()+ if not path:+ return MISSING+ while pos < len(path):+ if path[pos] == ".":+ pos += 1+ continue+ m = PATH_TOKEN.match(path, pos)+ if not m:+ raise TemplateError(f"bad path {path!r} at offset {pos}")+ pos = m.end()+ name, index, key_dq, key_sq = m.groups()+ if name is not None:+ cur = _get_key(cur, name)+ elif index is not None:+ cur = _get_index(cur, int(index))+ else:+ cur = _get_key(cur, key_dq if key_dq is not None else key_sq)+ if cur is MISSING:+ return MISSING+ return cur+++def _get_key(cur: Any, key: str) -> Any:+ if isinstance(cur, dict):+ return cur.get(key, MISSING)+ if isinstance(cur, list) and key.isdigit():+ return _get_index(cur, int(key))+ return MISSING+++def _get_index(cur: Any, index: int) -> Any:+ if isinstance(cur, (list, tuple, str)):+ try:+ return cur[index]+ except IndexError:+ return MISSING+ return MISSING+++FILTERS = {+ "url": lambda v: urllib.parse.quote(str(v), safe=""),+ "json": lambda v: json.dumps(v, ensure_ascii=False),+ "int": lambda v: int(float(v)),+ "float": lambda v: float(v),+ "str": lambda v: str(v),+ "lower": lambda v: str(v).lower(),+ "upper": lambda v: str(v).upper(),+ "strip": lambda v: str(v).strip(),+ "first": lambda v: v[0] if isinstance(v, (list, tuple, str)) and v else MISSING,+ "last": lambda v: v[-1] if isinstance(v, (list, tuple, str)) and v else MISSING,+ "length": lambda v: len(v) if hasattr(v, "__len__") else MISSING,+ "title": lambda v: str(v).title(),+}+++def render_value(expr: str, scope: dict[str, Any]) -> Any:+ """Evaluate one placeholder expression (path plus filters) to a value."""+ parts = [p.strip() for p in expr.split("|")]+ value = resolve(parts[0], scope)+ for name in parts[1:]:+ if name not in FILTERS:+ raise TemplateError(f"unknown filter {name!r} in {{{{ {expr} }}}}")+ if value is MISSING:+ continue+ value = FILTERS[name](value)+ return value+++def render(template: Any, scope: dict[str, Any], *, strict: bool = True) -> Any:+ """Render a template.++ - A string that is exactly one placeholder returns the resolved value with+ its type intact, so ``"{{ state.ids }}"`` yields a list, not its repr.+ - A string with surrounding text interpolates placeholders as text.+ - Lists and dicts are rendered recursively.+ - Other values pass through.++ With ``strict`` (the default) a placeholder that resolves to nothing raises+ :class:`TemplateError`; a tree should decide on absence explicitly rather+ than paste an empty string into a URL.+ """+ if isinstance(template, str):+ whole = PLACEHOLDER.fullmatch(template.strip())+ if whole and template.strip() == template:+ value = render_value(whole.group(1), scope)+ if value is MISSING:+ if strict:+ raise TemplateError(f"{template} resolves to nothing")+ return None+ return value++ def sub(m: re.Match[str]) -> str:+ value = render_value(m.group(1), scope)+ if value is MISSING:+ if strict:+ raise TemplateError(f"{m.group(0)} resolves to nothing")+ return ""+ if isinstance(value, (dict, list)):+ return json.dumps(value, ensure_ascii=False)+ return str(value)++ return PLACEHOLDER.sub(sub, template)+ if isinstance(template, list):+ return [render(item, scope, strict=strict) for item in template]+ if isinstance(template, dict):+ return {k: render(v, scope, strict=strict) for k, v in template.items()}+ return template+++def placeholders(template: Any) -> list[str]:+ """Every placeholder path used anywhere in a template, filters stripped."""+ found: list[str] = []+ if isinstance(template, str):+ for m in PLACEHOLDER.finditer(template):+ found.append(m.group(1).split("|")[0].strip())+ elif isinstance(template, list):+ for item in template:+ found.extend(placeholders(item))+ elif isinstance(template, dict):+ for v in template.values():+ found.extend(placeholders(v))+ return founddiff --git a/src/pyrtan/trace.py b/src/pyrtan/trace.py new file mode 100644 index 0000000..14e980f --- /dev/null +++ b/src/pyrtan/trace.py@@ -0,0 +1,74 @@+"""What a run did, step by step.++A trace is the product that distinguishes a decision tree from a model: every+observation is recorded, every branch names the condition that chose it, and+the whole thing is plain data a person or another service can read.+"""++from __future__ import annotations++import json+import time+from dataclasses import dataclass, field, asdict+from typing import Any+++@dataclass+class Step:+ node: str+ kind: str+ summary: str+ observed: Any = None+ chosen: str | None = None+ next: str | None = None+ elapsed_ms: int = 0+ depth: int = 0++ def to_dict(self) -> dict[str, Any]:+ d = asdict(self)+ if d["observed"] is None:+ del d["observed"]+ if d["chosen"] is None:+ del d["chosen"]+ return d+++@dataclass+class Trace:+ tree: str+ steps: list[Step] = field(default_factory=list)+ started: float = field(default_factory=time.time)+ gaps: list[dict[str, Any]] = field(default_factory=list)++ def add(self, step: Step) -> Step:+ self.steps.append(step)+ return step++ def gap(self, node: str, on: str, observed: Any) -> None:+ """Record a decision whose observed value matched no branch. Gaps are+ how a tree grows: each one is a branch an author has not written yet."""+ self.gaps.append({"tree": self.tree, "node": node, "on": on, "observed": _short(observed)})++ def to_dict(self) -> dict[str, Any]:+ return {"tree": self.tree, "started": self.started, "steps": [s.to_dict() for s in self.steps], "gaps": self.gaps}++ def render(self) -> str:+ lines = [f"trace: {self.tree}"]+ for s in self.steps:+ pad = " " * (s.depth + 1)+ arrow = f" -> {s.next}" if s.next else ""+ chosen = f" [{s.chosen}]" if s.chosen else ""+ lines.append(f"{pad}{s.node} ({s.kind}){chosen}: {s.summary}{arrow}")+ for g in self.gaps:+ lines.append(f" gap: {g['node']} saw {g['on']} = {json.dumps(g['observed'], ensure_ascii=False)} and had no branch for it")+ return "\n".join(lines)+++def _short(value: Any, limit: int = 200) -> Any:+ if isinstance(value, str) and len(value) > limit:+ return value[:limit] + "…"+ if isinstance(value, (dict, list)):+ text = json.dumps(value, ensure_ascii=False)+ if len(text) > limit:+ return text[:limit] + "…"+
The diff is cut here; the rest is in the clone.