apify-client 3.3.0__tar.gz → 3.3.1b2__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 (71) hide show
  1. {apify_client-3.3.0 → apify_client-3.3.1b2}/CHANGELOG.md +14 -0
  2. apify_client-3.3.1b2/CONTRIBUTING.md +166 -0
  3. {apify_client-3.3.0 → apify_client-3.3.1b2}/PKG-INFO +4 -8
  4. {apify_client-3.3.0 → apify_client-3.3.1b2}/README.md +1 -6
  5. {apify_client-3.3.0 → apify_client-3.3.1b2}/pyproject.toml +60 -16
  6. {apify_client-3.3.0 → apify_client-3.3.1b2}/pyproject.toml.orig +25 -15
  7. {apify_client-3.3.0 → apify_client-3.3.1b2}/src/apify_client/_resource_clients/dataset.py +3 -3
  8. {apify_client-3.3.0 → apify_client-3.3.1b2}/src/apify_client/_resource_clients/key_value_store.py +3 -3
  9. {apify_client-3.3.0 → apify_client-3.3.1b2}/src/apify_client/_resource_clients/log.py +3 -3
  10. {apify_client-3.3.0 → apify_client-3.3.1b2}/src/apify_client/_streamed_log.py +105 -43
  11. {apify_client-3.3.0 → apify_client-3.3.1b2}/src/apify_client/_utils/try_import.py +2 -2
  12. apify_client-3.3.0/CONTRIBUTING.md +0 -189
  13. {apify_client-3.3.0 → apify_client-3.3.1b2}/LICENSE +0 -0
  14. {apify_client-3.3.0 → apify_client-3.3.1b2}/src/apify_client/__init__.py +0 -0
  15. {apify_client-3.3.0 → apify_client-3.3.1b2}/src/apify_client/_apify_client.py +0 -0
  16. {apify_client-3.3.0 → apify_client-3.3.1b2}/src/apify_client/_client_registry.py +0 -0
  17. {apify_client-3.3.0 → apify_client-3.3.1b2}/src/apify_client/_consts.py +0 -0
  18. {apify_client-3.3.0 → apify_client-3.3.1b2}/src/apify_client/_docs.py +0 -0
  19. {apify_client-3.3.0 → apify_client-3.3.1b2}/src/apify_client/_literals.py +0 -0
  20. {apify_client-3.3.0 → apify_client-3.3.1b2}/src/apify_client/_logging.py +0 -0
  21. {apify_client-3.3.0 → apify_client-3.3.1b2}/src/apify_client/_models.py +0 -0
  22. {apify_client-3.3.0 → apify_client-3.3.1b2}/src/apify_client/_pagination.py +0 -0
  23. {apify_client-3.3.0 → apify_client-3.3.1b2}/src/apify_client/_resource_clients/__init__.py +0 -0
  24. {apify_client-3.3.0 → apify_client-3.3.1b2}/src/apify_client/_resource_clients/_resource_client.py +0 -0
  25. {apify_client-3.3.0 → apify_client-3.3.1b2}/src/apify_client/_resource_clients/actor.py +0 -0
  26. {apify_client-3.3.0 → apify_client-3.3.1b2}/src/apify_client/_resource_clients/actor_collection.py +0 -0
  27. {apify_client-3.3.0 → apify_client-3.3.1b2}/src/apify_client/_resource_clients/actor_env_var.py +0 -0
  28. {apify_client-3.3.0 → apify_client-3.3.1b2}/src/apify_client/_resource_clients/actor_env_var_collection.py +0 -0
  29. {apify_client-3.3.0 → apify_client-3.3.1b2}/src/apify_client/_resource_clients/actor_version.py +0 -0
  30. {apify_client-3.3.0 → apify_client-3.3.1b2}/src/apify_client/_resource_clients/actor_version_collection.py +0 -0
  31. {apify_client-3.3.0 → apify_client-3.3.1b2}/src/apify_client/_resource_clients/build.py +0 -0
  32. {apify_client-3.3.0 → apify_client-3.3.1b2}/src/apify_client/_resource_clients/build_collection.py +0 -0
  33. {apify_client-3.3.0 → apify_client-3.3.1b2}/src/apify_client/_resource_clients/dataset_collection.py +0 -0
  34. {apify_client-3.3.0 → apify_client-3.3.1b2}/src/apify_client/_resource_clients/key_value_store_collection.py +0 -0
  35. {apify_client-3.3.0 → apify_client-3.3.1b2}/src/apify_client/_resource_clients/request_queue.py +0 -0
  36. {apify_client-3.3.0 → apify_client-3.3.1b2}/src/apify_client/_resource_clients/request_queue_collection.py +0 -0
  37. {apify_client-3.3.0 → apify_client-3.3.1b2}/src/apify_client/_resource_clients/run.py +0 -0
  38. {apify_client-3.3.0 → apify_client-3.3.1b2}/src/apify_client/_resource_clients/run_collection.py +0 -0
  39. {apify_client-3.3.0 → apify_client-3.3.1b2}/src/apify_client/_resource_clients/schedule.py +0 -0
  40. {apify_client-3.3.0 → apify_client-3.3.1b2}/src/apify_client/_resource_clients/schedule_collection.py +0 -0
  41. {apify_client-3.3.0 → apify_client-3.3.1b2}/src/apify_client/_resource_clients/store_collection.py +0 -0
  42. {apify_client-3.3.0 → apify_client-3.3.1b2}/src/apify_client/_resource_clients/task.py +0 -0
  43. {apify_client-3.3.0 → apify_client-3.3.1b2}/src/apify_client/_resource_clients/task_collection.py +0 -0
  44. {apify_client-3.3.0 → apify_client-3.3.1b2}/src/apify_client/_resource_clients/user.py +0 -0
  45. {apify_client-3.3.0 → apify_client-3.3.1b2}/src/apify_client/_resource_clients/webhook.py +0 -0
  46. {apify_client-3.3.0 → apify_client-3.3.1b2}/src/apify_client/_resource_clients/webhook_collection.py +0 -0
  47. {apify_client-3.3.0 → apify_client-3.3.1b2}/src/apify_client/_resource_clients/webhook_dispatch.py +0 -0
  48. {apify_client-3.3.0 → apify_client-3.3.1b2}/src/apify_client/_resource_clients/webhook_dispatch_collection.py +0 -0
  49. {apify_client-3.3.0 → apify_client-3.3.1b2}/src/apify_client/_statistics.py +0 -0
  50. {apify_client-3.3.0 → apify_client-3.3.1b2}/src/apify_client/_status_message_watcher.py +0 -0
  51. {apify_client-3.3.0 → apify_client-3.3.1b2}/src/apify_client/_typeddicts.py +0 -0
  52. {apify_client-3.3.0 → apify_client-3.3.1b2}/src/apify_client/_utils/__init__.py +0 -0
  53. {apify_client-3.3.0 → apify_client-3.3.1b2}/src/apify_client/_utils/crypto.py +0 -0
  54. {apify_client-3.3.0 → apify_client-3.3.1b2}/src/apify_client/_utils/encoding.py +0 -0
  55. {apify_client-3.3.0 → apify_client-3.3.1b2}/src/apify_client/_utils/errors.py +0 -0
  56. {apify_client-3.3.0 → apify_client-3.3.1b2}/src/apify_client/_utils/http.py +0 -0
  57. {apify_client-3.3.0 → apify_client-3.3.1b2}/src/apify_client/_utils/time.py +0 -0
  58. {apify_client-3.3.0 → apify_client-3.3.1b2}/src/apify_client/_utils/wait_for_resources.py +0 -0
  59. {apify_client-3.3.0 → apify_client-3.3.1b2}/src/apify_client/errors.py +0 -0
  60. {apify_client-3.3.0 → apify_client-3.3.1b2}/src/apify_client/http_clients/__init__.py +0 -0
  61. {apify_client-3.3.0 → apify_client-3.3.1b2}/src/apify_client/http_clients/_base.py +0 -0
  62. {apify_client-3.3.0 → apify_client-3.3.1b2}/src/apify_client/http_clients/_httpx2.py +0 -0
  63. {apify_client-3.3.0 → apify_client-3.3.1b2}/src/apify_client/http_clients/_impit.py +0 -0
  64. {apify_client-3.3.0 → apify_client-3.3.1b2}/src/apify_client/http_clients/_streamed_body.py +0 -0
  65. {apify_client-3.3.0 → apify_client-3.3.1b2}/src/apify_client/http_compressors/__init__.py +0 -0
  66. {apify_client-3.3.0 → apify_client-3.3.1b2}/src/apify_client/http_compressors/_base.py +0 -0
  67. {apify_client-3.3.0 → apify_client-3.3.1b2}/src/apify_client/http_compressors/_brotli.py +0 -0
  68. {apify_client-3.3.0 → apify_client-3.3.1b2}/src/apify_client/http_compressors/_gzip.py +0 -0
  69. {apify_client-3.3.0 → apify_client-3.3.1b2}/src/apify_client/http_compressors/_resolve.py +0 -0
  70. {apify_client-3.3.0 → apify_client-3.3.1b2}/src/apify_client/py.typed +0 -0
  71. {apify_client-3.3.0 → apify_client-3.3.1b2}/src/apify_client/types.py +0 -0
@@ -2,6 +2,20 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ <!-- git-cliff-unreleased-start -->
6
+ ## 3.3.1 - **not yet released**
7
+
8
+ ### 🚀 Features
9
+
10
+ - Add support for Python 3.15 ([#1122](https://github.com/apify/apify-client-python/pull/1122)) ([ef8f088](https://github.com/apify/apify-client-python/commit/ef8f088c3d4fb5f6facfc2728343298464c5ecb6)) by [@vdusek](https://github.com/vdusek)
11
+
12
+ ### 🐛 Bug Fixes
13
+
14
+ - Redirect the run log when the first log stream comes back empty ([#1084](https://github.com/apify/apify-client-python/pull/1084)) ([a370e7a](https://github.com/apify/apify-client-python/commit/a370e7a9db75053c2f65ad626ba1bcb67c4cf398)) by [@vdusek](https://github.com/vdusek)
15
+ - **deps:** Allow impit v0.15 ([#1128](https://github.com/apify/apify-client-python/pull/1128)) ([eb9e188](https://github.com/apify/apify-client-python/commit/eb9e188dfb4cdbae620307c7696b0886ed8a6db3)) by [@vdusek](https://github.com/vdusek)
16
+
17
+
18
+ <!-- git-cliff-unreleased-end -->
5
19
  ## [3.3.0](https://github.com/apify/apify-client-python/releases/tag/v3.3.0) (2026-10-08)
6
20
 
7
21
  ### 🚀 Features
@@ -0,0 +1,166 @@
1
+ # Development
2
+
3
+ Here you'll find a contributing guide to get started with development.
4
+
5
+ ## Environment
6
+
7
+ For local development, you need a Python version that satisfies `requires-python` in `pyproject.toml`.
8
+
9
+ We use [uv](https://docs.astral.sh/uv/) for project management. Install it and set up your IDE accordingly.
10
+
11
+ We use [Poe the Poet](https://poethepoet.natn.io/) as a task runner, similar to npm scripts in `package.json`. All tasks are defined in `pyproject.toml` under `[tool.poe.tasks]`. Run `uv run poe` to list them with their descriptions, and `uv run poe <task>` to run one.
12
+
13
+ ## Dependencies
14
+
15
+ To install this package with its development dependencies and the pre-commit hooks, run:
16
+
17
+ ```sh
18
+ uv run poe install-dev
19
+ ```
20
+
21
+ ## Code checking
22
+
23
+ To run all code checks (lint, type check, async docstring check, and unit tests), run:
24
+
25
+ ```sh
26
+ uv run poe check-code
27
+ ```
28
+
29
+ We use [ruff](https://docs.astral.sh/ruff/) for linting and formatting, [ty](https://docs.astral.sh/ty/) for type checking, and [pytest](https://docs.pytest.org/) for tests. All of them are configured in `pyproject.toml`, and each has its own task (`lint`, `format`, `type-check`, `unit-tests`).
30
+
31
+ ## Tests
32
+
33
+ - **Unit tests** (`tests/unit`) are isolated from the Apify API. See [`tests/unit/README.md`](tests/unit/README.md) for the rules they follow. Run them with `uv run poe unit-tests`, or a single test with `uv run pytest tests/unit/<path>.py::<test_name>`.
34
+ - **Integration tests** (`tests/integration`) call the real Apify API. See [`tests/integration/README.md`](tests/integration/README.md) for the tokens they need. Run them with `uv run poe integration-tests`.
35
+
36
+ ## Code style
37
+
38
+ ### Readability
39
+
40
+ - Prefer a few simple statements over one dense line. Give intermediate values a name instead of nesting calls, comprehensions, and unpacking into a single long expression.
41
+ - When replacing a call with an equivalent one, change only the call and keep the surrounding statements as they were.
42
+
43
+ ### Comments and docstrings
44
+
45
+ - Every module starts with `from __future__ import annotations`.
46
+ - Comments and docstrings describe what the code does now. The bug a change fixed, what a PR changed, and contrasts with removed behavior (`instead of ...`, `no longer ...`) belong in the commit message.
47
+ - Don't explain language features in comments. A comment earns its place with a domain reason, an invariant the types can't express, or a non-obvious consequence for callers.
48
+ - Use single backticks for code references, never RST-style double backticks.
49
+ - Wrap docstrings and prose comments to the full line length configured in `pyproject.toml`.
50
+ - Write only ASCII in source files: `->` instead of an arrow, `-` instead of a dash, straight quotes.
51
+ - Write `RQ` for the request queue. Log messages and public `.mdx` docs spell out the full term.
52
+
53
+ ### Tests
54
+
55
+ - Write flat `def test_*()` functions, never `class Test*`, and no section-header comments between tests.
56
+ - Almost every test gets a one-line docstring stating what it verifies, without a `Regression test:`-style prefix.
57
+ - In `@pytest.mark.parametrize`, wrap each case in `pytest.param(..., id='...')` with an id that describes the case.
58
+ - Use `Mock` and the `monkeypatch` fixture for test doubles, not hand-rolled stubs, counters, or flags.
59
+ - A test that changes process-wide or event-loop-wide state restores it. Prefer `monkeypatch`; for APIs it can't patch (`loop.set_exception_handler()`, `signal.signal()`, logging config, `os.chdir()`), capture the original value and restore it in a `finally`.
60
+ - On Windows with Python 3.12 or older, `datetime.now()` ticks in 1-15 ms steps, so two timestamps taken a few milliseconds apart can be equal. A test that asserts one timestamp is later than another injects a `Mock` clock instead of sleeping.
61
+ - For a TCP endpoint that must refuse connections, bind a socket to `('127.0.0.1', 0)` and hold it open without calling `listen()`. A hard-coded port can be taken by a server in a parallel xdist worker. Test servers use `127.0.0.1`, since on Windows `localhost` resolves to both IPv4 and IPv6.
62
+
63
+ ### YAML
64
+
65
+ Use double quotes for quoted YAML values (`retries: "3"`). Keep single quotes when the value itself contains double quotes, and for string literals inside GitHub Actions expressions (`${{ ... }}`, `if:` conditions), where double quotes are invalid.
66
+
67
+ ## Generated models
68
+
69
+ `src/apify_client/_models.py`, `_typeddicts.py`, and `_literals.py` are generated from the Apify OpenAPI specification by [datamodel-code-generator](https://github.com/koxudaxi/datamodel-code-generator) and post-processed by `scripts/postprocess_generated_models.py`. Never edit them by hand; make lasting fixes in the post-processing script. The generator is configured under `[tool.datamodel-codegen]` in `pyproject.toml`, with field aliases in `datamodel_codegen_aliases.json`.
70
+
71
+ - `uv run poe generate-models` regenerates them from the published specification. `scripts/openapi_spec.py` describes how the specification version is recorded. Don't commit a regeneration that only moves that version.
72
+ - `uv run poe generate-models-from-file <path>` generates them from a local specification, for trying out unpublished changes. Don't commit the result.
73
+ - The nightly `Regenerate models` workflow opens a `chore:` PR when the models change. If the diff is user-facing, retitle it to `fix:` or `feat:` so it lands in the changelog and triggers a release.
74
+
75
+ ## Documentation
76
+
77
+ We adhere to the [Google docstring format](https://sphinxcontrib-napoleon.readthedocs.io/en/latest/example_google.html) for documenting our codebase. Every user-facing class or method is documented. Documentation standards are enforced using [Ruff](https://docs.astral.sh/ruff/).
78
+
79
+ Resource client docstrings follow one pattern:
80
+
81
+ - The first line is `Sub-client for managing a specific <resource>.` or `Sub-client for the <resource> collection.`
82
+ - The second paragraph names two or three things the client does (not every method) and says to obtain an instance via an appropriate method on the parent client class, without naming that method.
83
+
84
+ Docstrings are written on the sync clients only. After editing one, run `uv run poe fix-docstrings` to copy it to the async client; `uv run poe check-docstrings` fails when the two differ.
85
+
86
+ Our API documentation is generated from these docstrings using [pydoc-markdown](https://pypi.org/project/pydoc-markdown/) with additional post-processing. Markdown files in the `docs/` folder complement the autogenerated content. Final documentation is rendered using [Docusaurus](https://docusaurus.io/) and published to GitHub Pages.
87
+
88
+ A user-facing feature ships with its documentation in the same PR: a page in `docs/02_concepts/` that explains the mechanism and, where it helps, a page in `docs/03_guides/` that shows a realistic use.
89
+
90
+ Code examples in `docs/` come in `_sync.py` / `_async.py` pairs and have their own lint settings in `docs/pyproject.toml`. Never edit `website/versioned_docs/`: those snapshots are produced by the release automation and frozen against the code that shipped. Fix `docs/`, and the next release snapshot carries the change.
91
+
92
+ To run the documentation locally (requires Node.js):
93
+
94
+ ```sh
95
+ uv run poe run-docs
96
+ ```
97
+
98
+ ### Linting the docs and website
99
+
100
+ Markdown content (this guide, `README.md`, and the `docs/` folder) is checked with
101
+ [markdownlint](https://github.com/DavidAnson/markdownlint). The Docusaurus website code is linted
102
+ with [oxlint](https://oxc.rs/) in type-aware mode (types come from `website/tsconfig.json`) and
103
+ formatted with [oxfmt](https://oxc.rs/). All of them run in CI.
104
+ To run them locally (requires Node.js 22.12 or newer and pnpm), from the `website/` directory:
105
+
106
+ ```sh
107
+ pnpm lint # lint Markdown and website code
108
+ pnpm lint:fix # auto-fix both
109
+ pnpm format # format the website code
110
+ ```
111
+
112
+ Doc images are committed as optimized `.webp`. To convert a new image, run
113
+ `pnpm opt:images <path-to-image>` from the `website/` directory.
114
+
115
+ ## Commits
116
+
117
+ We use [Conventional Commits](https://www.conventionalcommits.org/) format for commit messages. The release process reads them to determine the version bump and build the changelog. Pick the type by what the commit changes, not by why:
118
+
119
+ | Type | Description |
120
+ | ---- | ----------- |
121
+ | `feat` | A new feature in the source code |
122
+ | `fix` | A bug fix in the source code |
123
+ | `perf` | A source code change that improves performance |
124
+ | `refactor` | A source code change that neither fixes a bug nor adds a feature |
125
+ | `style` | A source code change that doesn't affect its meaning (white-space, formatting) |
126
+ | `test` | Adding or changing tests |
127
+ | `docs` | Documentation changes |
128
+ | `ci` | CI workflow changes |
129
+ | `build` | Build system changes |
130
+ | `chore` | Dependency bumps, tooling, generated files, and other housekeeping |
131
+ | `revert` | Reverting a previous commit |
132
+
133
+ `feat`, `fix`, `perf`, `refactor`, and `style` trigger a release and appear in the changelog, so use them for source code changes only. `docs` triggers a docs release on master.
134
+
135
+ Logging changes are breaking: changing a log level, a logger name, or the wording of a log message, or removing one, breaks downstream log-based tooling. Keep the accurate type, add `!` after it, and list the changes in a `BREAKING CHANGE:` footer. PRs are squash-merged, so the `!` must also be in the PR title.
136
+
137
+ ## Release process
138
+
139
+ Publishing new versions to [PyPI](https://pypi.org/project/apify_client) is automated through GitHub Actions.
140
+
141
+ - **Beta releases**: On each commit to the master branch, a new beta release is automatically published. The version number is determined based on the latest release and conventional commits. The beta version suffix is incremented by 1 from the last beta release on PyPI.
142
+ - **Stable releases**: A stable version release may be created by triggering the `release` GitHub Actions workflow. The version number is determined based on the latest release and conventional commits (`auto` release type), or it may be overridden using the `custom` release type.
143
+
144
+ ### Publishing to PyPI manually
145
+
146
+ 1. **Do not do this unless absolutely necessary.** In all conceivable scenarios, you should use the `release` workflow instead.
147
+ 2. **Make sure you know what you're doing.**
148
+ 3. Update the version number by modifying the `version` field under `project` in `pyproject.toml`:
149
+
150
+ ```toml
151
+ [project]
152
+ name = "apify_client"
153
+ version = "x.z.y"
154
+ ```
155
+
156
+ 4. Build the package:
157
+
158
+ ```sh
159
+ uv run poe build
160
+ ```
161
+
162
+ 5. Upload to PyPI:
163
+
164
+ ```sh
165
+ uv publish --token YOUR_API_TOKEN
166
+ ```
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: apify_client
3
- Version: 3.3.0
3
+ Version: 3.3.1b2
4
4
  Summary: Apify API client for Python
5
5
  Keywords: apify,api,client,automation,crawling,scraping
6
6
  Author: Apify Technologies s.r.o.
@@ -15,9 +15,10 @@ Classifier: Programming Language :: Python :: 3.11
15
15
  Classifier: Programming Language :: Python :: 3.12
16
16
  Classifier: Programming Language :: Python :: 3.13
17
17
  Classifier: Programming Language :: Python :: 3.14
18
+ Classifier: Programming Language :: Python :: 3.15
18
19
  Classifier: Topic :: Software Development :: Libraries
19
20
  Requires-Dist: colorama>=0.4.0
20
- Requires-Dist: impit~=0.14.0
21
+ Requires-Dist: impit>=0.14.0,<0.16.0
21
22
  Requires-Dist: more-itertools>=10.0.0
22
23
  Requires-Dist: pydantic[email]>=2.11.0
23
24
  Requires-Dist: typing-extensions>=4.6.0
@@ -261,12 +262,7 @@ The full documentation lives at **[docs.apify.com/api/client/python](https://doc
261
262
 
262
263
  ## Contributing
263
264
 
264
- Bug reports, fixes, and improvements are welcome! See [CONTRIBUTING.md](./CONTRIBUTING.md) for the development setup, coding standards, testing, and the release process. The repo uses [uv](https://docs.astral.sh/uv/) for project management and [Poe the Poet](https://poethepoet.natn.io/) as a task runner; the typical loop is:
265
-
266
- ```bash
267
- uv run poe install-dev # install dev deps and git hooks
268
- uv run poe check-code # lint, type-check, unit tests, docstring check
269
- ```
265
+ Bug reports, fixes, and improvements are welcome! See [CONTRIBUTING.md](./CONTRIBUTING.md) for the development setup, coding standards, testing, and the release process.
270
266
 
271
267
  ## License
272
268
 
@@ -224,12 +224,7 @@ The full documentation lives at **[docs.apify.com/api/client/python](https://doc
224
224
 
225
225
  ## Contributing
226
226
 
227
- Bug reports, fixes, and improvements are welcome! See [CONTRIBUTING.md](./CONTRIBUTING.md) for the development setup, coding standards, testing, and the release process. The repo uses [uv](https://docs.astral.sh/uv/) for project management and [Poe the Poet](https://poethepoet.natn.io/) as a task runner; the typical loop is:
228
-
229
- ```bash
230
- uv run poe install-dev # install dev deps and git hooks
231
- uv run poe check-code # lint, type-check, unit tests, docstring check
232
- ```
227
+ Bug reports, fixes, and improvements are welcome! See [CONTRIBUTING.md](./CONTRIBUTING.md) for the development setup, coding standards, testing, and the release process.
233
228
 
234
229
  ## License
235
230
 
@@ -4,7 +4,7 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "apify_client"
7
- version = "3.3.0"
7
+ version = "3.3.1b2"
8
8
  description = "Apify API client for Python"
9
9
  license = "Apache-2.0"
10
10
  license-files = ["LICENSE"]
@@ -19,6 +19,7 @@ classifiers = [
19
19
  "Programming Language :: Python :: 3.12",
20
20
  "Programming Language :: Python :: 3.13",
21
21
  "Programming Language :: Python :: 3.14",
22
+ "Programming Language :: Python :: 3.15",
22
23
  "Topic :: Software Development :: Libraries",
23
24
  ]
24
25
  keywords = [
@@ -31,7 +32,7 @@ keywords = [
31
32
  ]
32
33
  dependencies = [
33
34
  "colorama>=0.4.0",
34
- "impit~=0.14.0",
35
+ "impit>=0.14.0,<0.16.0",
35
36
  "more_itertools>=10.0.0",
36
37
  "pydantic[email]>=2.11.0",
37
38
  "typing-extensions>=4.6.0",
@@ -68,7 +69,7 @@ dev = [
68
69
  "pytest-timeout<3.0.0",
69
70
  "pytest-xdist<4.0.0",
70
71
  "pytest<10.0.0",
71
- "ruff~=0.16.0",
72
+ "ruff~=0.17.0",
72
73
  "setuptools",
73
74
  "types-colorama<0.5.0",
74
75
  "ty~=0.0.0",
@@ -262,56 +263,99 @@ apify-client = false
262
263
  apify-shared = false
263
264
  apify_fingerprint_datapoints = false
264
265
  crawlee = false
266
+ impit = false
265
267
 
266
- [tool.poe.tasks]
267
- clean = "rm -rf .coverage .pytest_cache .ruff_cache .ty_cache build dist htmlcov"
268
- install-sync = "uv sync --all-extras"
269
- build = "uv build"
270
- publish-to-pypi = "uv publish --token ${APIFY_PYPI_TOKEN_CRAWLEE}"
271
- type-check = "uv run ty check"
272
- unit-tests = "uv run pytest --numprocesses=${TESTS_CONCURRENCY:-auto} tests/unit"
273
- unit-tests-cov = "uv run pytest --numprocesses=${TESTS_CONCURRENCY:-auto} --cov=src/apify_client --cov-report=xml:coverage-unit.xml tests/unit"
274
- integration-tests = "uv run pytest --numprocesses=${TESTS_CONCURRENCY:-auto} tests/integration"
275
- integration-tests-cov = "uv run pytest --numprocesses=${TESTS_CONCURRENCY:-auto} --cov=src/apify_client --cov-report=xml:coverage-integration.xml tests/integration"
276
- check-docstrings = "uv run python -m scripts.check_docstrings"
277
- fix-docstrings = "uv run python -m scripts.fix_docstrings"
278
- check-code = [
268
+ [tool.poe.tasks.clean]
269
+ cmd = "rm -rf .coverage .pytest_cache .ruff_cache .ty_cache build dist htmlcov"
270
+ help = "Remove build artifacts and clean caches"
271
+
272
+ [tool.poe.tasks.install-sync]
273
+ cmd = "uv sync --all-extras"
274
+ help = "Sync dependencies with all extras"
275
+
276
+ [tool.poe.tasks.build]
277
+ cmd = "uv build"
278
+ help = "Build the package"
279
+
280
+ [tool.poe.tasks.publish-to-pypi]
281
+ cmd = "uv publish --token ${APIFY_PYPI_TOKEN_CRAWLEE}"
282
+ help = "Publish the package to PyPI"
283
+
284
+ [tool.poe.tasks.type-check]
285
+ cmd = "uv run ty check"
286
+ help = "Run the type checker"
287
+
288
+ [tool.poe.tasks.unit-tests]
289
+ cmd = "uv run pytest --numprocesses=${TESTS_CONCURRENCY:-auto} tests/unit"
290
+ help = "Run unit tests"
291
+
292
+ [tool.poe.tasks.unit-tests-cov]
293
+ cmd = "uv run pytest --numprocesses=${TESTS_CONCURRENCY:-auto} --cov=src/apify_client --cov-report=xml:coverage-unit.xml tests/unit"
294
+ help = "Run unit tests with an XML coverage report"
295
+
296
+ [tool.poe.tasks.integration-tests]
297
+ cmd = "uv run pytest --numprocesses=${TESTS_CONCURRENCY:-auto} tests/integration"
298
+ help = "Run integration tests"
299
+
300
+ [tool.poe.tasks.integration-tests-cov]
301
+ cmd = "uv run pytest --numprocesses=${TESTS_CONCURRENCY:-auto} --cov=src/apify_client --cov-report=xml:coverage-integration.xml tests/integration"
302
+ help = "Run integration tests with an XML coverage report"
303
+
304
+ [tool.poe.tasks.check-docstrings]
305
+ cmd = "uv run python -m scripts.check_docstrings"
306
+ help = "Check that async client docstrings match their sync counterparts"
307
+
308
+ [tool.poe.tasks.fix-docstrings]
309
+ cmd = "uv run python -m scripts.fix_docstrings"
310
+ help = "Copy sync client docstrings to their async counterparts"
311
+
312
+ [tool.poe.tasks.check-code]
313
+ sequence = [
279
314
  "lint",
280
315
  "type-check",
281
316
  "check-docstrings",
282
317
  "unit-tests",
283
318
  ]
319
+ help = "Run lint, type check, docstring check, and unit tests"
284
320
 
285
321
  [tool.poe.tasks.install-dev]
286
322
  shell = "uv sync --all-extras && uv run pre-commit install"
323
+ help = "Install development dependencies and pre-commit hooks"
287
324
 
288
325
  [tool.poe.tasks.lint]
289
326
  shell = "uv run ruff format --check && uv run ruff check"
327
+ help = "Run the linter and check formatting"
290
328
 
291
329
  [tool.poe.tasks.format]
292
330
  shell = "uv run ruff check --fix && uv run ruff format"
331
+ help = "Fix lint issues and format code"
293
332
 
294
333
  [tool.poe.tasks.update-docs-theme]
295
334
  shell = "pnpm update @apify/docs-theme"
296
335
  cwd = "website"
336
+ help = "Update the documentation website theme"
297
337
 
298
338
  [tool.poe.tasks.build-docs]
299
339
  shell = "./build_api_reference.sh && pnpm install && uv run pnpm build"
300
340
  cwd = "website"
341
+ help = "Build the documentation website"
301
342
 
302
343
  [tool.poe.tasks.run-docs]
303
344
  shell = "./build_api_reference.sh && pnpm install && uv run pnpm start"
304
345
  cwd = "website"
346
+ help = "Run the documentation website locally"
305
347
 
306
348
  [tool.poe.tasks.generate-models]
307
349
  shell = """
308
350
  uv run python -m scripts.openapi_spec fetch && uv run datamodel-codegen --input tmp/openapi.json --alias-generator to_camel && uv run datamodel-codegen --input tmp/openapi.json --output src/apify_client/_typeddicts.py --output-model-type typing.TypedDict --no-use-closed-typed-dict && python scripts/postprocess_generated_models.py && uv run python -m scripts.openapi_spec record-version
309
351
  """
352
+ help = "Regenerate models from the published OpenAPI specification"
310
353
 
311
354
  [tool.poe.tasks.generate-models-from-file]
312
355
  shell = """
313
356
  uv run datamodel-codegen --input $input_file --alias-generator to_camel && uv run datamodel-codegen --input $input_file --output src/apify_client/_typeddicts.py --output-model-type typing.TypedDict --no-use-closed-typed-dict && python scripts/postprocess_generated_models.py && echo "Generated from $input_file - the version in [tool.apify.openapi-spec] still names the published specification, so do not commit these models."
314
357
  """
358
+ help = "Generate models from a local OpenAPI specification file (don't commit the result)"
315
359
 
316
360
  [[tool.poe.tasks.generate-models-from-file.args]]
317
361
  name = "input-file"
@@ -4,7 +4,7 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "apify_client"
7
- version = "3.3.0"
7
+ version = "3.3.1b2"
8
8
  description = "Apify API client for Python"
9
9
  authors = [{ name = "Apify Technologies s.r.o.", email = "support@apify.com" }]
10
10
  license = "Apache-2.0"
@@ -20,12 +20,13 @@ classifiers = [
20
20
  "Programming Language :: Python :: 3.12",
21
21
  "Programming Language :: Python :: 3.13",
22
22
  "Programming Language :: Python :: 3.14",
23
+ "Programming Language :: Python :: 3.15",
23
24
  "Topic :: Software Development :: Libraries",
24
25
  ]
25
26
  keywords = ["apify", "api", "client", "automation", "crawling", "scraping"]
26
27
  dependencies = [
27
28
  "colorama>=0.4.0",
28
- "impit~=0.14.0",
29
+ "impit>=0.14.0,<0.16.0",
29
30
  "more_itertools>=10.0.0",
30
31
  "pydantic[email]>=2.11.0",
31
32
  # 4.6.0 is the first release whose runtime protocol checks look attributes up statically.
@@ -63,7 +64,7 @@ dev = [
63
64
  "pytest-timeout<3.0.0",
64
65
  "pytest-xdist<4.0.0",
65
66
  "pytest<10.0.0",
66
- "ruff~=0.16.0",
67
+ "ruff~=0.17.0",
67
68
  "setuptools", # setuptools are used by pytest but not explicitly required
68
69
  "types-colorama<0.5.0",
69
70
  "ty~=0.0.0",
@@ -262,42 +263,49 @@ apify-client = false
262
263
  apify-shared = false
263
264
  apify_fingerprint_datapoints = false
264
265
  crawlee = false
266
+ impit = false
265
267
 
266
268
  # Run tasks with: uv run poe <task>
267
269
  [tool.poe.tasks]
268
- clean = "rm -rf .coverage .pytest_cache .ruff_cache .ty_cache build dist htmlcov"
269
- install-sync = "uv sync --all-extras"
270
- build = "uv build"
271
- publish-to-pypi = "uv publish --token ${APIFY_PYPI_TOKEN_CRAWLEE}"
272
- type-check = "uv run ty check"
273
- unit-tests = "uv run pytest --numprocesses=${TESTS_CONCURRENCY:-auto} tests/unit"
274
- unit-tests-cov = "uv run pytest --numprocesses=${TESTS_CONCURRENCY:-auto} --cov=src/apify_client --cov-report=xml:coverage-unit.xml tests/unit"
275
- integration-tests = "uv run pytest --numprocesses=${TESTS_CONCURRENCY:-auto} tests/integration"
276
- integration-tests-cov = "uv run pytest --numprocesses=${TESTS_CONCURRENCY:-auto} --cov=src/apify_client --cov-report=xml:coverage-integration.xml tests/integration"
277
- check-docstrings = "uv run python -m scripts.check_docstrings"
278
- fix-docstrings = "uv run python -m scripts.fix_docstrings"
279
- check-code = ["lint", "type-check", "check-docstrings", "unit-tests"]
270
+ clean = { cmd = "rm -rf .coverage .pytest_cache .ruff_cache .ty_cache build dist htmlcov", help = "Remove build artifacts and clean caches" }
271
+ install-sync = { cmd = "uv sync --all-extras", help = "Sync dependencies with all extras" }
272
+ build = { cmd = "uv build", help = "Build the package" }
273
+ publish-to-pypi = { cmd = "uv publish --token ${APIFY_PYPI_TOKEN_CRAWLEE}", help = "Publish the package to PyPI" }
274
+ type-check = { cmd = "uv run ty check", help = "Run the type checker" }
275
+ unit-tests = { cmd = "uv run pytest --numprocesses=${TESTS_CONCURRENCY:-auto} tests/unit", help = "Run unit tests" }
276
+ unit-tests-cov = { cmd = "uv run pytest --numprocesses=${TESTS_CONCURRENCY:-auto} --cov=src/apify_client --cov-report=xml:coverage-unit.xml tests/unit", help = "Run unit tests with an XML coverage report" }
277
+ integration-tests = { cmd = "uv run pytest --numprocesses=${TESTS_CONCURRENCY:-auto} tests/integration", help = "Run integration tests" }
278
+ integration-tests-cov = { cmd = "uv run pytest --numprocesses=${TESTS_CONCURRENCY:-auto} --cov=src/apify_client --cov-report=xml:coverage-integration.xml tests/integration", help = "Run integration tests with an XML coverage report" }
279
+ check-docstrings = { cmd = "uv run python -m scripts.check_docstrings", help = "Check that async client docstrings match their sync counterparts" }
280
+ fix-docstrings = { cmd = "uv run python -m scripts.fix_docstrings", help = "Copy sync client docstrings to their async counterparts" }
281
+ check-code = { sequence = ["lint", "type-check", "check-docstrings", "unit-tests"], help = "Run lint, type check, docstring check, and unit tests" }
280
282
 
281
283
  [tool.poe.tasks.install-dev]
282
284
  shell = "uv sync --all-extras && uv run pre-commit install"
285
+ help = "Install development dependencies and pre-commit hooks"
283
286
 
284
287
  [tool.poe.tasks.lint]
285
288
  shell = "uv run ruff format --check && uv run ruff check"
289
+ help = "Run the linter and check formatting"
286
290
 
287
291
  [tool.poe.tasks.format]
288
292
  shell = "uv run ruff check --fix && uv run ruff format"
293
+ help = "Fix lint issues and format code"
289
294
 
290
295
  [tool.poe.tasks.update-docs-theme]
291
296
  shell = "pnpm update @apify/docs-theme"
292
297
  cwd = "website"
298
+ help = "Update the documentation website theme"
293
299
 
294
300
  [tool.poe.tasks.build-docs]
295
301
  shell = "./build_api_reference.sh && pnpm install && uv run pnpm build"
296
302
  cwd = "website"
303
+ help = "Build the documentation website"
297
304
 
298
305
  [tool.poe.tasks.run-docs]
299
306
  shell = "./build_api_reference.sh && pnpm install && uv run pnpm start"
300
307
  cwd = "website"
308
+ help = "Run the documentation website locally"
301
309
 
302
310
  # Both passes read one downloaded copy, so a specification redeployed mid-run can't leave the generated files
303
311
  # describing two different inputs. The version is recorded last, once generation succeeded.
@@ -316,6 +324,7 @@ uv run python -m scripts.openapi_spec fetch \
316
324
  && python scripts/postprocess_generated_models.py \
317
325
  && uv run python -m scripts.openapi_spec record-version
318
326
  """
327
+ help = "Regenerate models from the published OpenAPI specification"
319
328
 
320
329
  # For a candidate specification that isn't published yet. The recorded version deliberately stays put - it names
321
330
  # the published specification - so models generated this way must not be committed, hence the warning.
@@ -331,3 +340,4 @@ uv run datamodel-codegen --input $input_file \
331
340
  && echo "Generated from $input_file - the version in [tool.apify.openapi-spec] still names the published specification, so do not commit these models."
332
341
  """
333
342
  args = [{ name = "input-file", positional = true, required = true }]
343
+ help = "Generate models from a local OpenAPI specification file (don't commit the result)"
@@ -12,7 +12,7 @@ from apify_client._utils.crypto import create_storage_content_signature
12
12
  from apify_client._utils.http import response_to_dict, response_to_list
13
13
 
14
14
  if TYPE_CHECKING:
15
- from collections.abc import AsyncIterator, Iterator
15
+ from collections.abc import AsyncGenerator, AsyncIterator, Generator, Iterator
16
16
  from datetime import timedelta
17
17
 
18
18
  from apify_client._literals import GeneralAccess
@@ -405,7 +405,7 @@ class DatasetClient(ResourceClient):
405
405
  flatten: list[str] | None = None,
406
406
  signature: str | None = None,
407
407
  timeout: Timeout = 'long',
408
- ) -> Iterator[HttpResponse]:
408
+ ) -> Generator[HttpResponse]:
409
409
  """Retrieve the items in the dataset as a stream.
410
410
 
411
411
  https://docs.apify.com/api/v2#/reference/datasets/item-collection/get-items
@@ -970,7 +970,7 @@ class DatasetClientAsync(ResourceClientAsync):
970
970
  flatten: list[str] | None = None,
971
971
  signature: str | None = None,
972
972
  timeout: Timeout = 'long',
973
- ) -> AsyncIterator[HttpResponse]:
973
+ ) -> AsyncGenerator[HttpResponse]:
974
974
  """Retrieve the items in the dataset as a stream.
975
975
 
976
976
  https://docs.apify.com/api/v2#/reference/datasets/item-collection/get-items
@@ -22,7 +22,7 @@ from apify_client._utils.http import response_to_dict, to_path_segment
22
22
  from apify_client.errors import ApifyApiError, InvalidResponseBodyError
23
23
 
24
24
  if TYPE_CHECKING:
25
- from collections.abc import AsyncIterator, Iterator
25
+ from collections.abc import AsyncGenerator, AsyncIterator, Generator, Iterator
26
26
  from datetime import timedelta
27
27
 
28
28
  from apify_client._literals import GeneralAccess
@@ -318,7 +318,7 @@ class KeyValueStoreClient(ResourceClient):
318
318
  @contextmanager
319
319
  def stream_record(
320
320
  self, key: str, *, signature: str | None = None, timeout: Timeout = 'long'
321
- ) -> Iterator[dict | None]:
321
+ ) -> Generator[dict | None]:
322
322
  """Retrieve the given record from the key-value store, as a stream.
323
323
 
324
324
  https://docs.apify.com/api/v2#/reference/key-value-stores/record/get-record
@@ -756,7 +756,7 @@ class KeyValueStoreClientAsync(ResourceClientAsync):
756
756
  @asynccontextmanager
757
757
  async def stream_record(
758
758
  self, key: str, *, signature: str | None = None, timeout: Timeout = 'long'
759
- ) -> AsyncIterator[dict | None]:
759
+ ) -> AsyncGenerator[dict | None]:
760
760
  """Retrieve the given record from the key-value store, as a stream.
761
761
 
762
762
  https://docs.apify.com/api/v2#/reference/key-value-stores/record/get-record
@@ -9,7 +9,7 @@ from apify_client._utils.errors import catch_not_found_for_resource_or_throw
9
9
  from apify_client.errors import ApifyApiError
10
10
 
11
11
  if TYPE_CHECKING:
12
- from collections.abc import AsyncIterator, Iterator
12
+ from collections.abc import AsyncGenerator, Generator
13
13
 
14
14
  from apify_client.http_clients import HttpResponse
15
15
  from apify_client.types import Timeout
@@ -96,7 +96,7 @@ class LogClient(ResourceClient):
96
96
  return None
97
97
 
98
98
  @contextmanager
99
- def stream(self, *, raw: bool = False, timeout: Timeout = 'long') -> Iterator[HttpResponse | None]:
99
+ def stream(self, *, raw: bool = False, timeout: Timeout = 'long') -> Generator[HttpResponse | None]:
100
100
  """Retrieve the log as a stream.
101
101
 
102
102
  https://docs.apify.com/api/v2#/reference/logs/log/get-log
@@ -211,7 +211,7 @@ class LogClientAsync(ResourceClientAsync):
211
211
  return None
212
212
 
213
213
  @asynccontextmanager
214
- async def stream(self, *, raw: bool = False, timeout: Timeout = 'long') -> AsyncIterator[HttpResponse | None]:
214
+ async def stream(self, *, raw: bool = False, timeout: Timeout = 'long') -> AsyncGenerator[HttpResponse | None]:
215
215
  """Retrieve the log as a stream.
216
216
 
217
217
  https://docs.apify.com/api/v2#/reference/logs/log/get-log
@@ -32,6 +32,17 @@ class StreamedLogBase:
32
32
  duration of the run (Impit currently maps it to an effective 24-hour cap) and mirrors the JS client.
33
33
  """
34
34
 
35
+ _empty_stream_retry_s: ClassVar[float] = 0.5
36
+ """Pause before the first reopen of a log stream that ended before the run logged anything.
37
+
38
+ The API serves the log of a run that has not logged anything yet as an empty stream that ends at once. Each further
39
+ reopen doubles the pause up to `_empty_stream_max_retry_s`, which bounds the request rate while a run waits long to
40
+ start, for example for free memory.
41
+ """
42
+
43
+ _empty_stream_max_retry_s: ClassVar[float] = 5
44
+ """Upper bound on the pause between reopens of an empty log stream."""
45
+
35
46
  def __init__(self, to_logger: logging.Logger, *, from_start: bool = True) -> None:
36
47
  if self._force_propagate:
37
48
  to_logger.propagate = True
@@ -39,8 +50,11 @@ class StreamedLogBase:
39
50
  self._stream_buffer = list[bytes]()
40
51
  self._split_marker = re.compile(rb'(?:\n|^)(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z)')
41
52
  self._relevancy_time_limit: datetime | None = None if from_start else datetime.now(tz=UTC)
53
+ self._received_data = False
42
54
 
43
55
  def _process_new_data(self, data: bytes) -> None:
56
+ if data:
57
+ self._received_data = True
44
58
  new_chunk = data
45
59
  self._stream_buffer.append(new_chunk)
46
60
  if re.findall(self._split_marker, new_chunk):
@@ -75,6 +89,12 @@ class StreamedLogBase:
75
89
  message = decoded_marker + decoded_content
76
90
  self._to_logger.log(level=self._guess_log_level_from_message(message), msg=message.strip())
77
91
 
92
+ def _process_whole_log(self, log: bytes | None) -> None:
93
+ """Redirect a whole log read in one request, including its last part."""
94
+ if log:
95
+ self._process_new_data(log)
96
+ self._log_buffer_content(include_last_part=True)
97
+
78
98
  @staticmethod
79
99
  def _guess_log_level_from_message(message: str) -> int:
80
100
  """Guess the log level from the message."""
@@ -120,7 +140,7 @@ class StreamedLog(StreamedLogBase):
120
140
  self._log_client = log_client
121
141
  self._streaming_thread: Thread | None = None
122
142
  self._log_stream: HttpResponse | None = None
123
- self._stop_logging = False
143
+ self._stop_event = threading.Event()
124
144
 
125
145
  def start(self) -> Thread:
126
146
  """Start the streaming thread.
@@ -129,7 +149,8 @@ class StreamedLog(StreamedLogBase):
129
149
  """
130
150
  if self._streaming_thread and self._streaming_thread.is_alive():
131
151
  raise RuntimeError('Streaming thread already active')
132
- self._stop_logging = False
152
+ self._stop_event.clear()
153
+ self._received_data = False
133
154
  # A daemon thread so a stream still blocked on a read can never hold up interpreter shutdown.
134
155
  self._streaming_thread = threading.Thread(target=self._stream_log, daemon=True)
135
156
  self._streaming_thread.start()
@@ -138,13 +159,14 @@ class StreamedLog(StreamedLogBase):
138
159
  def stop(self) -> None:
139
160
  """Signal the streaming thread to stop logging and wait up to `_stop_timeout_s` for it to finish.
140
161
 
141
- A thread that outlives the wait is a daemon with `_stop_logging` set, so it exits after at most one more chunk,
162
+ A thread that outlives the wait is a daemon with `_stop_event` set, so it exits after at most one more chunk,
142
163
  and only then does its buffered tail reach the logger. Its handle is kept while it is alive, so `start` cannot
143
- revive it beside a second thread on the same buffer.
164
+ revive it beside a second thread on the same buffer. If no stream has delivered anything yet, the thread reads
165
+ the whole log in one request before it ends.
144
166
  """
145
167
  if not self._streaming_thread:
146
168
  raise RuntimeError('Streaming thread is not active')
147
- self._stop_logging = True
169
+ self._stop_event.set()
148
170
  # Read once; the streaming thread clears the attribute as soon as the stream ends.
149
171
  log_stream = self._log_stream
150
172
  if log_stream is not None:
@@ -173,39 +195,60 @@ class StreamedLog(StreamedLogBase):
173
195
 
174
196
  def _stream_log(self) -> None:
175
197
  try:
176
- with self._log_client.stream(raw=True, timeout=self._stream_timeout) as log_stream:
177
- if not log_stream:
198
+ # An empty stream means the run has not logged anything yet, so reopen it until the first bytes arrive.
199
+ retry_s = self._empty_stream_retry_s
200
+ while not self._stop_event.is_set():
201
+ if not self._stream_log_once() or self._received_data:
178
202
  return
179
- # Published so `stop` can close the response.
180
- self._log_stream = log_stream
181
- try:
182
- # `stop` may have run before the response existed for it to close.
183
- if self._stop_logging:
184
- return
185
- for data in log_stream.iter_bytes():
186
- self._process_new_data(data)
187
- if self._stop_logging:
188
- break
189
- finally:
190
- self._log_stream = None
191
- try:
192
- # Flush the last buffered part even if the read timed out or was stopped.
193
- self._log_buffer_content(include_last_part=True)
194
- except Exception:
195
- # A truncated stream leaves an undecodable tail, which is worth a traceback even while a stop
196
- # is in progress.
197
- self._to_logger.exception('Log redirection stopped due to unexpected error:')
203
+ self._stop_event.wait(retry_s)
204
+ retry_s = min(retry_s * 2, self._empty_stream_max_retry_s)
198
205
  except Exception as exc:
199
- if self._stop_logging:
206
+ if self._stop_event.is_set():
200
207
  # `stop` closed the stream out from under the read, so the failure is expected.
201
208
  self._to_logger.debug('Log streaming stopped while `stop` was in progress: %r', exc)
202
- return
203
- if self._log_client._http_client.is_timeout_error(exc): # noqa: SLF001
209
+ elif self._log_client._http_client.is_timeout_error(exc): # noqa: SLF001
204
210
  # The stream cannot continue, so warn and let the thread end instead of leaking a traceback.
205
211
  self._to_logger.warning('Log streaming stopped: the log stream request timed out.')
212
+ return
206
213
  else:
207
214
  # Any other failure in log redirection must not escape the background thread; log it instead.
208
215
  self._to_logger.exception('Log redirection stopped due to unexpected error:')
216
+ return
217
+ if self._received_data:
218
+ return
219
+ # Stopped before any stream delivered a byte, which a run that finishes quickly can cause.
220
+ try:
221
+ self._process_whole_log(self._log_client.get_as_bytes(raw=True))
222
+ except Exception:
223
+ self._to_logger.exception('Log redirection stopped due to unexpected error:')
224
+
225
+ def _stream_log_once(self) -> bool:
226
+ """Redirect one log stream until it ends or `stop` is called. Return `False` when the log does not exist."""
227
+ with self._log_client.stream(raw=True, timeout=self._stream_timeout) as log_stream:
228
+ if not log_stream:
229
+ return False
230
+ # Published so `stop` can close the response.
231
+ self._log_stream = log_stream
232
+ try:
233
+ # `stop` may have run before the response existed for it to close. No earlier stream delivered
234
+ # anything, and one opened this late would end after its first chunk, so read the whole log in one
235
+ # request.
236
+ if self._stop_event.is_set():
237
+ return True
238
+ for data in log_stream.iter_bytes():
239
+ self._process_new_data(data)
240
+ if self._stop_event.is_set():
241
+ break
242
+ finally:
243
+ self._log_stream = None
244
+ try:
245
+ # Flush the last buffered part even if the read timed out or was stopped.
246
+ self._log_buffer_content(include_last_part=True)
247
+ except Exception:
248
+ # A truncated stream leaves an undecodable tail, which is worth a traceback even while a stop is
249
+ # in progress.
250
+ self._to_logger.exception('Log redirection stopped due to unexpected error:')
251
+ return True
209
252
 
210
253
 
211
254
  @docs_group('Other')
@@ -240,14 +283,19 @@ class StreamedLogAsync(StreamedLogBase):
240
283
  """
241
284
  if self._streaming_task and not self._streaming_task.done():
242
285
  raise RuntimeError('Streaming task already active')
286
+ self._received_data = False
243
287
  self._streaming_task = asyncio.create_task(self._stream_log())
244
288
  return self._streaming_task
245
289
 
246
290
  async def stop(self) -> None:
247
- """Stop the streaming task."""
291
+ """Stop the streaming task.
292
+
293
+ If no stream has delivered anything yet, read the whole log in one request instead.
294
+ """
248
295
  if not self._streaming_task:
249
296
  raise RuntimeError('Streaming task is not active')
250
297
 
298
+ was_streaming = not self._streaming_task.done()
251
299
  self._streaming_task.cancel()
252
300
  try:
253
301
  await self._streaming_task
@@ -255,6 +303,13 @@ class StreamedLogAsync(StreamedLogBase):
255
303
  pass
256
304
  finally:
257
305
  self._streaming_task = None
306
+ if not was_streaming or self._received_data:
307
+ return
308
+ # Stopped before any stream delivered a byte, which a run that finishes quickly can cause.
309
+ try:
310
+ self._process_whole_log(await self._log_client.get_as_bytes(raw=True))
311
+ except Exception:
312
+ self._to_logger.exception('Log redirection stopped due to unexpected error:')
258
313
 
259
314
  async def __aenter__(self) -> Self:
260
315
  """Start the streaming task within the context. Exiting the context will cancel the streaming task."""
@@ -269,20 +324,27 @@ class StreamedLogAsync(StreamedLogBase):
269
324
 
270
325
  async def _stream_log(self) -> None:
271
326
  try:
272
- async with self._log_client.stream(raw=True, timeout=self._stream_timeout) as log_stream:
273
- if not log_stream:
274
- return
275
- try:
276
- async for data in log_stream.aiter_bytes():
277
- self._process_new_data(data)
278
- finally:
327
+ # An empty stream means the run has not logged anything yet, so reopen it until the first bytes arrive.
328
+ retry_s = self._empty_stream_retry_s
329
+ while True:
330
+ async with self._log_client.stream(raw=True, timeout=self._stream_timeout) as log_stream:
331
+ if not log_stream:
332
+ return
279
333
  try:
280
- # Flush the last buffered part even if the task is cancelled by `stop()`.
281
- self._log_buffer_content(include_last_part=True)
282
- except Exception:
283
- # A truncated stream leaves an undecodable tail. Keeping the failure here also keeps the
284
- # cancellation `stop` raised propagating, so the task ends up cancelled as asyncio expects.
285
- self._to_logger.exception('Log redirection stopped due to unexpected error:')
334
+ async for data in log_stream.aiter_bytes():
335
+ self._process_new_data(data)
336
+ finally:
337
+ try:
338
+ # Flush the last buffered part even if the task is cancelled by `stop()`.
339
+ self._log_buffer_content(include_last_part=True)
340
+ except Exception:
341
+ # A truncated stream leaves an undecodable tail. Keeping the failure here also keeps the
342
+ # cancellation `stop` raised propagating, so the task ends up cancelled as asyncio expects.
343
+ self._to_logger.exception('Log redirection stopped due to unexpected error:')
344
+ if self._received_data:
345
+ return
346
+ await asyncio.sleep(retry_s)
347
+ retry_s = min(retry_s * 2, self._empty_stream_max_retry_s)
286
348
  except Exception as exc:
287
349
  if self._log_client._http_client.is_timeout_error(exc): # noqa: SLF001
288
350
  # A timeout on the long-lived stream is an expected terminal condition, not an error.
@@ -7,7 +7,7 @@ from types import ModuleType
7
7
  from typing import TYPE_CHECKING
8
8
 
9
9
  if TYPE_CHECKING:
10
- from collections.abc import Iterator
10
+ from collections.abc import Generator
11
11
  from typing import Any
12
12
 
13
13
 
@@ -19,7 +19,7 @@ class ImportState:
19
19
 
20
20
 
21
21
  @contextmanager
22
- def try_import(module_name: str, *symbol_names: str, dependency_name: str, extra_name: str) -> Iterator[ImportState]:
22
+ def try_import(module_name: str, *symbol_names: str, dependency_name: str, extra_name: str) -> Generator[ImportState]:
23
23
  """Context manager to attempt importing symbols into a module.
24
24
 
25
25
  If the named optional dependency is missing, the symbols are replaced with `FailedImport` objects whose message
@@ -1,189 +0,0 @@
1
- # Development
2
-
3
- Here you'll find a contributing guide to get started with development.
4
-
5
- ## Environment
6
-
7
- For local development, it is required to have Python 3.11 (or a later version) installed.
8
-
9
- We use [uv](https://docs.astral.sh/uv/) for project management. Install it and set up your IDE accordingly.
10
-
11
- We use [Poe the Poet](https://poethepoet.natn.io/) as a task runner, similar to npm scripts in `package.json`.
12
- All tasks are defined in `pyproject.toml` under `[tool.poe.tasks]` and can be run with `uv run poe <task>`.
13
-
14
- ### Available tasks
15
-
16
- | Task | Description |
17
- | ---- | ----------- |
18
- | `install-dev` | Install development dependencies |
19
- | `check-code` | Run lint, type-check, unit-tests, and docstring check |
20
- | `lint` | Run linter |
21
- | `format` | Fix lint issues and format code |
22
- | `type-check` | Run type checker |
23
- | `unit-tests` | Run unit tests |
24
- | `unit-tests-cov` | Run unit tests with coverage |
25
- | `integration-tests` | Run integration tests |
26
- | `integration-tests-cov` | Run integration tests with coverage |
27
- | `check-docstrings` | Check async client docstrings |
28
- | `fix-docstrings` | Fix async client docstrings |
29
- | `build-docs` | Build documentation website |
30
- | `run-docs` | Run documentation website locally |
31
- | `build` | Build package |
32
- | `clean` | Remove build artifacts and clean caches |
33
-
34
- ## Dependencies
35
-
36
- To install this package and its development dependencies, run:
37
-
38
- ```sh
39
- uv run poe install-dev
40
- ```
41
-
42
- ## Code checking
43
-
44
- To execute all code checking tools together, run:
45
-
46
- ```sh
47
- uv run poe check-code
48
- ```
49
-
50
- ### Linting
51
-
52
- We utilize [ruff](https://docs.astral.sh/ruff/) for linting, which analyzes code for potential issues and enforces consistent style. Refer to `pyproject.toml` for configuration details.
53
-
54
- To run linting:
55
-
56
- ```sh
57
- uv run poe lint
58
- ```
59
-
60
- ### Formatting
61
-
62
- Our automated code formatting also leverages [ruff](https://docs.astral.sh/ruff/), ensuring uniform style and addressing fixable linting issues. Configuration specifics are outlined in `pyproject.toml`.
63
-
64
- To run formatting:
65
-
66
- ```sh
67
- uv run poe format
68
- ```
69
-
70
- ### Type checking
71
-
72
- Type checking is handled by [ty](https://docs.astral.sh/ty/), verifying code against type annotations. Configuration settings can be found in `pyproject.toml`.
73
-
74
- To run type checking:
75
-
76
- ```sh
77
- uv run poe type-check
78
- ```
79
-
80
- ### Unit tests
81
-
82
- We use [pytest](https://docs.pytest.org/) as a testing framework with many plugins. Check `pyproject.toml` for configuration details and installed plugins.
83
-
84
- To run unit tests:
85
-
86
- ```sh
87
- uv run poe unit-tests
88
- ```
89
-
90
- To run unit tests with XML coverage report:
91
-
92
- ```sh
93
- uv run poe unit-tests-cov
94
- ```
95
-
96
- ## Integration tests
97
-
98
- We have integration tests which interact with the Apify platform.
99
-
100
- Prerequisites:
101
-
102
- - Set `APIFY_TEST_USER_API_TOKEN` to your Apify API token
103
- - Set `APIFY_TEST_USER_2_API_TOKEN` for storage access tests (requires two accounts)
104
- - Optionally set `APIFY_INTEGRATION_TESTS_API_URL` to use a different Apify API environment
105
-
106
- To run integration tests:
107
-
108
- ```sh
109
- uv run poe integration-tests
110
- ```
111
-
112
- ## Documentation
113
-
114
- We adhere to the [Google docstring format](https://sphinxcontrib-napoleon.readthedocs.io/en/latest/example_google.html) for documenting our codebase. Every user-facing class or method is documented. Documentation standards are enforced using [Ruff](https://docs.astral.sh/ruff/).
115
-
116
- Our API documentation is generated from these docstrings using [pydoc-markdown](https://pypi.org/project/pydoc-markdown/) with additional post-processing. Markdown files in the `docs/` folder complement the autogenerated content. Final documentation is rendered using [Docusaurus](https://docusaurus.io/) and published to GitHub Pages.
117
-
118
- To run the documentation locally (requires Node.js):
119
-
120
- ```sh
121
- uv run poe run-docs
122
- ```
123
-
124
- ### Linting the docs and website
125
-
126
- Markdown content (this guide, `README.md`, and the `docs/` folder) is checked with
127
- [markdownlint](https://github.com/DavidAnson/markdownlint). The Docusaurus website code is linted
128
- with [oxlint](https://oxc.rs/) and formatted with [oxfmt](https://oxc.rs/). All of them run in CI.
129
- To run them locally (requires Node.js 22.12 or newer and pnpm), from the `website/` directory:
130
-
131
- ```sh
132
- pnpm lint # lint Markdown and website code
133
- pnpm lint:fix # auto-fix both
134
- pnpm format # format the website code
135
- ```
136
-
137
- Doc images are committed as optimized `.webp`. To convert a new image, run
138
- `pnpm opt:images <path-to-image>` from the `website/` directory.
139
-
140
- ## Commits
141
-
142
- We use [Conventional Commits](https://www.conventionalcommits.org/) format for commit messages. This convention is used to automatically determine version bumps during the release process.
143
-
144
- ### Available commit types
145
-
146
- | Type | Description |
147
- | ---- | ----------- |
148
- | `feat` | A new feature |
149
- | `fix` | A bug fix |
150
- | `docs` | Documentation only changes |
151
- | `style` | Changes that do not affect the meaning of the code (white-space, formatting, missing semi-colons, etc) |
152
- | `refactor` | A code change that neither fixes a bug nor adds a feature |
153
- | `perf` | A code change that improves performance |
154
- | `test` | Adding missing tests or correcting existing tests |
155
- | `build` | Changes that affect the build system or external dependencies (example scopes: gulp, broccoli, npm) |
156
- | `ci` | Changes to our CI configuration files and scripts (example scopes: Travis, Circle, BrowserStack, SauceLabs) |
157
- | `chore` | Other changes that don't modify src or test files |
158
- | `revert` | Reverts a previous commit |
159
-
160
- ## Release process
161
-
162
- Publishing new versions to [PyPI](https://pypi.org/project/apify_client) is automated through GitHub Actions.
163
-
164
- - **Beta releases**: On each commit to the master branch, a new beta release is automatically published. The version number is determined based on the latest release and conventional commits. The beta version suffix is incremented by 1 from the last beta release on PyPI.
165
- - **Stable releases**: A stable version release may be created by triggering the `release` GitHub Actions workflow. The version number is determined based on the latest release and conventional commits (`auto` release type), or it may be overridden using the `custom` release type.
166
-
167
- ### Publishing to PyPI manually
168
-
169
- 1. **Do not do this unless absolutely necessary.** In all conceivable scenarios, you should use the `release` workflow instead.
170
- 2. **Make sure you know what you're doing.**
171
- 3. Update the version number by modifying the `version` field under `project` in `pyproject.toml`:
172
-
173
- ```toml
174
- [project]
175
- name = "apify_client"
176
- version = "x.z.y"
177
- ```
178
-
179
- 4. Build the package:
180
-
181
- ```sh
182
- uv run poe build
183
- ```
184
-
185
- 5. Upload to PyPI:
186
-
187
- ```sh
188
- uv publish --token YOUR_API_TOKEN
189
- ```
File without changes