planvortex 0.0.1__tar.gz → 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. {planvortex-0.0.1 → planvortex-0.2.0}/.gitignore +3 -0
  2. planvortex-0.2.0/CHANGELOG.md +177 -0
  3. planvortex-0.2.0/PKG-INFO +324 -0
  4. planvortex-0.2.0/README.md +294 -0
  5. planvortex-0.2.0/openapi/planvortex.openapi.json +14374 -0
  6. planvortex-0.2.0/pyproject.toml +255 -0
  7. planvortex-0.2.0/scripts/build_docs.py +84 -0
  8. planvortex-0.2.0/scripts/check_packaging.py +246 -0
  9. planvortex-0.2.0/scripts/generate_models.py +376 -0
  10. planvortex-0.2.0/scripts/generate_sync.py +284 -0
  11. planvortex-0.2.0/scripts/route_coverage.py +291 -0
  12. planvortex-0.2.0/src/planvortex/__init__.py +173 -0
  13. planvortex-0.2.0/src/planvortex/_client.py +200 -0
  14. planvortex-0.2.0/src/planvortex/_client_sync.py +193 -0
  15. planvortex-0.2.0/src/planvortex/_core/auth.py +172 -0
  16. planvortex-0.2.0/src/planvortex/_core/auth_sync.py +154 -0
  17. planvortex-0.2.0/src/planvortex/_core/errors.py +315 -0
  18. planvortex-0.2.0/src/planvortex/_core/files.py +190 -0
  19. planvortex-0.2.0/src/planvortex/_core/http.py +258 -0
  20. planvortex-0.2.0/src/planvortex/_core/http_sync.py +235 -0
  21. planvortex-0.2.0/src/planvortex/_core/pagination.py +122 -0
  22. planvortex-0.2.0/src/planvortex/_core/query.py +114 -0
  23. planvortex-0.2.0/src/planvortex/_core/transport.py +163 -0
  24. planvortex-0.2.0/src/planvortex/_generated/__init__.py +1 -0
  25. planvortex-0.2.0/src/planvortex/_generated/models.py +2978 -0
  26. planvortex-0.2.0/src/planvortex/_shapes.py +520 -0
  27. planvortex-0.2.0/src/planvortex/_version.py +28 -0
  28. planvortex-0.2.0/src/planvortex/py.typed +0 -0
  29. planvortex-0.2.0/src/planvortex/resources/__init__.py +6 -0
  30. planvortex-0.2.0/src/planvortex/resources/accounts.py +322 -0
  31. planvortex-0.2.0/src/planvortex/resources/ai_plans.py +223 -0
  32. planvortex-0.2.0/src/planvortex/resources/apps.py +115 -0
  33. planvortex-0.2.0/src/planvortex/resources/base.py +223 -0
  34. planvortex-0.2.0/src/planvortex/resources/catalog.py +129 -0
  35. planvortex-0.2.0/src/planvortex/resources/clients.py +223 -0
  36. planvortex-0.2.0/src/planvortex/resources/comments.py +332 -0
  37. planvortex-0.2.0/src/planvortex/resources/contacts.py +201 -0
  38. planvortex-0.2.0/src/planvortex/resources/dashboard.py +204 -0
  39. planvortex-0.2.0/src/planvortex/resources/integrations.py +222 -0
  40. planvortex-0.2.0/src/planvortex/resources/messages.py +333 -0
  41. planvortex-0.2.0/src/planvortex/resources/organizations.py +262 -0
  42. planvortex-0.2.0/src/planvortex/resources/products.py +211 -0
  43. planvortex-0.2.0/src/planvortex/resources/publications.py +363 -0
  44. planvortex-0.2.0/src/planvortex/resources/uploads.py +172 -0
  45. planvortex-0.2.0/src/planvortex/resources_sync/__init__.py +7 -0
  46. planvortex-0.2.0/src/planvortex/resources_sync/accounts.py +313 -0
  47. planvortex-0.2.0/src/planvortex/resources_sync/ai_plans.py +202 -0
  48. planvortex-0.2.0/src/planvortex/resources_sync/apps.py +94 -0
  49. planvortex-0.2.0/src/planvortex/resources_sync/base.py +192 -0
  50. planvortex-0.2.0/src/planvortex/resources_sync/catalog.py +113 -0
  51. planvortex-0.2.0/src/planvortex/resources_sync/clients.py +212 -0
  52. planvortex-0.2.0/src/planvortex/resources_sync/comments.py +309 -0
  53. planvortex-0.2.0/src/planvortex/resources_sync/contacts.py +182 -0
  54. planvortex-0.2.0/src/planvortex/resources_sync/dashboard.py +189 -0
  55. planvortex-0.2.0/src/planvortex/resources_sync/integrations.py +202 -0
  56. planvortex-0.2.0/src/planvortex/resources_sync/messages.py +317 -0
  57. planvortex-0.2.0/src/planvortex/resources_sync/organizations.py +259 -0
  58. planvortex-0.2.0/src/planvortex/resources_sync/products.py +201 -0
  59. planvortex-0.2.0/src/planvortex/resources_sync/publications.py +346 -0
  60. planvortex-0.2.0/src/planvortex/resources_sync/uploads.py +163 -0
  61. planvortex-0.2.0/src/planvortex/types.py +1043 -0
  62. planvortex-0.2.0/src/planvortex/webhooks.py +476 -0
  63. planvortex-0.2.0/tests/__init__.py +1 -0
  64. planvortex-0.2.0/tests/conftest.py +189 -0
  65. planvortex-0.2.0/tests/contrato.py +73 -0
  66. planvortex-0.2.0/tests/live/__init__.py +1 -0
  67. planvortex-0.2.0/tests/live/conftest.py +325 -0
  68. planvortex-0.2.0/tests/live/test_auth.py +97 -0
  69. planvortex-0.2.0/tests/live/test_catalog.py +162 -0
  70. planvortex-0.2.0/tests/live/test_connect_flow.py +219 -0
  71. planvortex-0.2.0/tests/live/test_errors.py +74 -0
  72. planvortex-0.2.0/tests/live/test_publish.py +216 -0
  73. planvortex-0.2.0/tests/live/test_read.py +167 -0
  74. planvortex-0.2.0/tests/test_accounts.py +249 -0
  75. planvortex-0.2.0/tests/test_ai_plans.py +109 -0
  76. planvortex-0.2.0/tests/test_apps.py +73 -0
  77. planvortex-0.2.0/tests/test_catalog.py +121 -0
  78. planvortex-0.2.0/tests/test_client.py +236 -0
  79. planvortex-0.2.0/tests/test_clients.py +166 -0
  80. planvortex-0.2.0/tests/test_comments.py +211 -0
  81. planvortex-0.2.0/tests/test_contacts.py +128 -0
  82. planvortex-0.2.0/tests/test_core.py +401 -0
  83. planvortex-0.2.0/tests/test_dashboard.py +157 -0
  84. planvortex-0.2.0/tests/test_example_comments.py +258 -0
  85. planvortex-0.2.0/tests/test_example_connect.py +248 -0
  86. planvortex-0.2.0/tests/test_example_publish.py +188 -0
  87. planvortex-0.2.0/tests/test_example_schedule.py +216 -0
  88. planvortex-0.2.0/tests/test_example_webhooks.py +149 -0
  89. planvortex-0.2.0/tests/test_files.py +209 -0
  90. planvortex-0.2.0/tests/test_generate_models.py +121 -0
  91. planvortex-0.2.0/tests/test_generate_sync.py +105 -0
  92. planvortex-0.2.0/tests/test_integrations.py +147 -0
  93. planvortex-0.2.0/tests/test_messages.py +162 -0
  94. planvortex-0.2.0/tests/test_models.py +278 -0
  95. planvortex-0.2.0/tests/test_openapi_freshness.py +56 -0
  96. planvortex-0.2.0/tests/test_organizations.py +178 -0
  97. planvortex-0.2.0/tests/test_packaging.py +111 -0
  98. planvortex-0.2.0/tests/test_pagination.py +151 -0
  99. planvortex-0.2.0/tests/test_products.py +130 -0
  100. planvortex-0.2.0/tests/test_publications.py +218 -0
  101. planvortex-0.2.0/tests/test_query_parity.py +116 -0
  102. planvortex-0.2.0/tests/test_readme.py +73 -0
  103. planvortex-0.2.0/tests/test_route_coverage.py +96 -0
  104. planvortex-0.2.0/tests/test_shapes_parity.py +366 -0
  105. planvortex-0.2.0/tests/test_types.py +279 -0
  106. planvortex-0.2.0/tests/test_uploads.py +142 -0
  107. planvortex-0.2.0/tests/test_webhooks.py +450 -0
  108. planvortex-0.0.1/CHANGELOG.md +0 -15
  109. planvortex-0.0.1/PKG-INFO +0 -77
  110. planvortex-0.0.1/README.md +0 -50
  111. planvortex-0.0.1/pyproject.toml +0 -58
  112. planvortex-0.0.1/src/planvortex/__init__.py +0 -11
  113. {planvortex-0.0.1 → planvortex-0.2.0}/LICENSE +0 -0
  114. /planvortex-0.0.1/src/planvortex/py.typed → /planvortex-0.2.0/src/planvortex/_core/__init__.py +0 -0
@@ -5,6 +5,9 @@ dist/
5
5
  build/
6
6
  *.egg-info/
7
7
  site/
8
+ # La referencia se GENERA (`scripts/build_docs.py`) y la publica la CI en cada push a main. Si se
9
+ # commiteara, la pagina publicada seria la de la ultima vez que alguien se acordo de regenerarla.
10
+ docs/
8
11
 
9
12
  # Cachés de herramientas
10
13
  __pycache__/
@@ -0,0 +1,177 @@
1
+ # Changelog
2
+
3
+ All notable changes to this package are documented here. The format follows
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to
5
+ [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
+
7
+ ## [0.2.0] - 2026-08-30
8
+
9
+ Telegram, the eleventh network, reaches the package. No endpoint was added and no signature moved:
10
+ what changes is what the types know — and unlike in the Node library, where an open enumeration
11
+ absorbs an unknown network in silence, here the closed `Literal`s go red until somebody looks. Which
12
+ is the point of them.
13
+
14
+ Upgrading from `0.1.0` needs no changes, and `MIGRATION.md` gains no entry.
15
+
16
+ ### Added
17
+
18
+ - **A third authorization shape: `TelegramBotAuthorization`, with its predicate
19
+ `is_telegram_bot_authorization`.** This is the part that is genuinely new contract rather than one
20
+ more name in a list. Telegram is the one entry that carries a `link` and still is not somewhere to
21
+ redirect: it opens a private chat with the PlanVortex bot, with no OAuth behind it — no consent
22
+ screen, no `code`, no `redirect_uri` — and the account is born minutes later, when the person
23
+ drops that bot into their channel. It is announced over the WebSocket and the `new_account`
24
+ webhook, never as the answer to a call of yours, and `accounts.connect` cannot finish it: for
25
+ `telegram` that endpoint always answers 700. The block carries `bot_username` (the bot's `@name`,
26
+ without the at sign) and `add_to_group_link`, the second step, which turns comments on by adding
27
+ the bot to the channel's linked discussion group. **Branch on `authorization["type"]`, never on
28
+ whether `link` is empty** — that test was already wrong for WhatsApp and it is worse here, because
29
+ a filled-in `link` makes it look like it worked.
30
+ - **`"telegram"` in `PublishableNetwork`** — the one hand-written union in `_shapes.py` — and
31
+ therefore in `PUBLISHABLE_NETWORKS`, `COMMENT_NETWORKS` and `SOCIAL_NETWORKS`, which derive from
32
+ their `Literal` and needed no edit. It is deliberately **not** in `CONTACT_CHANNELS`: Telegram has
33
+ no direct messages, and putting it there would promise a chat inbox that does not exist. That the
34
+ contact channels are now a proper subset of the networks, rather than all of them plus `email`, is
35
+ written down in the test that used to assert the equality.
36
+ - **`PublicationStats.reactions` and `reactions_by_emoji`**, which are Telegram's only publication
37
+ metric — and neither of them is asked for. `reactions` is the COMPLETE STATE and not an increment,
38
+ so it goes down when somebody takes theirs back.
39
+ - **`Publication.extra_data`**, for what one network has to remember about one publication and that
40
+ has no common field. Today only `telegram_message_ids` writes there: on Telegram an album is one
41
+ publication that is several messages, `external_identifier` holds the first and the rest live
42
+ here, because deleting the album means deleting all of them.
43
+
44
+ ### Changed
45
+
46
+ - The network-counting prose, which had quietly stopped being true and that no test watches:
47
+ `accounts.connect_links`, `SocialAuthorizationMethod`, `ConnectLink`, `PublishableNetwork` and
48
+ `is_publishable_network` all said "nine of the ten" or "one of ten and one of nine".
49
+ - Two warnings about this network are now on the methods that would otherwise surprise you, because
50
+ neither has an error code to announce it. **The comment inbox starts the day the channel was
51
+ connected** — the Bot API cannot read the past, so `comments.list` has nothing earlier and never
52
+ will, and `comments.thread`, which is a live read on every other network, is not live here at all.
53
+ And **there are no impressions and no reach**: engagement is computed over followers, the only
54
+ audience figure the Bot API publishes being the channel's member count.
55
+ - `catalog.social_limits` explains Telegram's **two numbers for the same field** — 4.096 characters
56
+ while the publication is text only and 1.024 the moment it carries an image or a video, because
57
+ then the text is a media caption. The counter switches when the file is attached, not when publish
58
+ is pressed.
59
+ - `publications.remove` documents Telegram's **48-hour window** (error 966, with `published_date`
60
+ and `max_hours` in `data`), which is a button to grey out rather than to offer and fail.
61
+ - `examples/connect.py` grows the third branch, and its `else` — the one that skips an authorization
62
+ method this version does not know — is no longer hypothetical: `telegram_bot` was exactly that.
63
+
64
+ ### Fixed
65
+
66
+ - The synchronous twin of `products.get` had been left behind by the previous release: its docstring
67
+ still said the Node library documented that endpoint as broken. Regenerating `resources_sync`
68
+ brings it back in line.
69
+
70
+ ## [0.1.0] - 2026-08-28
71
+
72
+ The first release with code in it. Every documented endpoint of the PlanVortex API has a method -
73
+ **112 of the 112 operations** - in two clients that cannot drift apart, the types come from the same
74
+ OpenAPI document the API publishes, the three test layers are in place (the third against a real
75
+ PlanVortex) and the reference is published. It is a `0.x` on purpose: the shape of the client is
76
+ settled and pinned by tests, but nobody outside has used it yet, so a break before `1.0.0` is
77
+ possible and would arrive written down in `MIGRATION.md`.
78
+
79
+ The `0.0.1` already on PyPI carries no code. It was published on 2026-08-26 to reserve the name and
80
+ to prove the release pipeline before there was anything to lose.
81
+
82
+ ### Added
83
+
84
+ - The **types**, generated from the OpenAPI document PlanVortex publishes and exposed with readable
85
+ names in `planvortex.types`: `Publication`, `Account`, `Upload`, `Comment`, `Message`, `Contact`
86
+ and the rest of the API's shapes, as `TypedDict`. The API's field names survive untranslated, so
87
+ it is `publication["_id"]` and not `publication.id`.
88
+ - The value lists, at the package root and at runtime: `SOCIAL_NETWORKS`, `PUBLICATION_STATES`,
89
+ `PUBLICATION_TYPES`, `COMMENT_NETWORKS`, `CONTACT_CHANNELS`, `MESSAGE_TYPES`,
90
+ `INTEGRATION_PROVIDERS` and `AI_PLAN_STATES`. Each one is derived from the same `Literal` the type
91
+ checker reads, so the two cannot disagree. **The lists grow**: a value you do not recognise is one
92
+ this release had not heard of, not an error.
93
+ - Helpers for the fields the API returns in two shapes: `account_id()`, `account()`,
94
+ `publication_id()`, `publication()`, `message_direction()`, `message_contact_id()`,
95
+ `message_contact()`, `message_files()` and `message_file_ids()`. `id_account` arrives resolved in
96
+ some operations and as a string in others, and these cover it without an `isinstance` in every
97
+ call site.
98
+ - The asynchronous and synchronous cores: HTTP with timeouts, backoff and retry rules, the
99
+ `client_credentials` token with its cache and its lock, the error hierarchy, and the query encoder
100
+ with the camelCase map.
101
+ - **The two clients**, `PlanVortex` and `AsyncPlanVortex`, with **fourteen resources covering the
102
+ whole public API**: `catalog`, `clients`, `organizations`, `accounts`, `uploads`, `publications`,
103
+ `comments`, `messages`, `contacts`, `products`, `integrations`, `ai_plans`, `dashboard` and
104
+ `apps` — **112 of the 112 operations** the specification documents, everything except the 19
105
+ routes of roles and invitations, which are out of scope. The synchronous client is generated from
106
+ the asynchronous one, so they cannot drift apart, and `scripts/route_coverage.py` walks the
107
+ OpenAPI bundle on every test run so that "no route is missing" keeps being true.
108
+ - Three things that **used to be documented as broken and work now**, fixed in the server on
109
+ 2026-08-24 and exposed here for the first time: filtering contacts by `social_network`, asking for
110
+ one product by `product_id` (`products.get()`, which also tolerates the single-object shape the
111
+ network answers with), and `in_response_external_id` in `messages.send()`, which is what makes
112
+ `comment_message` and `publication_message` reachable from the public API.
113
+ - `contacts.merge()`, which reads the contact before updating it so that its `extra_data` is not
114
+ wiped: it is the one field the server overwrites with whatever the body carries.
115
+ - `organizations.users()`, the one read of the roles section the library covers, because a custom
116
+ panel needs to paint the team.
117
+ - `Page(data, total)` for every listing, with the API's envelope already opened, plus `iterate()` /
118
+ `aiterate()` to chain pages — with a page cap so that a server ignoring the `offset` fails instead
119
+ of hanging the process.
120
+ - Uploading from a path, `bytes`, an open binary file or an iterable of `bytes`. A path is the one
121
+ that neither goes through memory nor asks you to close anything. A body that cannot be rewound
122
+ forbids the retry outright, because a second attempt over a spent stream uploads zero bytes.
123
+ - `publish_date` accepts a `datetime` and is serialized with its offset. A naive one raises instead
124
+ of guessing: assuming UTC publishes at the wrong time for whoever is in Madrid, and assuming the
125
+ process's zone does it for whoever is in Docker.
126
+ - `as_temporal_token()`, for the account-connection flow: an app cannot connect an account, so it
127
+ issues a temporal token and hands it to the person who can.
128
+ - `is_publishable_network()`, a `TypeGuard` narrowing a network down to the **nine** that accept
129
+ publications, and `PUBLISHABLE_NETWORKS` beside it. An account's `social_network` is one of ten
130
+ and a publication's is one of nine — `google_business` receives reviews, not posts — so handing
131
+ one to the other was a type error with no way out that did not throw away a real guarantee.
132
+ - **Five runnable examples**, each with a test of its own: `publish.py` (credentials to a scheduled
133
+ publication), `schedule.py` (the calendar, moving a post, rescuing a failed one), `comments.py`
134
+ (inbox, actions matrix, live thread and replying — read-only unless `PLANVORTEX_ALLOW_REPLY=1`),
135
+ `webhooks.py` (a receiver with no dependencies, and a `--self-test` that signs a delivery to
136
+ itself) and `connect.py` (the connection flow, and what the browser has to do).
137
+ - **The published reference**, generated with `pdoc` from the docstrings and deployed to GitHub
138
+ Pages on every push to `main`: <https://taliasoftworks.github.io/PlanVortexPython/>.
139
+ - **`planvortex.webhooks`**, in a module of its own because whoever receives deliveries is rarely
140
+ the process that publishes: `verify_webhook_signature()` over the **raw** body with a constant-time
141
+ comparison, `parse_webhook_body()` (the body is an **array** of changes), and
142
+ `handle_webhook_request()`, which does both and finds the signature header whatever your framework
143
+ calls it. No framework middleware is shipped: the README carries the Flask, FastAPI and Django
144
+ recipes, each with the one line that gives you the raw body.
145
+ - The webhook event types, split by `field` so they can be narrowed — `AccountStateChange`,
146
+ `MessageChange`, `CommentChange` and `IntegrationErrorChange` — with the predicates
147
+ `is_comment_change()` and friends, and `UnknownWebhookChange` for the events a future server sends
148
+ and this release has never heard of. `WebhookSignatureError` and `WebhookBodyError` are
149
+ `PlanVortexError` of family `webhook`.
150
+
151
+ - **The live test suite** (`tests/live/`), which talks to a real PlanVortex and is the only layer
152
+ that can notice the *server* changing under the library: a renamed list envelope, an error code
153
+ that moved, a tenth social network. It ships in the sdist like the rest of the tests. It is not in
154
+ `uv run pytest` — it carries the `live` marker and is asked for with `uv run pytest -m live` — it
155
+ skips itself whole without `.env.live`, and everything that writes is behind `LIVE_ALLOW_PUBLISH`,
156
+ `LIVE_ALLOW_SOCIAL_PUBLISH` and `LIVE_ALLOW_PRODUCTION`. See `.env.live.example`.
157
+
158
+ ### Changed
159
+
160
+ - `PublicationInput["publish_date"]` now **accepts a `datetime` in the type**, not only at runtime.
161
+ The generated type said `str`, so the usage the library documents did not type-check. It is the
162
+ one shape written by hand on top of the generated one, and it has a parity test against the
163
+ specification that allows exactly that one difference.
164
+ - `examples/` is inside mypy's scope. It was the only code in the repository nobody checked, and
165
+ the two failures it turned up were holes in the library, not in the examples.
166
+ - `typing-extensions` is now a dependency on **Python 3.10 only** (`python_version < "3.11"`), where
167
+ `NotRequired` is not yet in `typing`. Nothing is installed from 3.11 upwards.
168
+ - The coverage floor is now enforced (`fail_under = 97`), measured over layers 1 and 2 — the ones
169
+ CI runs.
170
+
171
+ ## [0.0.1] - 2026-08-26
172
+
173
+ Placeholder release. It reserves the `planvortex` name on PyPI and proves the trusted-publishing
174
+ pipeline end to end before there is anything to lose. It contains no client.
175
+
176
+ [0.1.0]: https://github.com/taliasoftworks/PlanVortexPython/compare/v0.0.1...v0.1.0
177
+ [0.0.1]: https://github.com/taliasoftworks/PlanVortexPython/releases/tag/v0.0.1
@@ -0,0 +1,324 @@
1
+ Metadata-Version: 2.5
2
+ Name: planvortex
3
+ Version: 0.2.0
4
+ Summary: Official Python client for the PlanVortex API
5
+ Project-URL: Homepage, https://planvortex.com/developers
6
+ Project-URL: Documentation, https://taliasoftworks.github.io/PlanVortexPython/
7
+ Project-URL: API, https://planvortex.com/documentation
8
+ Project-URL: Repository, https://github.com/taliasoftworks/PlanVortexPython
9
+ Project-URL: Changelog, https://github.com/taliasoftworks/PlanVortexPython/blob/main/CHANGELOG.md
10
+ Project-URL: Issues, https://github.com/taliasoftworks/PlanVortexPython/issues
11
+ Author-email: Talia Softworks <contact@planvortex.com>
12
+ License-Expression: MIT
13
+ License-File: LICENSE
14
+ Keywords: bluesky,facebook,instagram,linkedin,planvortex,publishing,scheduling,social media,tiktok
15
+ Classifier: Development Status :: 4 - Beta
16
+ Classifier: Intended Audience :: Developers
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Programming Language :: Python :: 3.13
22
+ Classifier: Programming Language :: Python :: 3.14
23
+ Classifier: Topic :: Internet :: WWW/HTTP
24
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
25
+ Classifier: Typing :: Typed
26
+ Requires-Python: >=3.10
27
+ Requires-Dist: httpx2<3,>=2.12
28
+ Requires-Dist: typing-extensions>=4.5; python_version < '3.11'
29
+ Description-Content-Type: text/markdown
30
+
31
+ # planvortex
32
+
33
+ [![PyPI](https://img.shields.io/pypi/v/planvortex.svg?color=2036d8&label=pypi)](https://pypi.org/project/planvortex/)
34
+ [![python](https://img.shields.io/pypi/pyversions/planvortex.svg?color=2036d8)](https://pypi.org/project/planvortex/)
35
+ [![CI](https://github.com/taliasoftworks/PlanVortexPython/actions/workflows/ci.yml/badge.svg)](https://github.com/taliasoftworks/PlanVortexPython/actions/workflows/ci.yml)
36
+ [![license](https://img.shields.io/pypi/l/planvortex.svg?color=2036d8)](./LICENSE)
37
+
38
+ The official Python client for the [PlanVortex](https://planvortex.com) API — connect social
39
+ accounts, schedule and publish posts, read comments and messages, and pull stats, from Python.
40
+
41
+ ```bash
42
+ pip install planvortex
43
+ ```
44
+
45
+ - **Synchronous and asynchronous**, same surface: `PlanVortex` and `AsyncPlanVortex`.
46
+ - **Typed**, from the same OpenAPI specification the API publishes at
47
+ <https://planvortex.com/openapi.json>. Returned shapes are `TypedDict`, so `_id` stays `_id`.
48
+ - **One runtime dependency**, `httpx2` — a different package from `httpx` classic, so it will not
49
+ collide with whatever your project already uses.
50
+ - **Server-side.** The `client_credentials` flow needs your `client_secret`, which must never reach
51
+ a browser. Connecting an account from one is what the [temporal connect token](#connecting-an-account)
52
+ is for.
53
+ - Python 3.10 and newer.
54
+
55
+ **Reference:** <https://taliasoftworks.github.io/PlanVortexPython/> · **Guides:**
56
+ [planvortex.com/developers](https://planvortex.com/developers)
57
+
58
+ ## Authentication
59
+
60
+ Your credentials are a **client app**'s: you create one in the PlanVortex panel and it gives you a
61
+ `client_id` and a `client_secret`. They are read from the environment, so nothing is hardcoded:
62
+
63
+ ```bash
64
+ export PLANVORTEX_CLIENT_ID=...
65
+ export PLANVORTEX_CLIENT_SECRET=...
66
+ # Only if you are not talking to production:
67
+ export PLANVORTEX_BASE_URL=http://localhost:3000/v1.0.0
68
+ ```
69
+
70
+ ```python
71
+ from planvortex import PlanVortex
72
+
73
+ pv = PlanVortex() # from the environment
74
+ pv = PlanVortex(client_id="...", client_secret="...") # or explicitly
75
+ ```
76
+
77
+ The token is fetched on the first call, cached, and renewed before it expires — you never touch
78
+ `/oauth/token`. Use the client as a context manager (`with PlanVortex() as pv:`) so the connection
79
+ pool closes, and **keep one instance**: a new one per request throws away the cache and the pool.
80
+
81
+ An app sees **its own client** and that client's organizations, and nothing else.
82
+
83
+ ## Publishing
84
+
85
+ ```python
86
+ from datetime import datetime, timedelta, timezone
87
+
88
+ from planvortex import PlanVortex
89
+
90
+ pv = PlanVortex()
91
+
92
+ upload = pv.uploads.create(org_id, "./sourdough.jpg")
93
+ publication = pv.publications.create(
94
+ org_id,
95
+ account_id,
96
+ {
97
+ "social_network": "instagram",
98
+ "text": "New oven, new loaves",
99
+ "files": [upload["_id"]],
100
+ "publish_date": datetime.now(timezone.utc) + timedelta(hours=1),
101
+ },
102
+ )
103
+
104
+ # A publication that could not be built is NOT an exception: it comes back saved, in `withErrors`,
105
+ # with the reason inside. The content is validated against the network, and that is not a failure
106
+ # of your request.
107
+ if publication["state"] == "withErrors":
108
+ for failure in publication["publication_errors"]:
109
+ print(failure["code"], failure["message"])
110
+ ```
111
+
112
+ `publish_date` takes a `datetime` as well as an ISO-8601 string, and **it has to carry a timezone**:
113
+ a naive one raises rather than being guessed at, because assuming UTC publishes at the wrong time
114
+ for whoever is in Madrid and assuming the process's zone does it for whoever is in Docker. With no
115
+ `publish_date` at all it goes out in that same request, and the answer already says whether it did.
116
+
117
+ A file can be a path, an open file, `bytes`, or a `(name, bytes)` pair. Per-network limits —
118
+ characters, images, video length, file size — come from `pv.catalog.social_limits()`, which is the
119
+ server's own copy: whoever enforces a limit is who gets to announce it.
120
+
121
+ Listing gives you a page, and there is a chaining iterator for when you want them all:
122
+
123
+ ```python
124
+ page = pv.accounts.list(org_id, limit=50)
125
+ page.data, page.total
126
+
127
+ for publication in pv.publications.iterate(org_id, state=["ready"]):
128
+ ...
129
+ ```
130
+
131
+ The same code in async changes three things and no more — the class, an `await`, and `aiterate`:
132
+
133
+ ```python
134
+ from planvortex import AsyncPlanVortex
135
+
136
+ async with AsyncPlanVortex() as pv:
137
+ page = await pv.accounts.list(org_id, limit=50)
138
+ async for publication in pv.publications.aiterate(org_id, state=["ready"]):
139
+ ...
140
+ ```
141
+
142
+ ## Connecting an account
143
+
144
+ Connecting is the one flow the library **cannot finish on its own**: it ends with a person pressing
145
+ "allow" on Instagram's page. What the library does is hand you a URL to send them to.
146
+
147
+ ```python
148
+ connection = pv.organizations.create_connect_token(org_id)
149
+ # connection["url"] is where the person goes. Never send them your client_secret.
150
+
151
+ person = pv.as_temporal_token(connection["token"]) # a client that can only do this
152
+ for link in person.accounts.connect_links(org_id):
153
+ ...
154
+ ```
155
+
156
+ Four things about that token, and each one bites separately: it lasts **fifteen minutes**, it is
157
+ **single-use**, it is tied to **one** organization, and it **cannot issue another one**. Saving it
158
+ for "next time" fails four different ways — issue a fresh one per connection, they are free.
159
+
160
+ And one that trips people without giving an error: branch on `link["authorization"]["type"]`, never
161
+ on the `link`. WhatsApp's is the empty string, because its sign-up is Meta's *Embedded Signup* popup
162
+ and not an OAuth redirect; walking the list redirecting to `link` sends your user to your own page.
163
+
164
+ Accounts come back **disabled** and take no plan slot until `pv.accounts.enable(...)`, and one
165
+ authorization can leave several — a Facebook user with four pages is four of them.
166
+
167
+ ## Comments and messages
168
+
169
+ ```python
170
+ # The comments inbox comes out of PlanVortex's database: free, fast, and a photograph of the last
171
+ # time the network was read. The thread asks the network right then, and on X that costs credits.
172
+ for comment in pv.comments.iterate(org_id, unread=True, rating=[1, 2]):
173
+ print(comment["rating"], comment["text"])
174
+
175
+ thread = pv.comments.thread(org_id, publication_id)
176
+ thread["credits_consumed"] # real money on X, 0 everywhere else
177
+
178
+ # Before painting a button, ask what the network allows: they are not all the same.
179
+ if (pv.comments.actions_for("linkedin") or {}).get("hide"):
180
+ ...
181
+ ```
182
+
183
+ ## Errors
184
+
185
+ Errors are classified by `code`, **never by the HTTP status** — every domain error in this API
186
+ travels with a 400. Each range has its own exception class, so you can catch a family without
187
+ memorising numbers:
188
+
189
+ | Codes | Family | Exception |
190
+ |---|---|---|
191
+ | 500-544 | `auth` | `AuthError` |
192
+ | 601-612 | `user` | `UserError` |
193
+ | 700-715 | `account` | `AccountError` |
194
+ | 800-810 | `file` | `FileError` |
195
+ | 900-960 | `publication` | `PublicationError` |
196
+ | 1000-1003 | `general` | `PlanVortexError` |
197
+ | 1100-1111 | `organization` | `OrganizationError` |
198
+ | 1200-1207 | `role` | `PlanVortexError` |
199
+ | 1300-1307, 1400-1408 | `plan_limit` | `PlanLimitError` |
200
+ | 1500-1512 | `messaging` | `MessagingError` |
201
+ | 1600-1601 | `contact` | `ContactError` |
202
+ | 1900-1906 | `payment` | `PlanVortexError` |
203
+ | 2000-2099 | `product` | `ProductError` |
204
+ | 2100-2199 | `ai_plan` | `AiPlanError` |
205
+ | 2200-2299 | `integration` | `IntegrationError` |
206
+
207
+ ```python
208
+ from planvortex import PlanLimitError, PlanVortexError
209
+
210
+ try:
211
+ ...
212
+ except PlanLimitError as error:
213
+ ... # not fixed by retrying: fixed by changing plan
214
+ except PlanVortexError as error:
215
+ error.code, error.family, error.message, error.data, error.status
216
+ ```
217
+
218
+ The runtime list is `PLANVORTEX_ERROR_RANGES`. Two more that are not the API's answer:
219
+ `PlanVortexConnectionError` (it never got there — retried already, on the methods where retrying is
220
+ safe) and `PlanVortexConfigError` (something is wrong on this side, like a missing `client_secret`).
221
+
222
+ ## Webhooks
223
+
224
+ PlanVortex `POST`s to your app when something happens: an account changed state, a message or a
225
+ comment came in, an integration stopped working. Two things trip up everybody, so they go first.
226
+
227
+ **The body is an array of changes**, not an object. And **the signature is computed over the raw
228
+ body** — if your framework already parsed the JSON and you serialise it again, the bytes are not the
229
+ same ones and the signature never matches. The line that gives you the raw body is the only line of
230
+ the recipe that changes:
231
+
232
+ ```python
233
+ # Flask
234
+ import os
235
+
236
+ from flask import request
237
+
238
+ from planvortex.webhooks import handle_webhook_request, is_comment_change
239
+
240
+
241
+ @app.post("/webhooks/planvortex")
242
+ def planvortex_webhook():
243
+ changes = handle_webhook_request(
244
+ body=request.get_data(), # raw! never request.json
245
+ headers=request.headers,
246
+ secret=os.environ["PLANVORTEX_CLIENT_SECRET"],
247
+ )
248
+ for change in changes:
249
+ if is_comment_change(change):
250
+ moderate(change.get("commentObj"))
251
+ return "", 200
252
+ ```
253
+
254
+ ```python
255
+ # FastAPI
256
+ @app.post("/webhooks/planvortex")
257
+ async def planvortex_webhook(request: Request):
258
+ changes = handle_webhook_request(
259
+ body=await request.body(), # raw! never the parsed model
260
+ headers=request.headers,
261
+ secret=os.environ["PLANVORTEX_CLIENT_SECRET"],
262
+ )
263
+ ...
264
+ ```
265
+
266
+ ```python
267
+ # Django
268
+ @csrf_exempt
269
+ def planvortex_webhook(request):
270
+ changes = handle_webhook_request(
271
+ body=request.body, # raw! never request.POST
272
+ headers=request.headers,
273
+ secret=os.environ["PLANVORTEX_CLIENT_SECRET"],
274
+ )
275
+ ...
276
+ ```
277
+
278
+ `handle_webhook_request` raises `WebhookSignatureError` if the signature is missing or does not
279
+ match (answer 401) and `WebhookBodyError` if the body is not what it has to be (answer 400). If you
280
+ would rather do it in two steps, `verify_webhook_signature(payload, signature, secret)` returns a
281
+ plain `True`/`False` and `parse_webhook_body(payload)` gives you the changes.
282
+
283
+ Narrow with the predicates — `is_account_state_change`, `is_message_change`, `is_comment_change`,
284
+ `is_integration_error_change` — and let anything else fall through: **the event list grows**, and a
285
+ `field` this release has never heard of is not an error.
286
+
287
+ **PlanVortex does not retry a failed delivery.** A 500 of yours loses the event, so if your work is
288
+ slow, queue it and answer — and use `pv.comments.list` / `pv.messages.list` to catch up on anything
289
+ you missed.
290
+
291
+ ## The whole API, in fourteen resources
292
+
293
+ `pv.catalog` · `pv.clients` · `pv.organizations` · `pv.accounts` · `pv.uploads` · `pv.publications`
294
+ · `pv.comments` · `pv.messages` · `pv.contacts` · `pv.products` · `pv.integrations` · `pv.ai_plans`
295
+ · `pv.dashboard` · `pv.apps`
296
+
297
+ That is **112 of the 112 operations** the specification documents — everything except the 19 routes
298
+ of roles and invitations, which are out of scope. A script walks the OpenAPI bundle on every test
299
+ run and fails if a route is left without a method, so the sentence above stays true.
300
+
301
+ ## Examples
302
+
303
+ Five runnable scripts, each one the whole of its path and with a test of its own:
304
+
305
+ | | |
306
+ |---|---|
307
+ | [`examples/publish.py`](https://github.com/taliasoftworks/PlanVortexPython/blob/main/examples/publish.py) | Credentials, quota, account, network limits, upload, scheduled publication. |
308
+ | [`examples/schedule.py`](https://github.com/taliasoftworks/PlanVortexPython/blob/main/examples/schedule.py) | The calendar: what is queued, moving it, and rescuing what failed. |
309
+ | [`examples/comments.py`](https://github.com/taliasoftworks/PlanVortexPython/blob/main/examples/comments.py) | The inbox, the actions matrix, the live thread, and replying. |
310
+ | [`examples/webhooks.py`](https://github.com/taliasoftworks/PlanVortexPython/blob/main/examples/webhooks.py) | A receiver with no dependencies. `--self-test` signs a delivery to itself. |
311
+ | [`examples/connect.py`](https://github.com/taliasoftworks/PlanVortexPython/blob/main/examples/connect.py) | The connection flow, and what the browser has to do. |
312
+
313
+ `comments.py` only reads unless you set `PLANVORTEX_ALLOW_REPLY=1`: replying is public, immediate,
314
+ and reaches a person.
315
+
316
+ ## Links
317
+
318
+ - [Reference](https://taliasoftworks.github.io/PlanVortexPython/)
319
+ - [API documentation](https://planvortex.com/documentation)
320
+ - [Developers](https://planvortex.com/developers)
321
+ - [Node client](https://github.com/taliasoftworks/PlanVortexNode)
322
+ - [Contributing](https://github.com/taliasoftworks/PlanVortexPython/blob/main/CONTRIBUTING.md) · [Security](https://github.com/taliasoftworks/PlanVortexPython/blob/main/SECURITY.md) · [Changelog](https://github.com/taliasoftworks/PlanVortexPython/blob/main/CHANGELOG.md)
323
+
324
+ MIT © Talia Softworks