From 19b07765a0edbe0d6adbbc65836d91718bb5b4fd Mon Sep 17 00:00:00 2001 From: "fern-api[bot]" <115122769+fern-api[bot]@users.noreply.github.com> Date: Mon, 23 Feb 2026 11:12:49 +0000 Subject: [PATCH] SDK regeneration Unable to analyze changes with AI, incrementing PATCH version. --- .fern/metadata.json | 14 + .fernignore | 3 +- changelog.md | 4 + poetry.lock | 143 +++-- pyproject.toml | 9 +- reference.md | 256 ++++---- src/newscatcher/__init__.py | 549 ++++++++++++------ src/newscatcher/aggregation/__init__.py | 44 +- src/newscatcher/aggregation/client.py | 100 ++-- src/newscatcher/aggregation/raw_client.py | 64 +- src/newscatcher/aggregation/types/__init__.py | 40 +- src/newscatcher/authors/__init__.py | 44 +- src/newscatcher/authors/client.py | 62 +- src/newscatcher/authors/raw_client.py | 36 +- src/newscatcher/authors/types/__init__.py | 40 +- src/newscatcher/base_client.py | 213 ++++++- src/newscatcher/breaking_news/__init__.py | 29 +- src/newscatcher/breaking_news/client.py | 18 +- .../breaking_news/types/__init__.py | 29 +- src/newscatcher/core/__init__.py | 106 +++- src/newscatcher/core/client_wrapper.py | 29 +- src/newscatcher/core/force_multipart.py | 6 +- src/newscatcher/core/http_client.py | 489 ++++++++++++---- src/newscatcher/core/http_response.py | 8 +- src/newscatcher/core/http_sse/__init__.py | 42 ++ src/newscatcher/core/http_sse/_api.py | 112 ++++ src/newscatcher/core/http_sse/_decoders.py | 61 ++ src/newscatcher/core/http_sse/_exceptions.py | 7 + src/newscatcher/core/http_sse/_models.py | 17 + src/newscatcher/core/jsonable_encoder.py | 8 + src/newscatcher/core/logging.py | 107 ++++ src/newscatcher/core/pydantic_utilities.py | 348 ++++++++++- src/newscatcher/errors/__init__.py | 49 +- src/newscatcher/latestheadlines/__init__.py | 37 +- src/newscatcher/latestheadlines/client.py | 42 +- .../latestheadlines/types/__init__.py | 37 +- src/newscatcher/search/__init__.py | 50 +- src/newscatcher/search/client.py | 112 ++-- src/newscatcher/search/raw_client.py | 64 +- src/newscatcher/search/types/__init__.py | 46 +- src/newscatcher/search_link/client.py | 43 +- src/newscatcher/search_link/raw_client.py | 17 + src/newscatcher/searchsimilar/__init__.py | 44 +- src/newscatcher/searchsimilar/client.py | 75 +-- src/newscatcher/searchsimilar/raw_client.py | 64 +- .../searchsimilar/types/__init__.py | 40 +- src/newscatcher/sources/__init__.py | 29 +- src/newscatcher/sources/client.py | 20 +- src/newscatcher/sources/types/__init__.py | 29 +- src/newscatcher/types/__init__.py | 390 +++++++++---- .../types/additional_source_info.py | 13 +- .../types/aggregation_count_response_dto.py | 4 +- ...gation_count_response_dto_aggregations.py} | 2 +- .../types/aggregation_get_request_from.py | 6 + .../types/aggregation_get_request_to.py | 6 + src/newscatcher/types/article_entity.py | 31 +- .../{authors.py => article_entity_authors.py} | 2 +- ...lists.py => article_entity_journalists.py} | 2 +- .../types/{articles.py => articles_dto.py} | 2 +- .../types/authors_get_request_from.py | 6 + .../types/authors_get_request_to.py | 6 + .../types/base_search_response_dto.py | 10 +- .../types/breaking_news_article_entity.py | 8 +- .../breaking_news_article_entity_authors.py | 5 + ...reaking_news_article_entity_journalists.py | 5 + src/newscatcher/types/canonical_url.py | 3 + .../types/failed_authors_response_dto.py | 2 +- .../failed_search_similar_response_dto.py | 2 +- src/newscatcher/types/nlp_data_entity.py | 111 ++-- .../types/search_get_request_from.py | 6 + .../types/search_get_request_to.py | 6 + src/newscatcher/types/search_response_dto.py | 4 +- .../types/search_similar_get_request_from.py | 6 + .../types/search_similar_get_request_to.py | 6 + src/newscatcher/types/source.py | 3 + src/newscatcher/types/sources_response_dto.py | 2 +- src/newscatcher/types/user_input_dto.py | 2 +- tests/utils/test_http_client.py | 249 +++++++- tests/utils/test_query_encoding.py | 1 - 79 files changed, 3586 insertions(+), 1150 deletions(-) create mode 100644 .fern/metadata.json create mode 100644 changelog.md create mode 100644 src/newscatcher/core/http_sse/__init__.py create mode 100644 src/newscatcher/core/http_sse/_api.py create mode 100644 src/newscatcher/core/http_sse/_decoders.py create mode 100644 src/newscatcher/core/http_sse/_exceptions.py create mode 100644 src/newscatcher/core/http_sse/_models.py create mode 100644 src/newscatcher/core/logging.py rename src/newscatcher/types/{aggregations.py => aggregation_count_response_dto_aggregations.py} (55%) create mode 100644 src/newscatcher/types/aggregation_get_request_from.py create mode 100644 src/newscatcher/types/aggregation_get_request_to.py rename src/newscatcher/types/{authors.py => article_entity_authors.py} (57%) rename src/newscatcher/types/{journalists.py => article_entity_journalists.py} (55%) rename src/newscatcher/types/{articles.py => articles_dto.py} (75%) create mode 100644 src/newscatcher/types/authors_get_request_from.py create mode 100644 src/newscatcher/types/authors_get_request_to.py create mode 100644 src/newscatcher/types/breaking_news_article_entity_authors.py create mode 100644 src/newscatcher/types/breaking_news_article_entity_journalists.py create mode 100644 src/newscatcher/types/canonical_url.py create mode 100644 src/newscatcher/types/search_get_request_from.py create mode 100644 src/newscatcher/types/search_get_request_to.py create mode 100644 src/newscatcher/types/search_similar_get_request_from.py create mode 100644 src/newscatcher/types/search_similar_get_request_to.py create mode 100644 src/newscatcher/types/source.py diff --git a/.fern/metadata.json b/.fern/metadata.json new file mode 100644 index 0000000..90aed75 --- /dev/null +++ b/.fern/metadata.json @@ -0,0 +1,14 @@ +{ + "cliVersion": "3.76.0", + "generatorName": "fernapi/fern-python-sdk", + "generatorVersion": "4.59.0", + "generatorConfig": { + "client": { + "class_name": "BaseNewscatcherApi", + "filename": "base_client.py", + "exported_class_name": "NewscatcherApi", + "exported_filename": "client.py" + } + }, + "sdkVersion": "1.4.1" +} \ No newline at end of file diff --git a/.fernignore b/.fernignore index d75d43d..b31d824 100644 --- a/.fernignore +++ b/.fernignore @@ -15,4 +15,5 @@ test-mock-mode.sh mypy.ini # CI/CD -.github/workflows/ci.yml \ No newline at end of file +.github/workflows/ci.yml +changelog.md diff --git a/changelog.md b/changelog.md new file mode 100644 index 0000000..c047728 --- /dev/null +++ b/changelog.md @@ -0,0 +1,4 @@ +## 1.4.1 - 2026-02-23 +* SDK regeneration +* Unable to analyze changes with AI, incrementing PATCH version. + diff --git a/poetry.lock b/poetry.lock index c2ddd86..2f8666b 100644 --- a/poetry.lock +++ b/poetry.lock @@ -38,13 +38,13 @@ trio = ["trio (>=0.26.1)"] [[package]] name = "certifi" -version = "2025.8.3" +version = "2026.1.4" description = "Python package for providing Mozilla's CA Bundle." optional = false python-versions = ">=3.7" files = [ - {file = "certifi-2025.8.3-py3-none-any.whl", hash = "sha256:f6c12493cfb1b06ba2ff328595af9350c65d6644968e5d3a2ffd78699af217a5"}, - {file = "certifi-2025.8.3.tar.gz", hash = "sha256:e564105f78ded564e3ae7c923924435e1daa7463faeab5bb932bc53ffae63407"}, + {file = "certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c"}, + {file = "certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120"}, ] [[package]] @@ -60,13 +60,13 @@ files = [ [[package]] name = "exceptiongroup" -version = "1.3.0" +version = "1.3.1" description = "Backport of PEP 654 (exception groups)" optional = false python-versions = ">=3.7" files = [ - {file = "exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10"}, - {file = "exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88"}, + {file = "exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598"}, + {file = "exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219"}, ] [package.dependencies] @@ -75,6 +75,20 @@ typing-extensions = {version = ">=4.6.0", markers = "python_version < \"3.13\""} [package.extras] test = ["pytest (>=6)"] +[[package]] +name = "execnet" +version = "2.1.2" +description = "execnet: rapid multi-Python deployment" +optional = false +python-versions = ">=3.8" +files = [ + {file = "execnet-2.1.2-py3-none-any.whl", hash = "sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec"}, + {file = "execnet-2.1.2.tar.gz", hash = "sha256:63d83bfdd9a23e35b9c6a3261412324f964c2ec8dcd8d3c6916ee9373e0befcd"}, +] + +[package.extras] +testing = ["hatch", "pre-commit", "pytest", "tox"] + [[package]] name = "h11" version = "0.16.0" @@ -133,13 +147,13 @@ zstd = ["zstandard (>=0.18.0)"] [[package]] name = "idna" -version = "3.10" +version = "3.11" description = "Internationalized Domain Names in Applications (IDNA)" optional = false -python-versions = ">=3.6" +python-versions = ">=3.8" files = [ - {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"}, - {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"}, + {file = "idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea"}, + {file = "idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902"}, ] [package.extras] @@ -222,13 +236,13 @@ files = [ [[package]] name = "packaging" -version = "25.0" +version = "26.0" description = "Core utilities for Python packages" optional = false python-versions = ">=3.8" files = [ - {file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"}, - {file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"}, + {file = "packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529"}, + {file = "packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4"}, ] [[package]] @@ -418,6 +432,26 @@ pytest = ">=7.0.0,<9" docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1.0)"] testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"] +[[package]] +name = "pytest-xdist" +version = "3.6.1" +description = "pytest xdist plugin for distributed testing, most importantly across multiple CPUs" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pytest_xdist-3.6.1-py3-none-any.whl", hash = "sha256:9ed4adfb68a016610848639bb7e02c9352d5d9f03d04809919e2dafc3be4cca7"}, + {file = "pytest_xdist-3.6.1.tar.gz", hash = "sha256:ead156a4db231eec769737f57668ef58a2084a34b2e55c4a8fa20d861107300d"}, +] + +[package.dependencies] +execnet = ">=2.1" +pytest = ">=7.0.0" + +[package.extras] +psutil = ["psutil (>=3.0)"] +setproctitle = ["setproctitle"] +testing = ["filelock"] + [[package]] name = "python-dateutil" version = "2.9.0.post0" @@ -483,43 +517,58 @@ files = [ [[package]] name = "tomli" -version = "2.2.1" +version = "2.4.0" description = "A lil' TOML parser" optional = false python-versions = ">=3.8" files = [ - {file = "tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249"}, - {file = "tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6"}, - {file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a"}, - {file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee"}, - {file = "tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e"}, - {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4"}, - {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106"}, - {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8"}, - {file = "tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff"}, - {file = "tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b"}, - {file = "tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea"}, - {file = "tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8"}, - {file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192"}, - {file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222"}, - {file = "tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77"}, - {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6"}, - {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd"}, - {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e"}, - {file = "tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98"}, - {file = "tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4"}, - {file = "tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7"}, - {file = "tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c"}, - {file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13"}, - {file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281"}, - {file = "tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272"}, - {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140"}, - {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2"}, - {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744"}, - {file = "tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec"}, - {file = "tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69"}, - {file = "tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc"}, - {file = "tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff"}, + {file = "tomli-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b5ef256a3fd497d4973c11bf142e9ed78b150d36f5773f1ca6088c230ffc5867"}, + {file = "tomli-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5572e41282d5268eb09a697c89a7bee84fae66511f87533a6f88bd2f7b652da9"}, + {file = "tomli-2.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:551e321c6ba03b55676970b47cb1b73f14a0a4dce6a3e1a9458fd6d921d72e95"}, + {file = "tomli-2.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e3f639a7a8f10069d0e15408c0b96a2a828cfdec6fca05296ebcdcc28ca7c76"}, + {file = "tomli-2.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1b168f2731796b045128c45982d3a4874057626da0e2ef1fdd722848b741361d"}, + {file = "tomli-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:133e93646ec4300d651839d382d63edff11d8978be23da4cc106f5a18b7d0576"}, + {file = "tomli-2.4.0-cp311-cp311-win32.whl", hash = "sha256:b6c78bdf37764092d369722d9946cb65b8767bfa4110f902a1b2542d8d173c8a"}, + {file = "tomli-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:d3d1654e11d724760cdb37a3d7691f0be9db5fbdaef59c9f532aabf87006dbaa"}, + {file = "tomli-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:cae9c19ed12d4e8f3ebf46d1a75090e4c0dc16271c5bce1c833ac168f08fb614"}, + {file = "tomli-2.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:920b1de295e72887bafa3ad9f7a792f811847d57ea6b1215154030cf131f16b1"}, + {file = "tomli-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d6d9a4aee98fac3eab4952ad1d73aee87359452d1c086b5ceb43ed02ddb16b8"}, + {file = "tomli-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36b9d05b51e65b254ea6c2585b59d2c4cb91c8a3d91d0ed0f17591a29aaea54a"}, + {file = "tomli-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c8a885b370751837c029ef9bc014f27d80840e48bac415f3412e6593bbc18c1"}, + {file = "tomli-2.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8768715ffc41f0008abe25d808c20c3d990f42b6e2e58305d5da280ae7d1fa3b"}, + {file = "tomli-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b438885858efd5be02a9a133caf5812b8776ee0c969fea02c45e8e3f296ba51"}, + {file = "tomli-2.4.0-cp312-cp312-win32.whl", hash = "sha256:0408e3de5ec77cc7f81960c362543cbbd91ef883e3138e81b729fc3eea5b9729"}, + {file = "tomli-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:685306e2cc7da35be4ee914fd34ab801a6acacb061b6a7abca922aaf9ad368da"}, + {file = "tomli-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:5aa48d7c2356055feef06a43611fc401a07337d5b006be13a30f6c58f869e3c3"}, + {file = "tomli-2.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84d081fbc252d1b6a982e1870660e7330fb8f90f676f6e78b052ad4e64714bf0"}, + {file = "tomli-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9a08144fa4cba33db5255f9b74f0b89888622109bd2776148f2597447f92a94e"}, + {file = "tomli-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c73add4bb52a206fd0c0723432db123c0c75c280cbd67174dd9d2db228ebb1b4"}, + {file = "tomli-2.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fb2945cbe303b1419e2706e711b7113da57b7db31ee378d08712d678a34e51e"}, + {file = "tomli-2.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bbb1b10aa643d973366dc2cb1ad94f99c1726a02343d43cbc011edbfac579e7c"}, + {file = "tomli-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4cbcb367d44a1f0c2be408758b43e1ffb5308abe0ea222897d6bfc8e8281ef2f"}, + {file = "tomli-2.4.0-cp313-cp313-win32.whl", hash = "sha256:7d49c66a7d5e56ac959cb6fc583aff0651094ec071ba9ad43df785abc2320d86"}, + {file = "tomli-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:3cf226acb51d8f1c394c1b310e0e0e61fecdd7adcb78d01e294ac297dd2e7f87"}, + {file = "tomli-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:d20b797a5c1ad80c516e41bc1fb0443ddb5006e9aaa7bda2d71978346aeb9132"}, + {file = "tomli-2.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:26ab906a1eb794cd4e103691daa23d95c6919cc2fa9160000ac02370cc9dd3f6"}, + {file = "tomli-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:20cedb4ee43278bc4f2fee6cb50daec836959aadaf948db5172e776dd3d993fc"}, + {file = "tomli-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:39b0b5d1b6dd03684b3fb276407ebed7090bbec989fa55838c98560c01113b66"}, + {file = "tomli-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a26d7ff68dfdb9f87a016ecfd1e1c2bacbe3108f4e0f8bcd2228ef9a766c787d"}, + {file = "tomli-2.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:20ffd184fb1df76a66e34bd1b36b4a4641bd2b82954befa32fe8163e79f1a702"}, + {file = "tomli-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75c2f8bbddf170e8effc98f5e9084a8751f8174ea6ccf4fca5398436e0320bc8"}, + {file = "tomli-2.4.0-cp314-cp314-win32.whl", hash = "sha256:31d556d079d72db7c584c0627ff3a24c5d3fb4f730221d3444f3efb1b2514776"}, + {file = "tomli-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:43e685b9b2341681907759cf3a04e14d7104b3580f808cfde1dfdb60ada85475"}, + {file = "tomli-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:3d895d56bd3f82ddd6faaff993c275efc2ff38e52322ea264122d72729dca2b2"}, + {file = "tomli-2.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5b5807f3999fb66776dbce568cc9a828544244a8eb84b84b9bafc080c99597b9"}, + {file = "tomli-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c084ad935abe686bd9c898e62a02a19abfc9760b5a79bc29644463eaf2840cb0"}, + {file = "tomli-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f2e3955efea4d1cfbcb87bc321e00dc08d2bcb737fd1d5e398af111d86db5df"}, + {file = "tomli-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e0fe8a0b8312acf3a88077a0802565cb09ee34107813bba1c7cd591fa6cfc8d"}, + {file = "tomli-2.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:413540dce94673591859c4c6f794dfeaa845e98bf35d72ed59636f869ef9f86f"}, + {file = "tomli-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0dc56fef0e2c1c470aeac5b6ca8cc7b640bb93e92d9803ddaf9ea03e198f5b0b"}, + {file = "tomli-2.4.0-cp314-cp314t-win32.whl", hash = "sha256:d878f2a6707cc9d53a1be1414bbb419e629c3d6e67f69230217bb663e76b5087"}, + {file = "tomli-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2add28aacc7425117ff6364fe9e06a183bb0251b03f986df0e78e974047571fd"}, + {file = "tomli-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2b1e3b80e1d5e52e40e9b924ec43d81570f0e7d09d11081b797bc4692765a3d4"}, + {file = "tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a"}, + {file = "tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c"}, ] [[package]] @@ -547,4 +596,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = "^3.8" -content-hash = "8551b871abee465e23fb0966d51f2c155fd257b55bdcb0c02d095de19f92f358" +content-hash = "bcf31a142c86d9e556553c8c260a93b563ac64a043076dbd48b26111d422c26e" diff --git a/pyproject.toml b/pyproject.toml index ba72212..d89ac48 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,9 +1,10 @@ [project] name = "newscatcher-sdk" +dynamic = ["version"] [tool.poetry] name = "newscatcher-sdk" -version = "1.4.0" +version = "1.4.1" description = "" readme = "README.md" authors = [] @@ -18,6 +19,9 @@ classifiers = [ "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Programming Language :: Python :: 3.15", "Operating System :: OS Independent", "Operating System :: POSIX", "Operating System :: MacOS", @@ -31,7 +35,7 @@ packages = [ { include = "newscatcher", from = "src"} ] -[project.urls] +[tool.poetry.urls] Repository = 'https://github.com/Newscatcher/newscatcher-python' [tool.poetry.dependencies] @@ -45,6 +49,7 @@ typing_extensions = ">= 4.0.0" mypy = "==1.13.0" pytest = "^7.4.0" pytest-asyncio = "^0.23.5" +pytest-xdist = "^3.6.1" python-dateutil = "^2.9.0" types-python-dateutil = "^2.9.0.20240316" ruff = "==0.11.5" diff --git a/reference.md b/reference.md index 367099b..ae2b738 100644 --- a/reference.md +++ b/reference.md @@ -1,6 +1,6 @@ # Reference ## Search -
client.search.get(...) +
client.search.get(...) -> AsyncHttpResponse[SearchGetResponse]
@@ -35,20 +35,36 @@ client = NewscatcherApi( api_key="YOUR_API_KEY", ) client.search.get( - q="technology AND (Apple OR Microsoft) NOT Google", + q='"supply chain" AND Amazon NOT China', search_in="title_content, title_content_translated", include_translation_fields=True, predefined_sources="top 100 US, top 5 GB", + source_name="sport", + sources="nytimes.com", + not_sources="cnn.com", + lang="en", + not_lang="fr", + countries="US", + not_countries="UK", + not_author_name="John Doe", from_=datetime.datetime.fromisoformat( "2024-07-01 00:00:00+00:00", ), to=datetime.datetime.fromisoformat( "2024-07-01 00:00:00+00:00", ), + parent_url="https://www.washingtonpost.com/politics", + all_links="https://aiindex.stanford.edu/report", + all_domain_links="nvidia.com", + news_type="General News Outlets", include_nlp_data=True, has_nlp=True, theme="Business,Finance", not_theme="Crime", + org_entity_name="Apple", + per_entity_name="Elon Musk", + loc_entity_name="California", + misc_entity_name="Bitcoin", iptc_tags="20000199,20000209", not_iptc_tags="20000205,20000209", iab_tags="Business,Events", @@ -70,18 +86,7 @@ client.search.get(
-**q:** `str` - -The keyword(s) to search for in articles. Query syntax supports logical operators (`AND`, `OR`, `NOT`) and wildcards: - -- For an exact match, use double quotes. For example, `"technology news"`. -- Use `*` to search for any keyword. -- Use `+` to include and `-` to exclude specific words or phrases. - For example, `+Apple`, `-Google`. -- Use `AND`, `OR`, and `NOT` to refine search results. - For example, `technology AND (Apple OR Microsoft) NOT Google`. - -For more details, see [Advanced querying](/docs/v3/documentation/guides-and-concepts/advanced-querying). +**q:** `Q`
@@ -231,7 +236,7 @@ Example: `"John Doe, Jane Doe"`
-**from_:** `typing.Optional[dt.datetime]` +**from_:** `typing.Optional[SearchGetRequestFrom]` The starting point in time to search from. Accepts date-time strings in ISO 8601 format and plain text. The default time zone is UTC. @@ -250,7 +255,7 @@ Formats with examples:
-**to:** `typing.Optional[dt.datetime]` +**to:** `typing.Optional[SearchGetRequestTo]` The ending point in time to search up to. Accepts date-time strings in ISO 8601 format and plain text. The default time zone is UTC. @@ -825,7 +830,7 @@ To learn more, see [Articles deduplication](/docs/v3/documentation/guides-and-co
-
client.search.post(...) +
client.search.post(...) -> AsyncHttpResponse[SearchPostResponse]
@@ -852,25 +857,14 @@ Searches for articles based on specified criteria such as keyword, language, cou
```python -import datetime - from newscatcher import NewscatcherApi client = NewscatcherApi( api_key="YOUR_API_KEY", ) client.search.post( - q="renewable energy", - predefined_sources=["top 50 US"], - lang=["en"], - from_=datetime.datetime.fromisoformat( - "2024-01-01 00:00:00+00:00", - ), - to=datetime.datetime.fromisoformat( - "2024-06-30 00:00:00+00:00", - ), - additional_domain_info=True, - is_news_domain=True, + q='"supply chain" AND Amazon NOT China', + page_size=1, ) ``` @@ -1348,7 +1342,7 @@ client.search.post(
## LatestHeadlines -
client.latestheadlines.get(...) +
client.latestheadlines.get(...) -> AsyncHttpResponse[LatestHeadlinesGetResponse]
@@ -1381,12 +1375,27 @@ client = NewscatcherApi( api_key="YOUR_API_KEY", ) client.latestheadlines.get( + when="7d", + lang="en", + not_lang="fr", + countries="US", + not_countries="UK", predefined_sources="top 100 US, top 5 GB", + sources="nytimes.com", + not_sources="cnn.com", + not_author_name="John Doe", + parent_url="https://www.washingtonpost.com/politics", + all_links="https://aiindex.stanford.edu/report", + all_domain_links="nvidia.com", include_translation_fields=True, include_nlp_data=True, has_nlp=True, theme="Business,Finance", not_theme="Crime", + org_entity_name="Apple", + per_entity_name="Elon Musk", + loc_entity_name="California", + misc_entity_name="Bitcoin", iptc_tags="20000199,20000209", not_iptc_tags="20000205,20000209", iab_tags="Business,Events", @@ -1996,7 +2005,7 @@ To learn more, see the [Custom tags](/docs/v3/documentation/guides-and-concepts/
-
client.latestheadlines.post(...) +
client.latestheadlines.post(...) -> AsyncHttpResponse[LatestHeadlinesPostResponse]
@@ -2029,10 +2038,8 @@ client = NewscatcherApi( api_key="YOUR_API_KEY", ) client.latestheadlines.post( - lang="en", - predefined_sources=["top 50 US", "top 20 GB"], - is_opinion=False, - page_size=10, + when="7d", + page_size=1, ) ``` @@ -2406,7 +2413,7 @@ client.latestheadlines.post(
## Breaking News -
client.breaking_news.breaking_news_get(...) +
client.breaking_news.breaking_news_get(...) -> AsyncHttpResponse[BreakingNewsResponseDto]
@@ -2445,6 +2452,10 @@ client.breaking_news.breaking_news_get( has_nlp=True, theme="Business,Finance", not_theme="Crime", + org_entity_name="Apple", + per_entity_name="Elon Musk", + loc_entity_name="California", + misc_entity_name="Bitcoin", ) ``` @@ -2744,7 +2755,7 @@ To learn more, see [NLP features](/docs/v3/documentation/guides-and-concepts/nlp
-
client.breaking_news.breaking_news_post(...) +
client.breaking_news.breaking_news_post(...) -> AsyncHttpResponse[BreakingNewsResponseDto]
@@ -2778,9 +2789,8 @@ client = NewscatcherApi( ) client.breaking_news.breaking_news_post( sort_by="relevancy", - page=1, - page_size=100, - include_nlp_data=True, + ranked_only=True, + top_n_articles=1, ) ``` @@ -2978,7 +2988,7 @@ client.breaking_news.breaking_news_post(
## Authors -
client.authors.get(...) +
client.authors.get(...) -> AsyncHttpResponse[AuthorsGetResponse]
@@ -3014,13 +3024,23 @@ client = NewscatcherApi( ) client.authors.get( author_name="Jane Smith", + not_author_name="John Doe", predefined_sources="top 100 US, top 5 GB", + sources="nytimes.com", + not_sources="cnn.com", + lang="en", + not_lang="fr", + countries="US", + not_countries="UK", from_=datetime.datetime.fromisoformat( "2024-07-01 00:00:00+00:00", ), to=datetime.datetime.fromisoformat( "2024-07-01 00:00:00+00:00", ), + parent_url="https://www.washingtonpost.com/politics", + all_links="https://aiindex.stanford.edu/report", + all_domain_links="nvidia.com", include_translation_fields=True, include_nlp_data=True, has_nlp=True, @@ -3168,7 +3188,7 @@ To learn more, see [Enumerated parameters > Country](/docs/v3/api-reference/over
-**from_:** `typing.Optional[dt.datetime]` +**from_:** `typing.Optional[AuthorsGetRequestFrom]` The starting point in time to search from. Accepts date-time strings in ISO 8601 format and plain text. The default time zone is UTC. @@ -3187,7 +3207,7 @@ Formats with examples:
-**to:** `typing.Optional[dt.datetime]` +**to:** `typing.Optional[AuthorsGetRequestTo]` The ending point in time to search up to. Accepts date-time strings in ISO 8601 format and plain text. The default time zone is UTC. @@ -3610,7 +3630,7 @@ To learn more, see the [Custom tags](/docs/v3/documentation/guides-and-concepts/
-
client.authors.post(...) +
client.authors.post(...) -> AsyncHttpResponse[AuthorsPostResponse]
@@ -3637,23 +3657,13 @@ Searches for articles by author. You can filter results by language, country, so
```python -import datetime - from newscatcher import NewscatcherApi client = NewscatcherApi( api_key="YOUR_API_KEY", ) client.authors.post( - author_name="Joanna Stern", - sources=["wsj.com", "nytimes.com"], - lang="en", - from_=datetime.datetime.fromisoformat( - "2024-01-01 00:00:00+00:00", - ), - to=datetime.datetime.fromisoformat( - "2024-06-30 00:00:00+00:00", - ), + author_name="David Muir", ) ``` @@ -4027,7 +4037,7 @@ client.authors.post(
## SearchLink -
client.search_link.search_url_get(...) +
client.search_link.search_url_get(...) -> AsyncHttpResponse[SearchResponseDto]
@@ -4062,6 +4072,9 @@ client = NewscatcherApi( api_key="YOUR_API_KEY", ) client.search_link.search_url_get( + ids="5f8d0d55b6e45e00179c6e7e", + links="https://nytimes.com/article1", + source="articles.id,articles.title,articles.link,articles.published_date", from_=datetime.datetime.fromisoformat( "2024-07-01 00:00:00+00:00", ), @@ -4112,6 +4125,14 @@ Example: `"https://example.com/article1, https://example.com/article2"`
+**source:** `typing.Optional[Source]` + +
+
+ +
+
+ **from_:** `typing.Optional[From]`
@@ -4168,7 +4189,7 @@ For details, see [How to paginate large datasets](https://www.newscatcherapi.com
-
client.search_link.search_url_post(...) +
client.search_link.search_url_post(...) -> AsyncHttpResponse[SearchResponseDto]
@@ -4201,14 +4222,8 @@ client = NewscatcherApi( api_key="YOUR_API_KEY", ) client.search_link.search_url_post( - ids=[ - "8ea8a784568ffaa05cb6d1ab2d2e84dd", - "0146a551ef05ab1c494a55e806e3ce64", - ], - links=[ - "https://www.nytimes.com/2024/08/30/technology/ai-chatbot-chatgpt-manipulation.html", - "https://www.bbc.com/news/articles/c39k379grzlo", - ], + links="https://www.reuters.com/business/energy/oil-prices-up-after-israeli-attacks-oversupply-caps-gains-2025-09-10/", + source="articles.id,articles.title,articles.link,articles.canonical_url", ) ``` @@ -4241,6 +4256,14 @@ client.search_link.search_url_post(
+**source:** `typing.Optional[Source]` + +
+
+ +
+
+ **from_:** `typing.Optional[From]` The starting point in time to search from. Accepts date-time strings in ISO 8601 format and plain text strings. The default time zone is UTC. @@ -4312,7 +4335,7 @@ Formats with examples:
## SearchSimilar -
client.searchsimilar.get(...) +
client.searchsimilar.get(...) -> AsyncHttpResponse[SearchSimilarGetResponse]
@@ -4347,17 +4370,26 @@ client = NewscatcherApi( api_key="YOUR_API_KEY", ) client.searchsimilar.get( - q="technology AND (Apple OR Microsoft) NOT Google", + q='"supply chain" AND Amazon NOT China', search_in="title_content, title_content_translated", include_translation_fields=True, similar_documents_fields="title,summary", predefined_sources="top 100 US, top 5 GB", + sources="nytimes.com", + not_sources="cnn.com", + lang="en", + not_lang="fr", + countries="US", + not_countries="UK", from_=datetime.datetime.fromisoformat( "2024-07-01 00:00:00+00:00", ), to=datetime.datetime.fromisoformat( "2024-07-01 00:00:00+00:00", ), + parent_url="https://www.washingtonpost.com/politics", + all_links="https://aiindex.stanford.edu/report", + all_domain_links="nvidia.com", include_nlp_data=True, has_nlp=True, theme="Business,Finance", @@ -4382,18 +4414,7 @@ client.searchsimilar.get(
-**q:** `str` - -The keyword(s) to search for in articles. Query syntax supports logical operators (`AND`, `OR`, `NOT`) and wildcards: - -- For an exact match, use double quotes. For example, `"technology news"`. -- Use `*` to search for any keyword. -- Use `+` to include and `-` to exclude specific words or phrases. - For example, `+Apple`, `-Google`. -- Use `AND`, `OR`, and `NOT` to refine search results. - For example, `technology AND (Apple OR Microsoft) NOT Google`. - -For more details, see [Advanced querying](/docs/v3/documentation/guides-and-concepts/advanced-querying). +**q:** `Q`
@@ -4541,7 +4562,7 @@ To learn more, see [Enumerated parameters > Country](/docs/v3/api-reference/over
-**from_:** `typing.Optional[dt.datetime]` +**from_:** `typing.Optional[SearchSimilarGetRequestFrom]` The starting point in time to search from. Accepts date-time strings in ISO 8601 format and plain text. The default time zone is UTC. @@ -4560,7 +4581,7 @@ Formats with examples:
-**to:** `typing.Optional[dt.datetime]` +**to:** `typing.Optional[SearchSimilarGetRequestTo]` The ending point in time to search up to. Accepts date-time strings in ISO 8601 format and plain text. The default time zone is UTC. @@ -4943,7 +4964,7 @@ To learn more, see the [Custom tags](/docs/v3/documentation/guides-and-concepts/
-
client.searchsimilar.post(...) +
client.searchsimilar.post(...) -> AsyncHttpResponse[SearchSimilarPostResponse]
@@ -4976,9 +4997,10 @@ client = NewscatcherApi( api_key="YOUR_API_KEY", ) client.searchsimilar.post( - q="artificial intelligence", + q='"supply chain" AND Amazon NOT China', include_similar_documents=True, similar_documents_number=5, + page_size=10, ) ``` @@ -5360,7 +5382,7 @@ client.searchsimilar.post(
## Sources -
client.sources.get(...) +
client.sources.get(...) -> AsyncHttpResponse[SourcesResponseDto]
@@ -5393,8 +5415,12 @@ client = NewscatcherApi( api_key="YOUR_API_KEY", ) client.sources.get( + lang="en", + countries="US", predefined_sources="top 100 US, top 5 GB", + source_name="sport", source_url="bbc.com", + news_type="General News Outlets", ) ``` @@ -5565,7 +5591,7 @@ For a complete list of available news types, see [Enumerated parameters > News t
-
client.sources.post(...) +
client.sources.post(...) -> AsyncHttpResponse[SourcesResponseDto]
@@ -5598,11 +5624,7 @@ client = NewscatcherApi( api_key="YOUR_API_KEY", ) client.sources.post( - predefined_sources=["top 50 US"], - include_additional_info=True, - is_news_domain=True, - news_domain_type="Original Content", - news_type="General News Outlets", + predefined_sources="top 10 US", ) ``` @@ -5720,7 +5742,7 @@ client.sources.post(
## Aggregation -
client.aggregation.get(...) +
client.aggregation.get(...) -> AsyncHttpResponse[AggregationGetResponse]
@@ -5755,19 +5777,33 @@ client = NewscatcherApi( api_key="YOUR_API_KEY", ) client.aggregation.get( - q="technology AND (Apple OR Microsoft) NOT Google", + q='"supply chain" AND Amazon NOT China', search_in="title_content, title_content_translated", predefined_sources="top 100 US, top 5 GB", + sources="nytimes.com", + not_sources="cnn.com", + lang="en", + not_lang="fr", + countries="US", + not_countries="UK", + not_author_name="John Doe", from_=datetime.datetime.fromisoformat( "2024-07-01 00:00:00+00:00", ), to=datetime.datetime.fromisoformat( "2024-07-01 00:00:00+00:00", ), + parent_url="https://www.washingtonpost.com/politics", + all_links="https://aiindex.stanford.edu/report", + all_domain_links="nvidia.com", include_nlp_data=True, has_nlp=True, theme="Business,Finance", not_theme="Crime", + org_entity_name="Apple", + per_entity_name="Elon Musk", + loc_entity_name="California", + misc_entity_name="Bitcoin", iptc_tags="20000199,20000209", not_iptc_tags="20000205,20000209", ) @@ -5786,18 +5822,7 @@ client.aggregation.get(
-**q:** `str` - -The keyword(s) to search for in articles. Query syntax supports logical operators (`AND`, `OR`, `NOT`) and wildcards: - -- For an exact match, use double quotes. For example, `"technology news"`. -- Use `*` to search for any keyword. -- Use `+` to include and `-` to exclude specific words or phrases. - For example, `+Apple`, `-Google`. -- Use `AND`, `OR`, and `NOT` to refine search results. - For example, `technology AND (Apple OR Microsoft) NOT Google`. - -For more details, see [Advanced querying](/docs/v3/documentation/guides-and-concepts/advanced-querying). +**q:** `Q`
@@ -5933,7 +5958,7 @@ Example: `"John Doe, Jane Doe"`
-**from_:** `typing.Optional[dt.datetime]` +**from_:** `typing.Optional[AggregationGetRequestFrom]` The starting point in time to search from. Accepts date-time strings in ISO 8601 format and plain text. The default time zone is UTC. @@ -5952,7 +5977,7 @@ Formats with examples:
-**to:** `typing.Optional[dt.datetime]` +**to:** `typing.Optional[AggregationGetRequestTo]` The ending point in time to search up to. Accepts date-time strings in ISO 8601 format and plain text. The default time zone is UTC. @@ -6371,7 +6396,7 @@ To learn more, see [IPTC Media Topic NewsCodes](https://www.iptc.org/std/NewsCod
-
client.aggregation.post(...) +
client.aggregation.post(...) -> AsyncHttpResponse[AggregationPostResponse]
@@ -6398,23 +6423,14 @@ Retrieves the count of articles aggregated by day or hour based on various searc
```python -import datetime - from newscatcher import NewscatcherApi client = NewscatcherApi( api_key="YOUR_API_KEY", ) client.aggregation.post( - q="renewable energy", + q='"supply chain" AND Amazon NOT China', aggregation_by="day", - predefined_sources="top 50 US", - from_=datetime.datetime.fromisoformat( - "2024-01-01 00:00:00+00:00", - ), - to=datetime.datetime.fromisoformat( - "2024-06-30 00:00:00+00:00", - ), ) ``` @@ -6796,7 +6812,7 @@ client.aggregation.post(
## Subscription -
client.subscription.get() +
client.subscription.get() -> AsyncHttpResponse[SubscriptionResponseDto]
@@ -6856,7 +6872,7 @@ client.subscription.get()
-
client.subscription.post() +
client.subscription.post() -> AsyncHttpResponse[SubscriptionResponseDto]
diff --git a/src/newscatcher/__init__.py b/src/newscatcher/__init__.py index 17df0a5..461cd38 100644 --- a/src/newscatcher/__init__.py +++ b/src/newscatcher/__init__.py @@ -2,170 +2,371 @@ # isort: skip_file -from .types import ( - AdditionalDomainInfo, - AdditionalDomainInfoEntity, - AdditionalSourceInfo, - AggregationBy, - AggregationCountResponseDto, - AggregationItem, - Aggregations, - AllDomainLinks, - AllLinks, - ArticleEntity, - ArticleEntityAllDomainLinks, - ArticleEntityAllLinks, - Articles, - AuthorName, - Authors, - BaseSearchResponseDto, - BreakingNewsArticleEntity, - BreakingNewsArticleEntityAllDomainLinks, - BreakingNewsArticleEntityAllLinks, - BreakingNewsEventEntity, - BreakingNewsResponseDto, - ByParseDate, - ClusterEntity, - ClusteredArticlesDto, - ClusteredSearchResponseDto, - ClusteringEnabled, - ClusteringThreshold, - ClusteringVariable, - ContentSentimentMax, - ContentSentimentMin, - Countries, - CustomTags, - Error, - ExcludeDuplicates, - FailedAggregationCountResponseDto, - FailedAuthorsResponseDto, - FailedSearchSimilarResponseDto, - From, - FromRank, - HasNlp, - IabTags, - Ids, - IncludeAdditionalInfo, - IncludeNlpData, - IncludeSimilarDocuments, - IncludeTranslationFields, - IptcTags, - IsHeadline, - IsNewsDomain, - IsOpinion, - IsPaidContent, - Journalists, - Lang, - Links, - LocEntityName, - MiscEntityName, - NamedEntityList, - NamedEntityListItem, - NerName, - NewsDomainType, - NewsType, - NlpDataEntity, - NotAuthorName, - NotCountries, - NotIabTags, - NotIptcTags, - NotLang, - NotSources, - NotTheme, - OrgEntityName, - Page, - PageSize, - ParentUrl, - PerEntityName, - PredefinedSources, - PublishedDatePrecision, - Q, - RankedOnly, - RobotsCompliant, - SearchIn, - SearchResponseDto, - SearchSimilarResponseDto, - SentimentScores, - SimilarArticleEntity, - SimilarDocument, - SimilarDocumentsFields, - SimilarDocumentsNumber, - SortBy, - SourceInfo, - SourceName, - SourceUrl, - Sources, - SourcesResponseDto, - SourcesResponseDtoSourcesItem, - SubscriptionResponseDto, - Theme, - TimeFrameCount, - TitleSentimentMax, - TitleSentimentMin, - To, - ToRank, - TopNArticles, - UserInputDto, - When, - WordCountMax, - WordCountMin, -) -from .errors import ( - BadRequestError, - ForbiddenError, - InternalServerError, - RequestTimeoutError, - TooManyRequestsError, - UnauthorizedError, - UnprocessableEntityError, -) -from . import ( - aggregation, - authors, - breaking_news, - latestheadlines, - search, - search_link, - searchsimilar, - sources, - subscription, -) -from .aggregation import ( - AggregationGetRequestPublishedDatePrecision, - AggregationGetRequestSortBy, - AggregationGetResponse, - AggregationPostResponse, -) -from .authors import ( - AuthorsGetRequestPublishedDatePrecision, - AuthorsGetRequestSortBy, - AuthorsGetResponse, - AuthorsPostResponse, -) -from .breaking_news import BreakingNewsGetRequestSortBy -from .client import AsyncNewscatcherApi, NewscatcherApi -from .environment import NewscatcherApiEnvironment -from .latestheadlines import ( - LatestHeadlinesGetRequestClusteringVariable, - LatestHeadlinesGetResponse, - LatestHeadlinesPostResponse, -) -from .search import ( - SearchGetRequestClusteringVariable, - SearchGetRequestNewsDomainType, - SearchGetRequestPublishedDatePrecision, - SearchGetRequestSortBy, - SearchGetResponse, - SearchPostResponse, -) -from .searchsimilar import ( - SearchSimilarGetRequestPublishedDatePrecision, - SearchSimilarGetRequestSortBy, - SearchSimilarGetResponse, - SearchSimilarPostResponse, -) -from .sources import SourcesGetRequestNewsDomainType -from .version import __version__ +import typing +from importlib import import_module + +if typing.TYPE_CHECKING: + from .types import ( + AdditionalDomainInfo, + AdditionalDomainInfoEntity, + AdditionalSourceInfo, + AggregationBy, + AggregationCountResponseDto, + AggregationCountResponseDtoAggregations, + AggregationGetRequestFrom, + AggregationGetRequestTo, + AggregationItem, + AllDomainLinks, + AllLinks, + ArticleEntity, + ArticleEntityAllDomainLinks, + ArticleEntityAllLinks, + ArticleEntityAuthors, + ArticleEntityJournalists, + ArticlesDto, + AuthorName, + AuthorsGetRequestFrom, + AuthorsGetRequestTo, + BaseSearchResponseDto, + BreakingNewsArticleEntity, + BreakingNewsArticleEntityAllDomainLinks, + BreakingNewsArticleEntityAllLinks, + BreakingNewsArticleEntityAuthors, + BreakingNewsArticleEntityJournalists, + BreakingNewsEventEntity, + BreakingNewsResponseDto, + ByParseDate, + CanonicalUrl, + ClusterEntity, + ClusteredArticlesDto, + ClusteredSearchResponseDto, + ClusteringEnabled, + ClusteringThreshold, + ClusteringVariable, + ContentSentimentMax, + ContentSentimentMin, + Countries, + CustomTags, + Error, + ExcludeDuplicates, + FailedAggregationCountResponseDto, + FailedAuthorsResponseDto, + FailedSearchSimilarResponseDto, + From, + FromRank, + HasNlp, + IabTags, + Ids, + IncludeAdditionalInfo, + IncludeNlpData, + IncludeSimilarDocuments, + IncludeTranslationFields, + IptcTags, + IsHeadline, + IsNewsDomain, + IsOpinion, + IsPaidContent, + Lang, + Links, + LocEntityName, + MiscEntityName, + NamedEntityList, + NamedEntityListItem, + NerName, + NewsDomainType, + NewsType, + NlpDataEntity, + NotAuthorName, + NotCountries, + NotIabTags, + NotIptcTags, + NotLang, + NotSources, + NotTheme, + OrgEntityName, + Page, + PageSize, + ParentUrl, + PerEntityName, + PredefinedSources, + PublishedDatePrecision, + Q, + RankedOnly, + RobotsCompliant, + SearchGetRequestFrom, + SearchGetRequestTo, + SearchIn, + SearchResponseDto, + SearchSimilarGetRequestFrom, + SearchSimilarGetRequestTo, + SearchSimilarResponseDto, + SentimentScores, + SimilarArticleEntity, + SimilarDocument, + SimilarDocumentsFields, + SimilarDocumentsNumber, + SortBy, + Source, + SourceInfo, + SourceName, + SourceUrl, + Sources, + SourcesResponseDto, + SourcesResponseDtoSourcesItem, + SubscriptionResponseDto, + Theme, + TimeFrameCount, + TitleSentimentMax, + TitleSentimentMin, + To, + ToRank, + TopNArticles, + UserInputDto, + When, + WordCountMax, + WordCountMin, + ) + from .errors import ( + BadRequestError, + ForbiddenError, + InternalServerError, + RequestTimeoutError, + TooManyRequestsError, + UnauthorizedError, + UnprocessableEntityError, + ) + from . import ( + aggregation, + authors, + breaking_news, + latestheadlines, + search, + search_link, + searchsimilar, + sources, + subscription, + ) + from .aggregation import ( + AggregationGetRequestPublishedDatePrecision, + AggregationGetRequestSortBy, + AggregationGetResponse, + AggregationPostResponse, + ) + from .authors import ( + AuthorsGetRequestPublishedDatePrecision, + AuthorsGetRequestSortBy, + AuthorsGetResponse, + AuthorsPostResponse, + ) + from .breaking_news import BreakingNewsGetRequestSortBy + from .client import AsyncNewscatcherApi, NewscatcherApi + from .environment import NewscatcherApiEnvironment + from .latestheadlines import ( + LatestHeadlinesGetRequestClusteringVariable, + LatestHeadlinesGetResponse, + LatestHeadlinesPostResponse, + ) + from .search import ( + SearchGetRequestClusteringVariable, + SearchGetRequestNewsDomainType, + SearchGetRequestPublishedDatePrecision, + SearchGetRequestSortBy, + SearchGetResponse, + SearchPostResponse, + ) + from .searchsimilar import ( + SearchSimilarGetRequestPublishedDatePrecision, + SearchSimilarGetRequestSortBy, + SearchSimilarGetResponse, + SearchSimilarPostResponse, + ) + from .sources import SourcesGetRequestNewsDomainType + from .version import __version__ +_dynamic_imports: typing.Dict[str, str] = { + "AdditionalDomainInfo": ".types", + "AdditionalDomainInfoEntity": ".types", + "AdditionalSourceInfo": ".types", + "AggregationBy": ".types", + "AggregationCountResponseDto": ".types", + "AggregationCountResponseDtoAggregations": ".types", + "AggregationGetRequestFrom": ".types", + "AggregationGetRequestPublishedDatePrecision": ".aggregation", + "AggregationGetRequestSortBy": ".aggregation", + "AggregationGetRequestTo": ".types", + "AggregationGetResponse": ".aggregation", + "AggregationItem": ".types", + "AggregationPostResponse": ".aggregation", + "AllDomainLinks": ".types", + "AllLinks": ".types", + "ArticleEntity": ".types", + "ArticleEntityAllDomainLinks": ".types", + "ArticleEntityAllLinks": ".types", + "ArticleEntityAuthors": ".types", + "ArticleEntityJournalists": ".types", + "ArticlesDto": ".types", + "AsyncNewscatcherApi": ".client", + "AuthorName": ".types", + "AuthorsGetRequestFrom": ".types", + "AuthorsGetRequestPublishedDatePrecision": ".authors", + "AuthorsGetRequestSortBy": ".authors", + "AuthorsGetRequestTo": ".types", + "AuthorsGetResponse": ".authors", + "AuthorsPostResponse": ".authors", + "BadRequestError": ".errors", + "BaseSearchResponseDto": ".types", + "BreakingNewsArticleEntity": ".types", + "BreakingNewsArticleEntityAllDomainLinks": ".types", + "BreakingNewsArticleEntityAllLinks": ".types", + "BreakingNewsArticleEntityAuthors": ".types", + "BreakingNewsArticleEntityJournalists": ".types", + "BreakingNewsEventEntity": ".types", + "BreakingNewsGetRequestSortBy": ".breaking_news", + "BreakingNewsResponseDto": ".types", + "ByParseDate": ".types", + "CanonicalUrl": ".types", + "ClusterEntity": ".types", + "ClusteredArticlesDto": ".types", + "ClusteredSearchResponseDto": ".types", + "ClusteringEnabled": ".types", + "ClusteringThreshold": ".types", + "ClusteringVariable": ".types", + "ContentSentimentMax": ".types", + "ContentSentimentMin": ".types", + "Countries": ".types", + "CustomTags": ".types", + "Error": ".types", + "ExcludeDuplicates": ".types", + "FailedAggregationCountResponseDto": ".types", + "FailedAuthorsResponseDto": ".types", + "FailedSearchSimilarResponseDto": ".types", + "ForbiddenError": ".errors", + "From": ".types", + "FromRank": ".types", + "HasNlp": ".types", + "IabTags": ".types", + "Ids": ".types", + "IncludeAdditionalInfo": ".types", + "IncludeNlpData": ".types", + "IncludeSimilarDocuments": ".types", + "IncludeTranslationFields": ".types", + "InternalServerError": ".errors", + "IptcTags": ".types", + "IsHeadline": ".types", + "IsNewsDomain": ".types", + "IsOpinion": ".types", + "IsPaidContent": ".types", + "Lang": ".types", + "LatestHeadlinesGetRequestClusteringVariable": ".latestheadlines", + "LatestHeadlinesGetResponse": ".latestheadlines", + "LatestHeadlinesPostResponse": ".latestheadlines", + "Links": ".types", + "LocEntityName": ".types", + "MiscEntityName": ".types", + "NamedEntityList": ".types", + "NamedEntityListItem": ".types", + "NerName": ".types", + "NewsDomainType": ".types", + "NewsType": ".types", + "NewscatcherApi": ".client", + "NewscatcherApiEnvironment": ".environment", + "NlpDataEntity": ".types", + "NotAuthorName": ".types", + "NotCountries": ".types", + "NotIabTags": ".types", + "NotIptcTags": ".types", + "NotLang": ".types", + "NotSources": ".types", + "NotTheme": ".types", + "OrgEntityName": ".types", + "Page": ".types", + "PageSize": ".types", + "ParentUrl": ".types", + "PerEntityName": ".types", + "PredefinedSources": ".types", + "PublishedDatePrecision": ".types", + "Q": ".types", + "RankedOnly": ".types", + "RequestTimeoutError": ".errors", + "RobotsCompliant": ".types", + "SearchGetRequestClusteringVariable": ".search", + "SearchGetRequestFrom": ".types", + "SearchGetRequestNewsDomainType": ".search", + "SearchGetRequestPublishedDatePrecision": ".search", + "SearchGetRequestSortBy": ".search", + "SearchGetRequestTo": ".types", + "SearchGetResponse": ".search", + "SearchIn": ".types", + "SearchPostResponse": ".search", + "SearchResponseDto": ".types", + "SearchSimilarGetRequestFrom": ".types", + "SearchSimilarGetRequestPublishedDatePrecision": ".searchsimilar", + "SearchSimilarGetRequestSortBy": ".searchsimilar", + "SearchSimilarGetRequestTo": ".types", + "SearchSimilarGetResponse": ".searchsimilar", + "SearchSimilarPostResponse": ".searchsimilar", + "SearchSimilarResponseDto": ".types", + "SentimentScores": ".types", + "SimilarArticleEntity": ".types", + "SimilarDocument": ".types", + "SimilarDocumentsFields": ".types", + "SimilarDocumentsNumber": ".types", + "SortBy": ".types", + "Source": ".types", + "SourceInfo": ".types", + "SourceName": ".types", + "SourceUrl": ".types", + "Sources": ".types", + "SourcesGetRequestNewsDomainType": ".sources", + "SourcesResponseDto": ".types", + "SourcesResponseDtoSourcesItem": ".types", + "SubscriptionResponseDto": ".types", + "Theme": ".types", + "TimeFrameCount": ".types", + "TitleSentimentMax": ".types", + "TitleSentimentMin": ".types", + "To": ".types", + "ToRank": ".types", + "TooManyRequestsError": ".errors", + "TopNArticles": ".types", + "UnauthorizedError": ".errors", + "UnprocessableEntityError": ".errors", + "UserInputDto": ".types", + "When": ".types", + "WordCountMax": ".types", + "WordCountMin": ".types", + "__version__": ".version", + "aggregation": ".aggregation", + "authors": ".authors", + "breaking_news": ".breaking_news", + "latestheadlines": ".latestheadlines", + "search": ".search", + "search_link": ".search_link", + "searchsimilar": ".searchsimilar", + "sources": ".sources", + "subscription": ".subscription", +} + + +def __getattr__(attr_name: str) -> typing.Any: + module_name = _dynamic_imports.get(attr_name) + if module_name is None: + raise AttributeError(f"No {attr_name} found in _dynamic_imports for module name -> {__name__}") + try: + module = import_module(module_name, __package__) + if module_name == f".{attr_name}": + return module + else: + return getattr(module, attr_name) + except ImportError as e: + raise ImportError(f"Failed to import {attr_name} from {module_name}: {e}") from e + except AttributeError as e: + raise AttributeError(f"Failed to get {attr_name} from {module_name}: {e}") from e + + +def __dir__(): + lazy_attrs = list(_dynamic_imports.keys()) + return sorted(lazy_attrs) + __all__ = [ "AdditionalDomainInfo", @@ -173,23 +374,28 @@ "AdditionalSourceInfo", "AggregationBy", "AggregationCountResponseDto", + "AggregationCountResponseDtoAggregations", + "AggregationGetRequestFrom", "AggregationGetRequestPublishedDatePrecision", "AggregationGetRequestSortBy", + "AggregationGetRequestTo", "AggregationGetResponse", "AggregationItem", "AggregationPostResponse", - "Aggregations", "AllDomainLinks", "AllLinks", "ArticleEntity", "ArticleEntityAllDomainLinks", "ArticleEntityAllLinks", - "Articles", + "ArticleEntityAuthors", + "ArticleEntityJournalists", + "ArticlesDto", "AsyncNewscatcherApi", "AuthorName", - "Authors", + "AuthorsGetRequestFrom", "AuthorsGetRequestPublishedDatePrecision", "AuthorsGetRequestSortBy", + "AuthorsGetRequestTo", "AuthorsGetResponse", "AuthorsPostResponse", "BadRequestError", @@ -197,10 +403,13 @@ "BreakingNewsArticleEntity", "BreakingNewsArticleEntityAllDomainLinks", "BreakingNewsArticleEntityAllLinks", + "BreakingNewsArticleEntityAuthors", + "BreakingNewsArticleEntityJournalists", "BreakingNewsEventEntity", "BreakingNewsGetRequestSortBy", "BreakingNewsResponseDto", "ByParseDate", + "CanonicalUrl", "ClusterEntity", "ClusteredArticlesDto", "ClusteredSearchResponseDto", @@ -232,7 +441,6 @@ "IsNewsDomain", "IsOpinion", "IsPaidContent", - "Journalists", "Lang", "LatestHeadlinesGetRequestClusteringVariable", "LatestHeadlinesGetResponse", @@ -267,15 +475,19 @@ "RequestTimeoutError", "RobotsCompliant", "SearchGetRequestClusteringVariable", + "SearchGetRequestFrom", "SearchGetRequestNewsDomainType", "SearchGetRequestPublishedDatePrecision", "SearchGetRequestSortBy", + "SearchGetRequestTo", "SearchGetResponse", "SearchIn", "SearchPostResponse", "SearchResponseDto", + "SearchSimilarGetRequestFrom", "SearchSimilarGetRequestPublishedDatePrecision", "SearchSimilarGetRequestSortBy", + "SearchSimilarGetRequestTo", "SearchSimilarGetResponse", "SearchSimilarPostResponse", "SearchSimilarResponseDto", @@ -285,6 +497,7 @@ "SimilarDocumentsFields", "SimilarDocumentsNumber", "SortBy", + "Source", "SourceInfo", "SourceName", "SourceUrl", diff --git a/src/newscatcher/aggregation/__init__.py b/src/newscatcher/aggregation/__init__.py index c4d8649..3e3f1cc 100644 --- a/src/newscatcher/aggregation/__init__.py +++ b/src/newscatcher/aggregation/__init__.py @@ -2,12 +2,44 @@ # isort: skip_file -from .types import ( - AggregationGetRequestPublishedDatePrecision, - AggregationGetRequestSortBy, - AggregationGetResponse, - AggregationPostResponse, -) +import typing +from importlib import import_module + +if typing.TYPE_CHECKING: + from .types import ( + AggregationGetRequestPublishedDatePrecision, + AggregationGetRequestSortBy, + AggregationGetResponse, + AggregationPostResponse, + ) +_dynamic_imports: typing.Dict[str, str] = { + "AggregationGetRequestPublishedDatePrecision": ".types", + "AggregationGetRequestSortBy": ".types", + "AggregationGetResponse": ".types", + "AggregationPostResponse": ".types", +} + + +def __getattr__(attr_name: str) -> typing.Any: + module_name = _dynamic_imports.get(attr_name) + if module_name is None: + raise AttributeError(f"No {attr_name} found in _dynamic_imports for module name -> {__name__}") + try: + module = import_module(module_name, __package__) + if module_name == f".{attr_name}": + return module + else: + return getattr(module, attr_name) + except ImportError as e: + raise ImportError(f"Failed to import {attr_name} from {module_name}: {e}") from e + except AttributeError as e: + raise AttributeError(f"Failed to get {attr_name} from {module_name}: {e}") from e + + +def __dir__(): + lazy_attrs = list(_dynamic_imports.keys()) + return sorted(lazy_attrs) + __all__ = [ "AggregationGetRequestPublishedDatePrecision", diff --git a/src/newscatcher/aggregation/client.py b/src/newscatcher/aggregation/client.py index a512b41..c0ce14e 100644 --- a/src/newscatcher/aggregation/client.py +++ b/src/newscatcher/aggregation/client.py @@ -1,11 +1,12 @@ # This file was auto-generated by Fern from our API Definition. -import datetime as dt import typing from ..core.client_wrapper import AsyncClientWrapper, SyncClientWrapper from ..core.request_options import RequestOptions from ..types.aggregation_by import AggregationBy +from ..types.aggregation_get_request_from import AggregationGetRequestFrom +from ..types.aggregation_get_request_to import AggregationGetRequestTo from ..types.all_domain_links import AllDomainLinks from ..types.all_links import AllLinks from ..types.by_parse_date import ByParseDate @@ -77,7 +78,7 @@ def with_raw_response(self) -> RawAggregationClient: def get( self, *, - q: str, + q: Q, aggregation_by: typing.Optional[AggregationBy] = None, search_in: typing.Optional[SearchIn] = None, predefined_sources: typing.Optional[str] = None, @@ -88,8 +89,8 @@ def get( countries: typing.Optional[str] = None, not_countries: typing.Optional[str] = None, not_author_name: typing.Optional[str] = None, - from_: typing.Optional[dt.datetime] = None, - to: typing.Optional[dt.datetime] = None, + from_: typing.Optional[AggregationGetRequestFrom] = None, + to: typing.Optional[AggregationGetRequestTo] = None, published_date_precision: typing.Optional[AggregationGetRequestPublishedDatePrecision] = None, by_parse_date: typing.Optional[bool] = None, sort_by: typing.Optional[AggregationGetRequestSortBy] = None, @@ -128,17 +129,7 @@ def get( Parameters ---------- - q : str - The keyword(s) to search for in articles. Query syntax supports logical operators (`AND`, `OR`, `NOT`) and wildcards: - - - For an exact match, use double quotes. For example, `"technology news"`. - - Use `*` to search for any keyword. - - Use `+` to include and `-` to exclude specific words or phrases. - For example, `+Apple`, `-Google`. - - Use `AND`, `OR`, and `NOT` to refine search results. - For example, `technology AND (Apple OR Microsoft) NOT Google`. - - For more details, see [Advanced querying](/docs/v3/documentation/guides-and-concepts/advanced-querying). + q : Q aggregation_by : typing.Optional[AggregationBy] @@ -200,7 +191,7 @@ def get( Example: `"John Doe, Jane Doe"` - from_ : typing.Optional[dt.datetime] + from_ : typing.Optional[AggregationGetRequestFrom] The starting point in time to search from. Accepts date-time strings in ISO 8601 format and plain text. The default time zone is UTC. Formats with examples: @@ -212,7 +203,7 @@ def get( **Note**: By default, applied to the publication date of the article. To use the article's parse date instead, set the `by_parse_date` parameter to `true`. - to : typing.Optional[dt.datetime] + to : typing.Optional[AggregationGetRequestTo] The ending point in time to search up to. Accepts date-time strings in ISO 8601 format and plain text. The default time zone is UTC. Formats with examples: @@ -437,19 +428,33 @@ def get( api_key="YOUR_API_KEY", ) client.aggregation.get( - q="technology AND (Apple OR Microsoft) NOT Google", + q='"supply chain" AND Amazon NOT China', search_in="title_content, title_content_translated", predefined_sources="top 100 US, top 5 GB", + sources="nytimes.com", + not_sources="cnn.com", + lang="en", + not_lang="fr", + countries="US", + not_countries="UK", + not_author_name="John Doe", from_=datetime.datetime.fromisoformat( "2024-07-01 00:00:00+00:00", ), to=datetime.datetime.fromisoformat( "2024-07-01 00:00:00+00:00", ), + parent_url="https://www.washingtonpost.com/politics", + all_links="https://aiindex.stanford.edu/report", + all_domain_links="nvidia.com", include_nlp_data=True, has_nlp=True, theme="Business,Finance", not_theme="Crime", + org_entity_name="Apple", + per_entity_name="Elon Musk", + loc_entity_name="California", + misc_entity_name="Bitcoin", iptc_tags="20000199,20000209", not_iptc_tags="20000205,20000209", ) @@ -655,23 +660,14 @@ def post( Examples -------- - import datetime - from newscatcher import NewscatcherApi client = NewscatcherApi( api_key="YOUR_API_KEY", ) client.aggregation.post( - q="renewable energy", + q='"supply chain" AND Amazon NOT China', aggregation_by="day", - predefined_sources="top 50 US", - from_=datetime.datetime.fromisoformat( - "2024-01-01 00:00:00+00:00", - ), - to=datetime.datetime.fromisoformat( - "2024-06-30 00:00:00+00:00", - ), ) """ _response = self._raw_client.post( @@ -742,7 +738,7 @@ def with_raw_response(self) -> AsyncRawAggregationClient: async def get( self, *, - q: str, + q: Q, aggregation_by: typing.Optional[AggregationBy] = None, search_in: typing.Optional[SearchIn] = None, predefined_sources: typing.Optional[str] = None, @@ -753,8 +749,8 @@ async def get( countries: typing.Optional[str] = None, not_countries: typing.Optional[str] = None, not_author_name: typing.Optional[str] = None, - from_: typing.Optional[dt.datetime] = None, - to: typing.Optional[dt.datetime] = None, + from_: typing.Optional[AggregationGetRequestFrom] = None, + to: typing.Optional[AggregationGetRequestTo] = None, published_date_precision: typing.Optional[AggregationGetRequestPublishedDatePrecision] = None, by_parse_date: typing.Optional[bool] = None, sort_by: typing.Optional[AggregationGetRequestSortBy] = None, @@ -793,17 +789,7 @@ async def get( Parameters ---------- - q : str - The keyword(s) to search for in articles. Query syntax supports logical operators (`AND`, `OR`, `NOT`) and wildcards: - - - For an exact match, use double quotes. For example, `"technology news"`. - - Use `*` to search for any keyword. - - Use `+` to include and `-` to exclude specific words or phrases. - For example, `+Apple`, `-Google`. - - Use `AND`, `OR`, and `NOT` to refine search results. - For example, `technology AND (Apple OR Microsoft) NOT Google`. - - For more details, see [Advanced querying](/docs/v3/documentation/guides-and-concepts/advanced-querying). + q : Q aggregation_by : typing.Optional[AggregationBy] @@ -865,7 +851,7 @@ async def get( Example: `"John Doe, Jane Doe"` - from_ : typing.Optional[dt.datetime] + from_ : typing.Optional[AggregationGetRequestFrom] The starting point in time to search from. Accepts date-time strings in ISO 8601 format and plain text. The default time zone is UTC. Formats with examples: @@ -877,7 +863,7 @@ async def get( **Note**: By default, applied to the publication date of the article. To use the article's parse date instead, set the `by_parse_date` parameter to `true`. - to : typing.Optional[dt.datetime] + to : typing.Optional[AggregationGetRequestTo] The ending point in time to search up to. Accepts date-time strings in ISO 8601 format and plain text. The default time zone is UTC. Formats with examples: @@ -1106,19 +1092,33 @@ async def get( async def main() -> None: await client.aggregation.get( - q="technology AND (Apple OR Microsoft) NOT Google", + q='"supply chain" AND Amazon NOT China', search_in="title_content, title_content_translated", predefined_sources="top 100 US, top 5 GB", + sources="nytimes.com", + not_sources="cnn.com", + lang="en", + not_lang="fr", + countries="US", + not_countries="UK", + not_author_name="John Doe", from_=datetime.datetime.fromisoformat( "2024-07-01 00:00:00+00:00", ), to=datetime.datetime.fromisoformat( "2024-07-01 00:00:00+00:00", ), + parent_url="https://www.washingtonpost.com/politics", + all_links="https://aiindex.stanford.edu/report", + all_domain_links="nvidia.com", include_nlp_data=True, has_nlp=True, theme="Business,Finance", not_theme="Crime", + org_entity_name="Apple", + per_entity_name="Elon Musk", + loc_entity_name="California", + misc_entity_name="Bitcoin", iptc_tags="20000199,20000209", not_iptc_tags="20000205,20000209", ) @@ -1328,7 +1328,6 @@ async def post( Examples -------- import asyncio - import datetime from newscatcher import AsyncNewscatcherApi @@ -1339,15 +1338,8 @@ async def post( async def main() -> None: await client.aggregation.post( - q="renewable energy", + q='"supply chain" AND Amazon NOT China', aggregation_by="day", - predefined_sources="top 50 US", - from_=datetime.datetime.fromisoformat( - "2024-01-01 00:00:00+00:00", - ), - to=datetime.datetime.fromisoformat( - "2024-06-30 00:00:00+00:00", - ), ) diff --git a/src/newscatcher/aggregation/raw_client.py b/src/newscatcher/aggregation/raw_client.py index 66a8064..1547eeb 100644 --- a/src/newscatcher/aggregation/raw_client.py +++ b/src/newscatcher/aggregation/raw_client.py @@ -1,12 +1,10 @@ # This file was auto-generated by Fern from our API Definition. -import datetime as dt import typing from json.decoder import JSONDecodeError from ..core.api_error import ApiError from ..core.client_wrapper import AsyncClientWrapper, SyncClientWrapper -from ..core.datetime_utils import serialize_datetime from ..core.http_response import AsyncHttpResponse, HttpResponse from ..core.pydantic_utilities import parse_obj_as from ..core.request_options import RequestOptions @@ -19,6 +17,8 @@ from ..errors.unauthorized_error import UnauthorizedError from ..errors.unprocessable_entity_error import UnprocessableEntityError from ..types.aggregation_by import AggregationBy +from ..types.aggregation_get_request_from import AggregationGetRequestFrom +from ..types.aggregation_get_request_to import AggregationGetRequestTo from ..types.all_domain_links import AllDomainLinks from ..types.all_links import AllLinks from ..types.by_parse_date import ByParseDate @@ -79,7 +79,7 @@ def __init__(self, *, client_wrapper: SyncClientWrapper): def get( self, *, - q: str, + q: Q, aggregation_by: typing.Optional[AggregationBy] = None, search_in: typing.Optional[SearchIn] = None, predefined_sources: typing.Optional[str] = None, @@ -90,8 +90,8 @@ def get( countries: typing.Optional[str] = None, not_countries: typing.Optional[str] = None, not_author_name: typing.Optional[str] = None, - from_: typing.Optional[dt.datetime] = None, - to: typing.Optional[dt.datetime] = None, + from_: typing.Optional[AggregationGetRequestFrom] = None, + to: typing.Optional[AggregationGetRequestTo] = None, published_date_precision: typing.Optional[AggregationGetRequestPublishedDatePrecision] = None, by_parse_date: typing.Optional[bool] = None, sort_by: typing.Optional[AggregationGetRequestSortBy] = None, @@ -130,17 +130,7 @@ def get( Parameters ---------- - q : str - The keyword(s) to search for in articles. Query syntax supports logical operators (`AND`, `OR`, `NOT`) and wildcards: - - - For an exact match, use double quotes. For example, `"technology news"`. - - Use `*` to search for any keyword. - - Use `+` to include and `-` to exclude specific words or phrases. - For example, `+Apple`, `-Google`. - - Use `AND`, `OR`, and `NOT` to refine search results. - For example, `technology AND (Apple OR Microsoft) NOT Google`. - - For more details, see [Advanced querying](/docs/v3/documentation/guides-and-concepts/advanced-querying). + q : Q aggregation_by : typing.Optional[AggregationBy] @@ -202,7 +192,7 @@ def get( Example: `"John Doe, Jane Doe"` - from_ : typing.Optional[dt.datetime] + from_ : typing.Optional[AggregationGetRequestFrom] The starting point in time to search from. Accepts date-time strings in ISO 8601 format and plain text. The default time zone is UTC. Formats with examples: @@ -214,7 +204,7 @@ def get( **Note**: By default, applied to the publication date of the article. To use the article's parse date instead, set the `by_parse_date` parameter to `true`. - to : typing.Optional[dt.datetime] + to : typing.Optional[AggregationGetRequestTo] The ending point in time to search up to. Accepts date-time strings in ISO 8601 format and plain text. The default time zone is UTC. Formats with examples: @@ -444,8 +434,12 @@ def get( "countries": countries, "not_countries": not_countries, "not_author_name": not_author_name, - "from_": serialize_datetime(from_) if from_ is not None else None, - "to_": serialize_datetime(to) if to is not None else None, + "from_": convert_and_respect_annotation_metadata( + object_=from_, annotation=AggregationGetRequestFrom, direction="write" + ), + "to_": convert_and_respect_annotation_metadata( + object_=to, annotation=AggregationGetRequestTo, direction="write" + ), "published_date_precision": published_date_precision, "by_parse_date": by_parse_date, "sort_by": sort_by, @@ -903,7 +897,7 @@ def __init__(self, *, client_wrapper: AsyncClientWrapper): async def get( self, *, - q: str, + q: Q, aggregation_by: typing.Optional[AggregationBy] = None, search_in: typing.Optional[SearchIn] = None, predefined_sources: typing.Optional[str] = None, @@ -914,8 +908,8 @@ async def get( countries: typing.Optional[str] = None, not_countries: typing.Optional[str] = None, not_author_name: typing.Optional[str] = None, - from_: typing.Optional[dt.datetime] = None, - to: typing.Optional[dt.datetime] = None, + from_: typing.Optional[AggregationGetRequestFrom] = None, + to: typing.Optional[AggregationGetRequestTo] = None, published_date_precision: typing.Optional[AggregationGetRequestPublishedDatePrecision] = None, by_parse_date: typing.Optional[bool] = None, sort_by: typing.Optional[AggregationGetRequestSortBy] = None, @@ -954,17 +948,7 @@ async def get( Parameters ---------- - q : str - The keyword(s) to search for in articles. Query syntax supports logical operators (`AND`, `OR`, `NOT`) and wildcards: - - - For an exact match, use double quotes. For example, `"technology news"`. - - Use `*` to search for any keyword. - - Use `+` to include and `-` to exclude specific words or phrases. - For example, `+Apple`, `-Google`. - - Use `AND`, `OR`, and `NOT` to refine search results. - For example, `technology AND (Apple OR Microsoft) NOT Google`. - - For more details, see [Advanced querying](/docs/v3/documentation/guides-and-concepts/advanced-querying). + q : Q aggregation_by : typing.Optional[AggregationBy] @@ -1026,7 +1010,7 @@ async def get( Example: `"John Doe, Jane Doe"` - from_ : typing.Optional[dt.datetime] + from_ : typing.Optional[AggregationGetRequestFrom] The starting point in time to search from. Accepts date-time strings in ISO 8601 format and plain text. The default time zone is UTC. Formats with examples: @@ -1038,7 +1022,7 @@ async def get( **Note**: By default, applied to the publication date of the article. To use the article's parse date instead, set the `by_parse_date` parameter to `true`. - to : typing.Optional[dt.datetime] + to : typing.Optional[AggregationGetRequestTo] The ending point in time to search up to. Accepts date-time strings in ISO 8601 format and plain text. The default time zone is UTC. Formats with examples: @@ -1268,8 +1252,12 @@ async def get( "countries": countries, "not_countries": not_countries, "not_author_name": not_author_name, - "from_": serialize_datetime(from_) if from_ is not None else None, - "to_": serialize_datetime(to) if to is not None else None, + "from_": convert_and_respect_annotation_metadata( + object_=from_, annotation=AggregationGetRequestFrom, direction="write" + ), + "to_": convert_and_respect_annotation_metadata( + object_=to, annotation=AggregationGetRequestTo, direction="write" + ), "published_date_precision": published_date_precision, "by_parse_date": by_parse_date, "sort_by": sort_by, diff --git a/src/newscatcher/aggregation/types/__init__.py b/src/newscatcher/aggregation/types/__init__.py index 324ce07..d99ba3c 100644 --- a/src/newscatcher/aggregation/types/__init__.py +++ b/src/newscatcher/aggregation/types/__init__.py @@ -2,10 +2,42 @@ # isort: skip_file -from .aggregation_get_request_published_date_precision import AggregationGetRequestPublishedDatePrecision -from .aggregation_get_request_sort_by import AggregationGetRequestSortBy -from .aggregation_get_response import AggregationGetResponse -from .aggregation_post_response import AggregationPostResponse +import typing +from importlib import import_module + +if typing.TYPE_CHECKING: + from .aggregation_get_request_published_date_precision import AggregationGetRequestPublishedDatePrecision + from .aggregation_get_request_sort_by import AggregationGetRequestSortBy + from .aggregation_get_response import AggregationGetResponse + from .aggregation_post_response import AggregationPostResponse +_dynamic_imports: typing.Dict[str, str] = { + "AggregationGetRequestPublishedDatePrecision": ".aggregation_get_request_published_date_precision", + "AggregationGetRequestSortBy": ".aggregation_get_request_sort_by", + "AggregationGetResponse": ".aggregation_get_response", + "AggregationPostResponse": ".aggregation_post_response", +} + + +def __getattr__(attr_name: str) -> typing.Any: + module_name = _dynamic_imports.get(attr_name) + if module_name is None: + raise AttributeError(f"No {attr_name} found in _dynamic_imports for module name -> {__name__}") + try: + module = import_module(module_name, __package__) + if module_name == f".{attr_name}": + return module + else: + return getattr(module, attr_name) + except ImportError as e: + raise ImportError(f"Failed to import {attr_name} from {module_name}: {e}") from e + except AttributeError as e: + raise AttributeError(f"Failed to get {attr_name} from {module_name}: {e}") from e + + +def __dir__(): + lazy_attrs = list(_dynamic_imports.keys()) + return sorted(lazy_attrs) + __all__ = [ "AggregationGetRequestPublishedDatePrecision", diff --git a/src/newscatcher/authors/__init__.py b/src/newscatcher/authors/__init__.py index 420f07c..7284e04 100644 --- a/src/newscatcher/authors/__init__.py +++ b/src/newscatcher/authors/__init__.py @@ -2,12 +2,44 @@ # isort: skip_file -from .types import ( - AuthorsGetRequestPublishedDatePrecision, - AuthorsGetRequestSortBy, - AuthorsGetResponse, - AuthorsPostResponse, -) +import typing +from importlib import import_module + +if typing.TYPE_CHECKING: + from .types import ( + AuthorsGetRequestPublishedDatePrecision, + AuthorsGetRequestSortBy, + AuthorsGetResponse, + AuthorsPostResponse, + ) +_dynamic_imports: typing.Dict[str, str] = { + "AuthorsGetRequestPublishedDatePrecision": ".types", + "AuthorsGetRequestSortBy": ".types", + "AuthorsGetResponse": ".types", + "AuthorsPostResponse": ".types", +} + + +def __getattr__(attr_name: str) -> typing.Any: + module_name = _dynamic_imports.get(attr_name) + if module_name is None: + raise AttributeError(f"No {attr_name} found in _dynamic_imports for module name -> {__name__}") + try: + module = import_module(module_name, __package__) + if module_name == f".{attr_name}": + return module + else: + return getattr(module, attr_name) + except ImportError as e: + raise ImportError(f"Failed to import {attr_name} from {module_name}: {e}") from e + except AttributeError as e: + raise AttributeError(f"Failed to get {attr_name} from {module_name}: {e}") from e + + +def __dir__(): + lazy_attrs = list(_dynamic_imports.keys()) + return sorted(lazy_attrs) + __all__ = [ "AuthorsGetRequestPublishedDatePrecision", diff --git a/src/newscatcher/authors/client.py b/src/newscatcher/authors/client.py index c2a1c65..62e03ec 100644 --- a/src/newscatcher/authors/client.py +++ b/src/newscatcher/authors/client.py @@ -1,6 +1,5 @@ # This file was auto-generated by Fern from our API Definition. -import datetime as dt import typing from ..core.client_wrapper import AsyncClientWrapper, SyncClientWrapper @@ -8,6 +7,8 @@ from ..types.all_domain_links import AllDomainLinks from ..types.all_links import AllLinks from ..types.author_name import AuthorName +from ..types.authors_get_request_from import AuthorsGetRequestFrom +from ..types.authors_get_request_to import AuthorsGetRequestTo from ..types.by_parse_date import ByParseDate from ..types.content_sentiment_max import ContentSentimentMax from ..types.content_sentiment_min import ContentSentimentMin @@ -85,8 +86,8 @@ def get( not_lang: typing.Optional[str] = None, countries: typing.Optional[str] = None, not_countries: typing.Optional[str] = None, - from_: typing.Optional[dt.datetime] = None, - to: typing.Optional[dt.datetime] = None, + from_: typing.Optional[AuthorsGetRequestFrom] = None, + to: typing.Optional[AuthorsGetRequestTo] = None, published_date_precision: typing.Optional[AuthorsGetRequestPublishedDatePrecision] = None, by_parse_date: typing.Optional[bool] = None, sort_by: typing.Optional[AuthorsGetRequestSortBy] = None, @@ -185,7 +186,7 @@ def get( To learn more, see [Enumerated parameters > Country](/docs/v3/api-reference/overview/enumerated-parameters#country-country-and-not-country). - from_ : typing.Optional[dt.datetime] + from_ : typing.Optional[AuthorsGetRequestFrom] The starting point in time to search from. Accepts date-time strings in ISO 8601 format and plain text. The default time zone is UTC. Formats with examples: @@ -197,7 +198,7 @@ def get( **Note**: By default, applied to the publication date of the article. To use the article's parse date instead, set the `by_parse_date` parameter to `true`. - to : typing.Optional[dt.datetime] + to : typing.Optional[AuthorsGetRequestTo] The ending point in time to search up to. Accepts date-time strings in ISO 8601 format and plain text. The default time zone is UTC. Formats with examples: @@ -421,13 +422,23 @@ def get( ) client.authors.get( author_name="Jane Smith", + not_author_name="John Doe", predefined_sources="top 100 US, top 5 GB", + sources="nytimes.com", + not_sources="cnn.com", + lang="en", + not_lang="fr", + countries="US", + not_countries="UK", from_=datetime.datetime.fromisoformat( "2024-07-01 00:00:00+00:00", ), to=datetime.datetime.fromisoformat( "2024-07-01 00:00:00+00:00", ), + parent_url="https://www.washingtonpost.com/politics", + all_links="https://aiindex.stanford.edu/report", + all_domain_links="nvidia.com", include_translation_fields=True, include_nlp_data=True, has_nlp=True, @@ -638,23 +649,13 @@ def post( Examples -------- - import datetime - from newscatcher import NewscatcherApi client = NewscatcherApi( api_key="YOUR_API_KEY", ) client.authors.post( - author_name="Joanna Stern", - sources=["wsj.com", "nytimes.com"], - lang="en", - from_=datetime.datetime.fromisoformat( - "2024-01-01 00:00:00+00:00", - ), - to=datetime.datetime.fromisoformat( - "2024-06-30 00:00:00+00:00", - ), + author_name="David Muir", ) """ _response = self._raw_client.post( @@ -733,8 +734,8 @@ async def get( not_lang: typing.Optional[str] = None, countries: typing.Optional[str] = None, not_countries: typing.Optional[str] = None, - from_: typing.Optional[dt.datetime] = None, - to: typing.Optional[dt.datetime] = None, + from_: typing.Optional[AuthorsGetRequestFrom] = None, + to: typing.Optional[AuthorsGetRequestTo] = None, published_date_precision: typing.Optional[AuthorsGetRequestPublishedDatePrecision] = None, by_parse_date: typing.Optional[bool] = None, sort_by: typing.Optional[AuthorsGetRequestSortBy] = None, @@ -833,7 +834,7 @@ async def get( To learn more, see [Enumerated parameters > Country](/docs/v3/api-reference/overview/enumerated-parameters#country-country-and-not-country). - from_ : typing.Optional[dt.datetime] + from_ : typing.Optional[AuthorsGetRequestFrom] The starting point in time to search from. Accepts date-time strings in ISO 8601 format and plain text. The default time zone is UTC. Formats with examples: @@ -845,7 +846,7 @@ async def get( **Note**: By default, applied to the publication date of the article. To use the article's parse date instead, set the `by_parse_date` parameter to `true`. - to : typing.Optional[dt.datetime] + to : typing.Optional[AuthorsGetRequestTo] The ending point in time to search up to. Accepts date-time strings in ISO 8601 format and plain text. The default time zone is UTC. Formats with examples: @@ -1073,13 +1074,23 @@ async def get( async def main() -> None: await client.authors.get( author_name="Jane Smith", + not_author_name="John Doe", predefined_sources="top 100 US, top 5 GB", + sources="nytimes.com", + not_sources="cnn.com", + lang="en", + not_lang="fr", + countries="US", + not_countries="UK", from_=datetime.datetime.fromisoformat( "2024-07-01 00:00:00+00:00", ), to=datetime.datetime.fromisoformat( "2024-07-01 00:00:00+00:00", ), + parent_url="https://www.washingtonpost.com/politics", + all_links="https://aiindex.stanford.edu/report", + all_domain_links="nvidia.com", include_translation_fields=True, include_nlp_data=True, has_nlp=True, @@ -1294,7 +1305,6 @@ async def post( Examples -------- import asyncio - import datetime from newscatcher import AsyncNewscatcherApi @@ -1305,15 +1315,7 @@ async def post( async def main() -> None: await client.authors.post( - author_name="Joanna Stern", - sources=["wsj.com", "nytimes.com"], - lang="en", - from_=datetime.datetime.fromisoformat( - "2024-01-01 00:00:00+00:00", - ), - to=datetime.datetime.fromisoformat( - "2024-06-30 00:00:00+00:00", - ), + author_name="David Muir", ) diff --git a/src/newscatcher/authors/raw_client.py b/src/newscatcher/authors/raw_client.py index 6f244c2..7a54689 100644 --- a/src/newscatcher/authors/raw_client.py +++ b/src/newscatcher/authors/raw_client.py @@ -1,12 +1,10 @@ # This file was auto-generated by Fern from our API Definition. -import datetime as dt import typing from json.decoder import JSONDecodeError from ..core.api_error import ApiError from ..core.client_wrapper import AsyncClientWrapper, SyncClientWrapper -from ..core.datetime_utils import serialize_datetime from ..core.http_response import AsyncHttpResponse, HttpResponse from ..core.pydantic_utilities import parse_obj_as from ..core.request_options import RequestOptions @@ -21,6 +19,8 @@ from ..types.all_domain_links import AllDomainLinks from ..types.all_links import AllLinks from ..types.author_name import AuthorName +from ..types.authors_get_request_from import AuthorsGetRequestFrom +from ..types.authors_get_request_to import AuthorsGetRequestTo from ..types.by_parse_date import ByParseDate from ..types.content_sentiment_max import ContentSentimentMax from ..types.content_sentiment_min import ContentSentimentMin @@ -87,8 +87,8 @@ def get( not_lang: typing.Optional[str] = None, countries: typing.Optional[str] = None, not_countries: typing.Optional[str] = None, - from_: typing.Optional[dt.datetime] = None, - to: typing.Optional[dt.datetime] = None, + from_: typing.Optional[AuthorsGetRequestFrom] = None, + to: typing.Optional[AuthorsGetRequestTo] = None, published_date_precision: typing.Optional[AuthorsGetRequestPublishedDatePrecision] = None, by_parse_date: typing.Optional[bool] = None, sort_by: typing.Optional[AuthorsGetRequestSortBy] = None, @@ -187,7 +187,7 @@ def get( To learn more, see [Enumerated parameters > Country](/docs/v3/api-reference/overview/enumerated-parameters#country-country-and-not-country). - from_ : typing.Optional[dt.datetime] + from_ : typing.Optional[AuthorsGetRequestFrom] The starting point in time to search from. Accepts date-time strings in ISO 8601 format and plain text. The default time zone is UTC. Formats with examples: @@ -199,7 +199,7 @@ def get( **Note**: By default, applied to the publication date of the article. To use the article's parse date instead, set the `by_parse_date` parameter to `true`. - to : typing.Optional[dt.datetime] + to : typing.Optional[AuthorsGetRequestTo] The ending point in time to search up to. Accepts date-time strings in ISO 8601 format and plain text. The default time zone is UTC. Formats with examples: @@ -425,8 +425,12 @@ def get( "not_lang": not_lang, "countries": countries, "not_countries": not_countries, - "from_": serialize_datetime(from_) if from_ is not None else None, - "to_": serialize_datetime(to) if to is not None else None, + "from_": convert_and_respect_annotation_metadata( + object_=from_, annotation=AuthorsGetRequestFrom, direction="write" + ), + "to_": convert_and_respect_annotation_metadata( + object_=to, annotation=AuthorsGetRequestTo, direction="write" + ), "published_date_precision": published_date_precision, "by_parse_date": by_parse_date, "sort_by": sort_by, @@ -896,8 +900,8 @@ async def get( not_lang: typing.Optional[str] = None, countries: typing.Optional[str] = None, not_countries: typing.Optional[str] = None, - from_: typing.Optional[dt.datetime] = None, - to: typing.Optional[dt.datetime] = None, + from_: typing.Optional[AuthorsGetRequestFrom] = None, + to: typing.Optional[AuthorsGetRequestTo] = None, published_date_precision: typing.Optional[AuthorsGetRequestPublishedDatePrecision] = None, by_parse_date: typing.Optional[bool] = None, sort_by: typing.Optional[AuthorsGetRequestSortBy] = None, @@ -996,7 +1000,7 @@ async def get( To learn more, see [Enumerated parameters > Country](/docs/v3/api-reference/overview/enumerated-parameters#country-country-and-not-country). - from_ : typing.Optional[dt.datetime] + from_ : typing.Optional[AuthorsGetRequestFrom] The starting point in time to search from. Accepts date-time strings in ISO 8601 format and plain text. The default time zone is UTC. Formats with examples: @@ -1008,7 +1012,7 @@ async def get( **Note**: By default, applied to the publication date of the article. To use the article's parse date instead, set the `by_parse_date` parameter to `true`. - to : typing.Optional[dt.datetime] + to : typing.Optional[AuthorsGetRequestTo] The ending point in time to search up to. Accepts date-time strings in ISO 8601 format and plain text. The default time zone is UTC. Formats with examples: @@ -1234,8 +1238,12 @@ async def get( "not_lang": not_lang, "countries": countries, "not_countries": not_countries, - "from_": serialize_datetime(from_) if from_ is not None else None, - "to_": serialize_datetime(to) if to is not None else None, + "from_": convert_and_respect_annotation_metadata( + object_=from_, annotation=AuthorsGetRequestFrom, direction="write" + ), + "to_": convert_and_respect_annotation_metadata( + object_=to, annotation=AuthorsGetRequestTo, direction="write" + ), "published_date_precision": published_date_precision, "by_parse_date": by_parse_date, "sort_by": sort_by, diff --git a/src/newscatcher/authors/types/__init__.py b/src/newscatcher/authors/types/__init__.py index afa44b2..5023575 100644 --- a/src/newscatcher/authors/types/__init__.py +++ b/src/newscatcher/authors/types/__init__.py @@ -2,10 +2,42 @@ # isort: skip_file -from .authors_get_request_published_date_precision import AuthorsGetRequestPublishedDatePrecision -from .authors_get_request_sort_by import AuthorsGetRequestSortBy -from .authors_get_response import AuthorsGetResponse -from .authors_post_response import AuthorsPostResponse +import typing +from importlib import import_module + +if typing.TYPE_CHECKING: + from .authors_get_request_published_date_precision import AuthorsGetRequestPublishedDatePrecision + from .authors_get_request_sort_by import AuthorsGetRequestSortBy + from .authors_get_response import AuthorsGetResponse + from .authors_post_response import AuthorsPostResponse +_dynamic_imports: typing.Dict[str, str] = { + "AuthorsGetRequestPublishedDatePrecision": ".authors_get_request_published_date_precision", + "AuthorsGetRequestSortBy": ".authors_get_request_sort_by", + "AuthorsGetResponse": ".authors_get_response", + "AuthorsPostResponse": ".authors_post_response", +} + + +def __getattr__(attr_name: str) -> typing.Any: + module_name = _dynamic_imports.get(attr_name) + if module_name is None: + raise AttributeError(f"No {attr_name} found in _dynamic_imports for module name -> {__name__}") + try: + module = import_module(module_name, __package__) + if module_name == f".{attr_name}": + return module + else: + return getattr(module, attr_name) + except ImportError as e: + raise ImportError(f"Failed to import {attr_name} from {module_name}: {e}") from e + except AttributeError as e: + raise AttributeError(f"Failed to get {attr_name} from {module_name}: {e}") from e + + +def __dir__(): + lazy_attrs = list(_dynamic_imports.keys()) + return sorted(lazy_attrs) + __all__ = [ "AuthorsGetRequestPublishedDatePrecision", diff --git a/src/newscatcher/base_client.py b/src/newscatcher/base_client.py index 04a1f90..f77640a 100644 --- a/src/newscatcher/base_client.py +++ b/src/newscatcher/base_client.py @@ -1,19 +1,24 @@ # This file was auto-generated by Fern from our API Definition. +from __future__ import annotations + import typing import httpx -from .aggregation.client import AggregationClient, AsyncAggregationClient -from .authors.client import AsyncAuthorsClient, AuthorsClient -from .breaking_news.client import AsyncBreakingNewsClient, BreakingNewsClient from .core.client_wrapper import AsyncClientWrapper, SyncClientWrapper +from .core.logging import LogConfig, Logger from .environment import NewscatcherApiEnvironment -from .latestheadlines.client import AsyncLatestheadlinesClient, LatestheadlinesClient -from .search.client import AsyncSearchClient, SearchClient -from .search_link.client import AsyncSearchLinkClient, SearchLinkClient -from .searchsimilar.client import AsyncSearchsimilarClient, SearchsimilarClient -from .sources.client import AsyncSourcesClient, SourcesClient -from .subscription.client import AsyncSubscriptionClient, SubscriptionClient + +if typing.TYPE_CHECKING: + from .aggregation.client import AggregationClient, AsyncAggregationClient + from .authors.client import AsyncAuthorsClient, AuthorsClient + from .breaking_news.client import AsyncBreakingNewsClient, BreakingNewsClient + from .latestheadlines.client import AsyncLatestheadlinesClient, LatestheadlinesClient + from .search.client import AsyncSearchClient, SearchClient + from .search_link.client import AsyncSearchLinkClient, SearchLinkClient + from .searchsimilar.client import AsyncSearchsimilarClient, SearchsimilarClient + from .sources.client import AsyncSourcesClient, SourcesClient + from .subscription.client import AsyncSubscriptionClient, SubscriptionClient class BaseNewscatcherApi: @@ -47,6 +52,9 @@ class BaseNewscatcherApi: httpx_client : typing.Optional[httpx.Client] The httpx client to use for making requests, a preconfigured client is used by default, however this is useful should you want to pass in any custom httpx configuration. + logging : typing.Optional[typing.Union[LogConfig, Logger]] + Configure logging for the SDK. Accepts a LogConfig dict with 'level' (debug/info/warn/error), 'logger' (custom logger implementation), and 'silent' (boolean, defaults to True) fields. You can also pass a pre-configured Logger instance. + Examples -------- from newscatcher import NewscatcherApi @@ -66,6 +74,7 @@ def __init__( timeout: typing.Optional[float] = None, follow_redirects: typing.Optional[bool] = True, httpx_client: typing.Optional[httpx.Client] = None, + logging: typing.Optional[typing.Union[LogConfig, Logger]] = None, ): _defaulted_timeout = ( timeout if timeout is not None else 60 if httpx_client is None else httpx_client.timeout.read @@ -80,16 +89,89 @@ def __init__( if follow_redirects is not None else httpx.Client(timeout=_defaulted_timeout), timeout=_defaulted_timeout, + logging=logging, ) - self.search = SearchClient(client_wrapper=self._client_wrapper) - self.latestheadlines = LatestheadlinesClient(client_wrapper=self._client_wrapper) - self.breaking_news = BreakingNewsClient(client_wrapper=self._client_wrapper) - self.authors = AuthorsClient(client_wrapper=self._client_wrapper) - self.search_link = SearchLinkClient(client_wrapper=self._client_wrapper) - self.searchsimilar = SearchsimilarClient(client_wrapper=self._client_wrapper) - self.sources = SourcesClient(client_wrapper=self._client_wrapper) - self.aggregation = AggregationClient(client_wrapper=self._client_wrapper) - self.subscription = SubscriptionClient(client_wrapper=self._client_wrapper) + self._search: typing.Optional[SearchClient] = None + self._latestheadlines: typing.Optional[LatestheadlinesClient] = None + self._breaking_news: typing.Optional[BreakingNewsClient] = None + self._authors: typing.Optional[AuthorsClient] = None + self._search_link: typing.Optional[SearchLinkClient] = None + self._searchsimilar: typing.Optional[SearchsimilarClient] = None + self._sources: typing.Optional[SourcesClient] = None + self._aggregation: typing.Optional[AggregationClient] = None + self._subscription: typing.Optional[SubscriptionClient] = None + + @property + def search(self): + if self._search is None: + from .search.client import SearchClient # noqa: E402 + + self._search = SearchClient(client_wrapper=self._client_wrapper) + return self._search + + @property + def latestheadlines(self): + if self._latestheadlines is None: + from .latestheadlines.client import LatestheadlinesClient # noqa: E402 + + self._latestheadlines = LatestheadlinesClient(client_wrapper=self._client_wrapper) + return self._latestheadlines + + @property + def breaking_news(self): + if self._breaking_news is None: + from .breaking_news.client import BreakingNewsClient # noqa: E402 + + self._breaking_news = BreakingNewsClient(client_wrapper=self._client_wrapper) + return self._breaking_news + + @property + def authors(self): + if self._authors is None: + from .authors.client import AuthorsClient # noqa: E402 + + self._authors = AuthorsClient(client_wrapper=self._client_wrapper) + return self._authors + + @property + def search_link(self): + if self._search_link is None: + from .search_link.client import SearchLinkClient # noqa: E402 + + self._search_link = SearchLinkClient(client_wrapper=self._client_wrapper) + return self._search_link + + @property + def searchsimilar(self): + if self._searchsimilar is None: + from .searchsimilar.client import SearchsimilarClient # noqa: E402 + + self._searchsimilar = SearchsimilarClient(client_wrapper=self._client_wrapper) + return self._searchsimilar + + @property + def sources(self): + if self._sources is None: + from .sources.client import SourcesClient # noqa: E402 + + self._sources = SourcesClient(client_wrapper=self._client_wrapper) + return self._sources + + @property + def aggregation(self): + if self._aggregation is None: + from .aggregation.client import AggregationClient # noqa: E402 + + self._aggregation = AggregationClient(client_wrapper=self._client_wrapper) + return self._aggregation + + @property + def subscription(self): + if self._subscription is None: + from .subscription.client import SubscriptionClient # noqa: E402 + + self._subscription = SubscriptionClient(client_wrapper=self._client_wrapper) + return self._subscription class AsyncBaseNewscatcherApi: @@ -123,6 +205,9 @@ class AsyncBaseNewscatcherApi: httpx_client : typing.Optional[httpx.AsyncClient] The httpx client to use for making requests, a preconfigured client is used by default, however this is useful should you want to pass in any custom httpx configuration. + logging : typing.Optional[typing.Union[LogConfig, Logger]] + Configure logging for the SDK. Accepts a LogConfig dict with 'level' (debug/info/warn/error), 'logger' (custom logger implementation), and 'silent' (boolean, defaults to True) fields. You can also pass a pre-configured Logger instance. + Examples -------- from newscatcher import AsyncNewscatcherApi @@ -142,6 +227,7 @@ def __init__( timeout: typing.Optional[float] = None, follow_redirects: typing.Optional[bool] = True, httpx_client: typing.Optional[httpx.AsyncClient] = None, + logging: typing.Optional[typing.Union[LogConfig, Logger]] = None, ): _defaulted_timeout = ( timeout if timeout is not None else 60 if httpx_client is None else httpx_client.timeout.read @@ -156,16 +242,89 @@ def __init__( if follow_redirects is not None else httpx.AsyncClient(timeout=_defaulted_timeout), timeout=_defaulted_timeout, + logging=logging, ) - self.search = AsyncSearchClient(client_wrapper=self._client_wrapper) - self.latestheadlines = AsyncLatestheadlinesClient(client_wrapper=self._client_wrapper) - self.breaking_news = AsyncBreakingNewsClient(client_wrapper=self._client_wrapper) - self.authors = AsyncAuthorsClient(client_wrapper=self._client_wrapper) - self.search_link = AsyncSearchLinkClient(client_wrapper=self._client_wrapper) - self.searchsimilar = AsyncSearchsimilarClient(client_wrapper=self._client_wrapper) - self.sources = AsyncSourcesClient(client_wrapper=self._client_wrapper) - self.aggregation = AsyncAggregationClient(client_wrapper=self._client_wrapper) - self.subscription = AsyncSubscriptionClient(client_wrapper=self._client_wrapper) + self._search: typing.Optional[AsyncSearchClient] = None + self._latestheadlines: typing.Optional[AsyncLatestheadlinesClient] = None + self._breaking_news: typing.Optional[AsyncBreakingNewsClient] = None + self._authors: typing.Optional[AsyncAuthorsClient] = None + self._search_link: typing.Optional[AsyncSearchLinkClient] = None + self._searchsimilar: typing.Optional[AsyncSearchsimilarClient] = None + self._sources: typing.Optional[AsyncSourcesClient] = None + self._aggregation: typing.Optional[AsyncAggregationClient] = None + self._subscription: typing.Optional[AsyncSubscriptionClient] = None + + @property + def search(self): + if self._search is None: + from .search.client import AsyncSearchClient # noqa: E402 + + self._search = AsyncSearchClient(client_wrapper=self._client_wrapper) + return self._search + + @property + def latestheadlines(self): + if self._latestheadlines is None: + from .latestheadlines.client import AsyncLatestheadlinesClient # noqa: E402 + + self._latestheadlines = AsyncLatestheadlinesClient(client_wrapper=self._client_wrapper) + return self._latestheadlines + + @property + def breaking_news(self): + if self._breaking_news is None: + from .breaking_news.client import AsyncBreakingNewsClient # noqa: E402 + + self._breaking_news = AsyncBreakingNewsClient(client_wrapper=self._client_wrapper) + return self._breaking_news + + @property + def authors(self): + if self._authors is None: + from .authors.client import AsyncAuthorsClient # noqa: E402 + + self._authors = AsyncAuthorsClient(client_wrapper=self._client_wrapper) + return self._authors + + @property + def search_link(self): + if self._search_link is None: + from .search_link.client import AsyncSearchLinkClient # noqa: E402 + + self._search_link = AsyncSearchLinkClient(client_wrapper=self._client_wrapper) + return self._search_link + + @property + def searchsimilar(self): + if self._searchsimilar is None: + from .searchsimilar.client import AsyncSearchsimilarClient # noqa: E402 + + self._searchsimilar = AsyncSearchsimilarClient(client_wrapper=self._client_wrapper) + return self._searchsimilar + + @property + def sources(self): + if self._sources is None: + from .sources.client import AsyncSourcesClient # noqa: E402 + + self._sources = AsyncSourcesClient(client_wrapper=self._client_wrapper) + return self._sources + + @property + def aggregation(self): + if self._aggregation is None: + from .aggregation.client import AsyncAggregationClient # noqa: E402 + + self._aggregation = AsyncAggregationClient(client_wrapper=self._client_wrapper) + return self._aggregation + + @property + def subscription(self): + if self._subscription is None: + from .subscription.client import AsyncSubscriptionClient # noqa: E402 + + self._subscription = AsyncSubscriptionClient(client_wrapper=self._client_wrapper) + return self._subscription def _get_base_url(*, base_url: typing.Optional[str] = None, environment: NewscatcherApiEnvironment) -> str: diff --git a/src/newscatcher/breaking_news/__init__.py b/src/newscatcher/breaking_news/__init__.py index 8661612..bde27e6 100644 --- a/src/newscatcher/breaking_news/__init__.py +++ b/src/newscatcher/breaking_news/__init__.py @@ -2,6 +2,33 @@ # isort: skip_file -from .types import BreakingNewsGetRequestSortBy +import typing +from importlib import import_module + +if typing.TYPE_CHECKING: + from .types import BreakingNewsGetRequestSortBy +_dynamic_imports: typing.Dict[str, str] = {"BreakingNewsGetRequestSortBy": ".types"} + + +def __getattr__(attr_name: str) -> typing.Any: + module_name = _dynamic_imports.get(attr_name) + if module_name is None: + raise AttributeError(f"No {attr_name} found in _dynamic_imports for module name -> {__name__}") + try: + module = import_module(module_name, __package__) + if module_name == f".{attr_name}": + return module + else: + return getattr(module, attr_name) + except ImportError as e: + raise ImportError(f"Failed to import {attr_name} from {module_name}: {e}") from e + except AttributeError as e: + raise AttributeError(f"Failed to get {attr_name} from {module_name}: {e}") from e + + +def __dir__(): + lazy_attrs = list(_dynamic_imports.keys()) + return sorted(lazy_attrs) + __all__ = ["BreakingNewsGetRequestSortBy"] diff --git a/src/newscatcher/breaking_news/client.py b/src/newscatcher/breaking_news/client.py index 350a29a..880a5c1 100644 --- a/src/newscatcher/breaking_news/client.py +++ b/src/newscatcher/breaking_news/client.py @@ -239,6 +239,10 @@ def breaking_news_get( has_nlp=True, theme="Business,Finance", not_theme="Crime", + org_entity_name="Apple", + per_entity_name="Elon Musk", + loc_entity_name="California", + misc_entity_name="Bitcoin", ) """ _response = self._raw_client.breaking_news_get( @@ -357,9 +361,8 @@ def breaking_news_post( ) client.breaking_news.breaking_news_post( sort_by="relevancy", - page=1, - page_size=100, - include_nlp_data=True, + ranked_only=True, + top_n_articles=1, ) """ _response = self._raw_client.breaking_news_post( @@ -600,6 +603,10 @@ async def main() -> None: has_nlp=True, theme="Business,Finance", not_theme="Crime", + org_entity_name="Apple", + per_entity_name="Elon Musk", + loc_entity_name="California", + misc_entity_name="Bitcoin", ) @@ -726,9 +733,8 @@ async def breaking_news_post( async def main() -> None: await client.breaking_news.breaking_news_post( sort_by="relevancy", - page=1, - page_size=100, - include_nlp_data=True, + ranked_only=True, + top_n_articles=1, ) diff --git a/src/newscatcher/breaking_news/types/__init__.py b/src/newscatcher/breaking_news/types/__init__.py index 7ad450d..43f2771 100644 --- a/src/newscatcher/breaking_news/types/__init__.py +++ b/src/newscatcher/breaking_news/types/__init__.py @@ -2,6 +2,33 @@ # isort: skip_file -from .breaking_news_get_request_sort_by import BreakingNewsGetRequestSortBy +import typing +from importlib import import_module + +if typing.TYPE_CHECKING: + from .breaking_news_get_request_sort_by import BreakingNewsGetRequestSortBy +_dynamic_imports: typing.Dict[str, str] = {"BreakingNewsGetRequestSortBy": ".breaking_news_get_request_sort_by"} + + +def __getattr__(attr_name: str) -> typing.Any: + module_name = _dynamic_imports.get(attr_name) + if module_name is None: + raise AttributeError(f"No {attr_name} found in _dynamic_imports for module name -> {__name__}") + try: + module = import_module(module_name, __package__) + if module_name == f".{attr_name}": + return module + else: + return getattr(module, attr_name) + except ImportError as e: + raise ImportError(f"Failed to import {attr_name} from {module_name}: {e}") from e + except AttributeError as e: + raise AttributeError(f"Failed to get {attr_name} from {module_name}: {e}") from e + + +def __dir__(): + lazy_attrs = list(_dynamic_imports.keys()) + return sorted(lazy_attrs) + __all__ = ["BreakingNewsGetRequestSortBy"] diff --git a/src/newscatcher/core/__init__.py b/src/newscatcher/core/__init__.py index 31bbb81..75b240c 100644 --- a/src/newscatcher/core/__init__.py +++ b/src/newscatcher/core/__init__.py @@ -2,26 +2,86 @@ # isort: skip_file -from .api_error import ApiError -from .client_wrapper import AsyncClientWrapper, BaseClientWrapper, SyncClientWrapper -from .datetime_utils import serialize_datetime -from .file import File, convert_file_dict_to_httpx_tuples, with_content_type -from .http_client import AsyncHttpClient, HttpClient -from .http_response import AsyncHttpResponse, HttpResponse -from .jsonable_encoder import jsonable_encoder -from .pydantic_utilities import ( - IS_PYDANTIC_V2, - UniversalBaseModel, - UniversalRootModel, - parse_obj_as, - universal_field_validator, - universal_root_validator, - update_forward_refs, -) -from .query_encoder import encode_query -from .remove_none_from_dict import remove_none_from_dict -from .request_options import RequestOptions -from .serialization import FieldMetadata, convert_and_respect_annotation_metadata +import typing +from importlib import import_module + +if typing.TYPE_CHECKING: + from .api_error import ApiError + from .client_wrapper import AsyncClientWrapper, BaseClientWrapper, SyncClientWrapper + from .datetime_utils import serialize_datetime + from .file import File, convert_file_dict_to_httpx_tuples, with_content_type + from .http_client import AsyncHttpClient, HttpClient + from .http_response import AsyncHttpResponse, HttpResponse + from .jsonable_encoder import jsonable_encoder + from .logging import ConsoleLogger, ILogger, LogConfig, LogLevel, Logger, create_logger + from .pydantic_utilities import ( + IS_PYDANTIC_V2, + UniversalBaseModel, + UniversalRootModel, + parse_obj_as, + universal_field_validator, + universal_root_validator, + update_forward_refs, + ) + from .query_encoder import encode_query + from .remove_none_from_dict import remove_none_from_dict + from .request_options import RequestOptions + from .serialization import FieldMetadata, convert_and_respect_annotation_metadata +_dynamic_imports: typing.Dict[str, str] = { + "ApiError": ".api_error", + "AsyncClientWrapper": ".client_wrapper", + "AsyncHttpClient": ".http_client", + "AsyncHttpResponse": ".http_response", + "BaseClientWrapper": ".client_wrapper", + "ConsoleLogger": ".logging", + "FieldMetadata": ".serialization", + "File": ".file", + "HttpClient": ".http_client", + "HttpResponse": ".http_response", + "ILogger": ".logging", + "IS_PYDANTIC_V2": ".pydantic_utilities", + "LogConfig": ".logging", + "LogLevel": ".logging", + "Logger": ".logging", + "RequestOptions": ".request_options", + "SyncClientWrapper": ".client_wrapper", + "UniversalBaseModel": ".pydantic_utilities", + "UniversalRootModel": ".pydantic_utilities", + "convert_and_respect_annotation_metadata": ".serialization", + "convert_file_dict_to_httpx_tuples": ".file", + "create_logger": ".logging", + "encode_query": ".query_encoder", + "jsonable_encoder": ".jsonable_encoder", + "parse_obj_as": ".pydantic_utilities", + "remove_none_from_dict": ".remove_none_from_dict", + "serialize_datetime": ".datetime_utils", + "universal_field_validator": ".pydantic_utilities", + "universal_root_validator": ".pydantic_utilities", + "update_forward_refs": ".pydantic_utilities", + "with_content_type": ".file", +} + + +def __getattr__(attr_name: str) -> typing.Any: + module_name = _dynamic_imports.get(attr_name) + if module_name is None: + raise AttributeError(f"No {attr_name} found in _dynamic_imports for module name -> {__name__}") + try: + module = import_module(module_name, __package__) + if module_name == f".{attr_name}": + return module + else: + return getattr(module, attr_name) + except ImportError as e: + raise ImportError(f"Failed to import {attr_name} from {module_name}: {e}") from e + except AttributeError as e: + raise AttributeError(f"Failed to get {attr_name} from {module_name}: {e}") from e + + +def __dir__(): + lazy_attrs = list(_dynamic_imports.keys()) + return sorted(lazy_attrs) + __all__ = [ "ApiError", @@ -29,17 +89,23 @@ "AsyncHttpClient", "AsyncHttpResponse", "BaseClientWrapper", + "ConsoleLogger", "FieldMetadata", "File", "HttpClient", "HttpResponse", + "ILogger", "IS_PYDANTIC_V2", + "LogConfig", + "LogLevel", + "Logger", "RequestOptions", "SyncClientWrapper", "UniversalBaseModel", "UniversalRootModel", "convert_and_respect_annotation_metadata", "convert_file_dict_to_httpx_tuples", + "create_logger", "encode_query", "jsonable_encoder", "parse_obj_as", diff --git a/src/newscatcher/core/client_wrapper.py b/src/newscatcher/core/client_wrapper.py index 2c185f8..b501203 100644 --- a/src/newscatcher/core/client_wrapper.py +++ b/src/newscatcher/core/client_wrapper.py @@ -4,6 +4,7 @@ import httpx from .http_client import AsyncHttpClient, HttpClient +from .logging import LogConfig, Logger class BaseClientWrapper: @@ -14,18 +15,24 @@ def __init__( headers: typing.Optional[typing.Dict[str, str]] = None, base_url: str, timeout: typing.Optional[float] = None, + logging: typing.Optional[typing.Union[LogConfig, Logger]] = None, ): self.api_key = api_key self._headers = headers self._base_url = base_url self._timeout = timeout + self._logging = logging def get_headers(self) -> typing.Dict[str, str]: + import platform + headers: typing.Dict[str, str] = { - "User-Agent": "newscatcher-sdk/1.4.0", + "User-Agent": "newscatcher-sdk/1.4.1", "X-Fern-Language": "Python", + "X-Fern-Runtime": f"python/{platform.python_version()}", + "X-Fern-Platform": f"{platform.system().lower()}/{platform.release()}", "X-Fern-SDK-Name": "newscatcher-sdk", - "X-Fern-SDK-Version": "1.4.0", + "X-Fern-SDK-Version": "1.4.1", **(self.get_custom_headers() or {}), } headers["x-api-token"] = self.api_key @@ -49,14 +56,16 @@ def __init__( headers: typing.Optional[typing.Dict[str, str]] = None, base_url: str, timeout: typing.Optional[float] = None, + logging: typing.Optional[typing.Union[LogConfig, Logger]] = None, httpx_client: httpx.Client, ): - super().__init__(api_key=api_key, headers=headers, base_url=base_url, timeout=timeout) + super().__init__(api_key=api_key, headers=headers, base_url=base_url, timeout=timeout, logging=logging) self.httpx_client = HttpClient( httpx_client=httpx_client, base_headers=self.get_headers, base_timeout=self.get_timeout, base_url=self.get_base_url, + logging_config=self._logging, ) @@ -68,12 +77,24 @@ def __init__( headers: typing.Optional[typing.Dict[str, str]] = None, base_url: str, timeout: typing.Optional[float] = None, + logging: typing.Optional[typing.Union[LogConfig, Logger]] = None, + async_token: typing.Optional[typing.Callable[[], typing.Awaitable[str]]] = None, httpx_client: httpx.AsyncClient, ): - super().__init__(api_key=api_key, headers=headers, base_url=base_url, timeout=timeout) + super().__init__(api_key=api_key, headers=headers, base_url=base_url, timeout=timeout, logging=logging) + self._async_token = async_token self.httpx_client = AsyncHttpClient( httpx_client=httpx_client, base_headers=self.get_headers, base_timeout=self.get_timeout, base_url=self.get_base_url, + async_base_headers=self.async_get_headers, + logging_config=self._logging, ) + + async def async_get_headers(self) -> typing.Dict[str, str]: + headers = self.get_headers() + if self._async_token is not None: + token = await self._async_token() + headers["Authorization"] = f"Bearer {token}" + return headers diff --git a/src/newscatcher/core/force_multipart.py b/src/newscatcher/core/force_multipart.py index ae24ccf..5440913 100644 --- a/src/newscatcher/core/force_multipart.py +++ b/src/newscatcher/core/force_multipart.py @@ -1,7 +1,9 @@ # This file was auto-generated by Fern from our API Definition. +from typing import Any, Dict -class ForceMultipartDict(dict): + +class ForceMultipartDict(Dict[str, Any]): """ A dictionary subclass that always evaluates to True in boolean contexts. @@ -9,7 +11,7 @@ class ForceMultipartDict(dict): the dictionary is empty, which would normally evaluate to False. """ - def __bool__(self): + def __bool__(self) -> bool: return True diff --git a/src/newscatcher/core/http_client.py b/src/newscatcher/core/http_client.py index e4173f9..ee93758 100644 --- a/src/newscatcher/core/http_client.py +++ b/src/newscatcher/core/http_client.py @@ -5,7 +5,6 @@ import re import time import typing -import urllib.parse from contextlib import asynccontextmanager, contextmanager from random import random @@ -13,14 +12,15 @@ from .file import File, convert_file_dict_to_httpx_tuples from .force_multipart import FORCE_MULTIPART from .jsonable_encoder import jsonable_encoder +from .logging import LogConfig, Logger, create_logger from .query_encoder import encode_query -from .remove_none_from_dict import remove_none_from_dict +from .remove_none_from_dict import remove_none_from_dict as remove_none_from_dict from .request_options import RequestOptions from httpx._types import RequestFiles -INITIAL_RETRY_DELAY_SECONDS = 0.5 -MAX_RETRY_DELAY_SECONDS = 10 -MAX_RETRY_DELAY_SECONDS_FROM_HEADER = 30 +INITIAL_RETRY_DELAY_SECONDS = 1.0 +MAX_RETRY_DELAY_SECONDS = 60.0 +JITTER_FACTOR = 0.2 # 20% random jitter def _parse_retry_after(response_headers: httpx.Headers) -> typing.Optional[float]: @@ -64,6 +64,38 @@ def _parse_retry_after(response_headers: httpx.Headers) -> typing.Optional[float return seconds +def _add_positive_jitter(delay: float) -> float: + """Add positive jitter (0-20%) to prevent thundering herd.""" + jitter_multiplier = 1 + random() * JITTER_FACTOR + return delay * jitter_multiplier + + +def _add_symmetric_jitter(delay: float) -> float: + """Add symmetric jitter (±10%) for exponential backoff.""" + jitter_multiplier = 1 + (random() - 0.5) * JITTER_FACTOR + return delay * jitter_multiplier + + +def _parse_x_ratelimit_reset(response_headers: httpx.Headers) -> typing.Optional[float]: + """ + Parse the X-RateLimit-Reset header (Unix timestamp in seconds). + Returns seconds to wait, or None if header is missing/invalid. + """ + reset_time_str = response_headers.get("x-ratelimit-reset") + if reset_time_str is None: + return None + + try: + reset_time = int(reset_time_str) + delay = reset_time - time.time() + if delay > 0: + return delay + except (ValueError, TypeError): + pass + + return None + + def _retry_timeout(response: httpx.Response, retries: int) -> float: """ Determine the amount of time to wait before retrying a request. @@ -71,17 +103,19 @@ def _retry_timeout(response: httpx.Response, retries: int) -> float: with a jitter to determine the number of seconds to wait. """ - # If the API asks us to wait a certain amount of time (and it's a reasonable amount), just do what it says. + # 1. Check Retry-After header first retry_after = _parse_retry_after(response.headers) - if retry_after is not None and retry_after <= MAX_RETRY_DELAY_SECONDS_FROM_HEADER: - return retry_after + if retry_after is not None and retry_after > 0: + return min(retry_after, MAX_RETRY_DELAY_SECONDS) - # Apply exponential backoff, capped at MAX_RETRY_DELAY_SECONDS. - retry_delay = min(INITIAL_RETRY_DELAY_SECONDS * pow(2.0, retries), MAX_RETRY_DELAY_SECONDS) + # 2. Check X-RateLimit-Reset header (with positive jitter) + ratelimit_reset = _parse_x_ratelimit_reset(response.headers) + if ratelimit_reset is not None: + return _add_positive_jitter(min(ratelimit_reset, MAX_RETRY_DELAY_SECONDS)) - # Add a randomness / jitter to the retry delay to avoid overwhelming the server with retries. - timeout = retry_delay * (1 - 0.25 * random()) - return timeout if timeout >= 0 else 0 + # 3. Fall back to exponential backoff (with symmetric jitter) + backoff = min(INITIAL_RETRY_DELAY_SECONDS * pow(2.0, retries), MAX_RETRY_DELAY_SECONDS) + return _add_symmetric_jitter(backoff) def _should_retry(response: httpx.Response) -> bool: @@ -89,6 +123,71 @@ def _should_retry(response: httpx.Response) -> bool: return response.status_code >= 500 or response.status_code in retryable_400s +_SENSITIVE_HEADERS = frozenset( + { + "authorization", + "www-authenticate", + "x-api-key", + "api-key", + "apikey", + "x-api-token", + "x-auth-token", + "auth-token", + "cookie", + "set-cookie", + "proxy-authorization", + "proxy-authenticate", + "x-csrf-token", + "x-xsrf-token", + "x-session-token", + "x-access-token", + } +) + + +def _redact_headers(headers: typing.Dict[str, str]) -> typing.Dict[str, str]: + return {k: ("[REDACTED]" if k.lower() in _SENSITIVE_HEADERS else v) for k, v in headers.items()} + + +def _build_url(base_url: str, path: typing.Optional[str]) -> str: + """ + Build a full URL by joining a base URL with a path. + + This function correctly handles base URLs that contain path prefixes (e.g., tenant-based URLs) + by using string concatenation instead of urllib.parse.urljoin(), which would incorrectly + strip path components when the path starts with '/'. + + Example: + >>> _build_url("https://cloud.example.com/org/tenant/api", "/users") + 'https://cloud.example.com/org/tenant/api/users' + + Args: + base_url: The base URL, which may contain path prefixes. + path: The path to append. Can be None or empty string. + + Returns: + The full URL with base_url and path properly joined. + """ + if not path: + return base_url + return f"{base_url.rstrip('/')}/{path.lstrip('/')}" + + +def _maybe_filter_none_from_multipart_data( + data: typing.Optional[typing.Any], + request_files: typing.Optional[RequestFiles], + force_multipart: typing.Optional[bool], +) -> typing.Optional[typing.Any]: + """ + Filter None values from data body for multipart/form requests. + This prevents httpx from converting None to empty strings in multipart encoding. + Only applies when files are present or force_multipart is True. + """ + if data is not None and isinstance(data, typing.Mapping) and (request_files or force_multipart): + return remove_none_from_dict(data) + return data + + def remove_omit_from_dict( original: typing.Dict[str, typing.Optional[typing.Any]], omit: typing.Optional[typing.Any], @@ -143,8 +242,19 @@ def get_request_body( # If both data and json are None, we send json data in the event extra properties are specified json_body = maybe_filter_request_body(json, request_options, omit) - # If you have an empty JSON body, you should just send None - return (json_body if json_body != {} else None), data_body if data_body != {} else None + has_additional_body_parameters = bool( + request_options is not None and request_options.get("additional_body_parameters") + ) + + # Only collapse empty dict to None when the body was not explicitly provided + # and there are no additional body parameters. This preserves explicit empty + # bodies (e.g., when an endpoint has a request body type but all fields are optional). + if json_body == {} and json is None and not has_additional_body_parameters: + json_body = None + if data_body == {} and data is None and not has_additional_body_parameters: + data_body = None + + return json_body, data_body class HttpClient: @@ -155,11 +265,13 @@ def __init__( base_timeout: typing.Callable[[], typing.Optional[float]], base_headers: typing.Callable[[], typing.Dict[str, str]], base_url: typing.Optional[typing.Callable[[], str]] = None, + logging_config: typing.Optional[typing.Union[LogConfig, Logger]] = None, ): self.base_url = base_url self.base_timeout = base_timeout self.base_headers = base_headers self.httpx_client = httpx_client + self.logger = create_logger(logging_config) def get_base_url(self, maybe_base_url: typing.Optional[str]) -> str: base_url = maybe_base_url @@ -188,7 +300,7 @@ def request( ] = None, headers: typing.Optional[typing.Dict[str, typing.Any]] = None, request_options: typing.Optional[RequestOptions] = None, - retries: int = 2, + retries: int = 0, omit: typing.Optional[typing.Any] = None, force_multipart: typing.Optional[bool] = None, ) -> httpx.Response: @@ -210,35 +322,53 @@ def request( if (request_files is None or len(request_files) == 0) and force_multipart: request_files = FORCE_MULTIPART - response = self.httpx_client.request( - method=method, - url=urllib.parse.urljoin(f"{base_url}/", path), - headers=jsonable_encoder( + data_body = _maybe_filter_none_from_multipart_data(data_body, request_files, force_multipart) + + # Compute encoded params separately to avoid passing empty list to httpx + # (httpx strips existing query params from URL when params=[] is passed) + _encoded_params = encode_query( + jsonable_encoder( remove_none_from_dict( - { - **self.base_headers(), - **(headers if headers is not None else {}), - **(request_options.get("additional_headers", {}) or {} if request_options is not None else {}), - } - ) - ), - params=encode_query( - jsonable_encoder( - remove_none_from_dict( - remove_omit_from_dict( - { - **(params if params is not None else {}), - **( - request_options.get("additional_query_parameters", {}) or {} - if request_options is not None - else {} - ), - }, - omit, - ) + remove_omit_from_dict( + { + **(params if params is not None else {}), + **( + request_options.get("additional_query_parameters", {}) or {} + if request_options is not None + else {} + ), + }, + omit, ) ) - ), + ) + ) + + _request_url = _build_url(base_url, path) + _request_headers = jsonable_encoder( + remove_none_from_dict( + { + **self.base_headers(), + **(headers if headers is not None else {}), + **(request_options.get("additional_headers", {}) or {} if request_options is not None else {}), + } + ) + ) + + if self.logger.is_debug(): + self.logger.debug( + "Making HTTP request", + method=method, + url=_request_url, + headers=_redact_headers(_request_headers), + has_body=json_body is not None or data_body is not None, + ) + + response = self.httpx_client.request( + method=method, + url=_request_url, + headers=_request_headers, + params=_encoded_params if _encoded_params else None, json=json_body, data=data_body, content=content, @@ -246,9 +376,9 @@ def request( timeout=timeout, ) - max_retries: int = request_options.get("max_retries", 0) if request_options is not None else 0 + max_retries: int = request_options.get("max_retries", 2) if request_options is not None else 2 if _should_retry(response=response): - if max_retries > retries: + if retries < max_retries: time.sleep(_retry_timeout(response=response, retries=retries)) return self.request( path=path, @@ -264,6 +394,24 @@ def request( omit=omit, ) + if self.logger.is_debug(): + if 200 <= response.status_code < 400: + self.logger.debug( + "HTTP request succeeded", + method=method, + url=_request_url, + status_code=response.status_code, + ) + + if self.logger.is_error(): + if response.status_code >= 400: + self.logger.error( + "HTTP request failed with error status", + method=method, + url=_request_url, + status_code=response.status_code, + ) + return response @contextmanager @@ -285,7 +433,7 @@ def stream( ] = None, headers: typing.Optional[typing.Dict[str, typing.Any]] = None, request_options: typing.Optional[RequestOptions] = None, - retries: int = 2, + retries: int = 0, omit: typing.Optional[typing.Any] = None, force_multipart: typing.Optional[bool] = None, ) -> typing.Iterator[httpx.Response]: @@ -307,35 +455,52 @@ def stream( json_body, data_body = get_request_body(json=json, data=data, request_options=request_options, omit=omit) - with self.httpx_client.stream( - method=method, - url=urllib.parse.urljoin(f"{base_url}/", path), - headers=jsonable_encoder( + data_body = _maybe_filter_none_from_multipart_data(data_body, request_files, force_multipart) + + # Compute encoded params separately to avoid passing empty list to httpx + # (httpx strips existing query params from URL when params=[] is passed) + _encoded_params = encode_query( + jsonable_encoder( remove_none_from_dict( - { - **self.base_headers(), - **(headers if headers is not None else {}), - **(request_options.get("additional_headers", {}) if request_options is not None else {}), - } - ) - ), - params=encode_query( - jsonable_encoder( - remove_none_from_dict( - remove_omit_from_dict( - { - **(params if params is not None else {}), - **( - request_options.get("additional_query_parameters", {}) - if request_options is not None - else {} - ), - }, - omit, - ) + remove_omit_from_dict( + { + **(params if params is not None else {}), + **( + request_options.get("additional_query_parameters", {}) + if request_options is not None + else {} + ), + }, + omit, ) ) - ), + ) + ) + + _request_url = _build_url(base_url, path) + _request_headers = jsonable_encoder( + remove_none_from_dict( + { + **self.base_headers(), + **(headers if headers is not None else {}), + **(request_options.get("additional_headers", {}) if request_options is not None else {}), + } + ) + ) + + if self.logger.is_debug(): + self.logger.debug( + "Making streaming HTTP request", + method=method, + url=_request_url, + headers=_redact_headers(_request_headers), + ) + + with self.httpx_client.stream( + method=method, + url=_request_url, + headers=_request_headers, + params=_encoded_params if _encoded_params else None, json=json_body, data=data_body, content=content, @@ -353,11 +518,20 @@ def __init__( base_timeout: typing.Callable[[], typing.Optional[float]], base_headers: typing.Callable[[], typing.Dict[str, str]], base_url: typing.Optional[typing.Callable[[], str]] = None, + async_base_headers: typing.Optional[typing.Callable[[], typing.Awaitable[typing.Dict[str, str]]]] = None, + logging_config: typing.Optional[typing.Union[LogConfig, Logger]] = None, ): self.base_url = base_url self.base_timeout = base_timeout self.base_headers = base_headers + self.async_base_headers = async_base_headers self.httpx_client = httpx_client + self.logger = create_logger(logging_config) + + async def _get_headers(self) -> typing.Dict[str, str]: + if self.async_base_headers is not None: + return await self.async_base_headers() + return self.base_headers() def get_base_url(self, maybe_base_url: typing.Optional[str]) -> str: base_url = maybe_base_url @@ -386,7 +560,7 @@ async def request( ] = None, headers: typing.Optional[typing.Dict[str, typing.Any]] = None, request_options: typing.Optional[RequestOptions] = None, - retries: int = 2, + retries: int = 0, omit: typing.Optional[typing.Any] = None, force_multipart: typing.Optional[bool] = None, ) -> httpx.Response: @@ -408,36 +582,56 @@ async def request( json_body, data_body = get_request_body(json=json, data=data, request_options=request_options, omit=omit) - # Add the input to each of these and do None-safety checks - response = await self.httpx_client.request( - method=method, - url=urllib.parse.urljoin(f"{base_url}/", path), - headers=jsonable_encoder( + data_body = _maybe_filter_none_from_multipart_data(data_body, request_files, force_multipart) + + # Get headers (supports async token providers) + _headers = await self._get_headers() + + # Compute encoded params separately to avoid passing empty list to httpx + # (httpx strips existing query params from URL when params=[] is passed) + _encoded_params = encode_query( + jsonable_encoder( remove_none_from_dict( - { - **self.base_headers(), - **(headers if headers is not None else {}), - **(request_options.get("additional_headers", {}) or {} if request_options is not None else {}), - } - ) - ), - params=encode_query( - jsonable_encoder( - remove_none_from_dict( - remove_omit_from_dict( - { - **(params if params is not None else {}), - **( - request_options.get("additional_query_parameters", {}) or {} - if request_options is not None - else {} - ), - }, - omit, - ) + remove_omit_from_dict( + { + **(params if params is not None else {}), + **( + request_options.get("additional_query_parameters", {}) or {} + if request_options is not None + else {} + ), + }, + omit, ) ) - ), + ) + ) + + _request_url = _build_url(base_url, path) + _request_headers = jsonable_encoder( + remove_none_from_dict( + { + **_headers, + **(headers if headers is not None else {}), + **(request_options.get("additional_headers", {}) or {} if request_options is not None else {}), + } + ) + ) + + if self.logger.is_debug(): + self.logger.debug( + "Making HTTP request", + method=method, + url=_request_url, + headers=_redact_headers(_request_headers), + has_body=json_body is not None or data_body is not None, + ) + + response = await self.httpx_client.request( + method=method, + url=_request_url, + headers=_request_headers, + params=_encoded_params if _encoded_params else None, json=json_body, data=data_body, content=content, @@ -445,9 +639,9 @@ async def request( timeout=timeout, ) - max_retries: int = request_options.get("max_retries", 0) if request_options is not None else 0 + max_retries: int = request_options.get("max_retries", 2) if request_options is not None else 2 if _should_retry(response=response): - if max_retries > retries: + if retries < max_retries: await asyncio.sleep(_retry_timeout(response=response, retries=retries)) return await self.request( path=path, @@ -462,6 +656,25 @@ async def request( retries=retries + 1, omit=omit, ) + + if self.logger.is_debug(): + if 200 <= response.status_code < 400: + self.logger.debug( + "HTTP request succeeded", + method=method, + url=_request_url, + status_code=response.status_code, + ) + + if self.logger.is_error(): + if response.status_code >= 400: + self.logger.error( + "HTTP request failed with error status", + method=method, + url=_request_url, + status_code=response.status_code, + ) + return response @asynccontextmanager @@ -483,7 +696,7 @@ async def stream( ] = None, headers: typing.Optional[typing.Dict[str, typing.Any]] = None, request_options: typing.Optional[RequestOptions] = None, - retries: int = 2, + retries: int = 0, omit: typing.Optional[typing.Any] = None, force_multipart: typing.Optional[bool] = None, ) -> typing.AsyncIterator[httpx.Response]: @@ -505,35 +718,55 @@ async def stream( json_body, data_body = get_request_body(json=json, data=data, request_options=request_options, omit=omit) - async with self.httpx_client.stream( - method=method, - url=urllib.parse.urljoin(f"{base_url}/", path), - headers=jsonable_encoder( + data_body = _maybe_filter_none_from_multipart_data(data_body, request_files, force_multipart) + + # Get headers (supports async token providers) + _headers = await self._get_headers() + + # Compute encoded params separately to avoid passing empty list to httpx + # (httpx strips existing query params from URL when params=[] is passed) + _encoded_params = encode_query( + jsonable_encoder( remove_none_from_dict( - { - **self.base_headers(), - **(headers if headers is not None else {}), - **(request_options.get("additional_headers", {}) if request_options is not None else {}), - } - ) - ), - params=encode_query( - jsonable_encoder( - remove_none_from_dict( - remove_omit_from_dict( - { - **(params if params is not None else {}), - **( - request_options.get("additional_query_parameters", {}) - if request_options is not None - else {} - ), - }, - omit=omit, - ) + remove_omit_from_dict( + { + **(params if params is not None else {}), + **( + request_options.get("additional_query_parameters", {}) + if request_options is not None + else {} + ), + }, + omit=omit, ) ) - ), + ) + ) + + _request_url = _build_url(base_url, path) + _request_headers = jsonable_encoder( + remove_none_from_dict( + { + **_headers, + **(headers if headers is not None else {}), + **(request_options.get("additional_headers", {}) if request_options is not None else {}), + } + ) + ) + + if self.logger.is_debug(): + self.logger.debug( + "Making streaming HTTP request", + method=method, + url=_request_url, + headers=_redact_headers(_request_headers), + ) + + async with self.httpx_client.stream( + method=method, + url=_request_url, + headers=_request_headers, + params=_encoded_params if _encoded_params else None, json=json_body, data=data_body, content=content, diff --git a/src/newscatcher/core/http_response.py b/src/newscatcher/core/http_response.py index 48a1798..00bb109 100644 --- a/src/newscatcher/core/http_response.py +++ b/src/newscatcher/core/http_response.py @@ -4,12 +4,12 @@ import httpx +# Generic to represent the underlying type of the data wrapped by the HTTP response. T = TypeVar("T") -"""Generic to represent the underlying type of the data wrapped by the HTTP response.""" class BaseHttpResponse: - """Minimalist HTTP response wrapper that exposes response headers.""" + """Minimalist HTTP response wrapper that exposes response headers and status code.""" _response: httpx.Response @@ -20,6 +20,10 @@ def __init__(self, response: httpx.Response): def headers(self) -> Dict[str, str]: return dict(self._response.headers) + @property + def status_code(self) -> int: + return self._response.status_code + class HttpResponse(Generic[T], BaseHttpResponse): """HTTP response wrapper that exposes response headers and data.""" diff --git a/src/newscatcher/core/http_sse/__init__.py b/src/newscatcher/core/http_sse/__init__.py new file mode 100644 index 0000000..730e5a3 --- /dev/null +++ b/src/newscatcher/core/http_sse/__init__.py @@ -0,0 +1,42 @@ +# This file was auto-generated by Fern from our API Definition. + +# isort: skip_file + +import typing +from importlib import import_module + +if typing.TYPE_CHECKING: + from ._api import EventSource, aconnect_sse, connect_sse + from ._exceptions import SSEError + from ._models import ServerSentEvent +_dynamic_imports: typing.Dict[str, str] = { + "EventSource": "._api", + "SSEError": "._exceptions", + "ServerSentEvent": "._models", + "aconnect_sse": "._api", + "connect_sse": "._api", +} + + +def __getattr__(attr_name: str) -> typing.Any: + module_name = _dynamic_imports.get(attr_name) + if module_name is None: + raise AttributeError(f"No {attr_name} found in _dynamic_imports for module name -> {__name__}") + try: + module = import_module(module_name, __package__) + if module_name == f".{attr_name}": + return module + else: + return getattr(module, attr_name) + except ImportError as e: + raise ImportError(f"Failed to import {attr_name} from {module_name}: {e}") from e + except AttributeError as e: + raise AttributeError(f"Failed to get {attr_name} from {module_name}: {e}") from e + + +def __dir__(): + lazy_attrs = list(_dynamic_imports.keys()) + return sorted(lazy_attrs) + + +__all__ = ["EventSource", "SSEError", "ServerSentEvent", "aconnect_sse", "connect_sse"] diff --git a/src/newscatcher/core/http_sse/_api.py b/src/newscatcher/core/http_sse/_api.py new file mode 100644 index 0000000..f900b3b --- /dev/null +++ b/src/newscatcher/core/http_sse/_api.py @@ -0,0 +1,112 @@ +# This file was auto-generated by Fern from our API Definition. + +import re +from contextlib import asynccontextmanager, contextmanager +from typing import Any, AsyncGenerator, AsyncIterator, Iterator, cast + +import httpx +from ._decoders import SSEDecoder +from ._exceptions import SSEError +from ._models import ServerSentEvent + + +class EventSource: + def __init__(self, response: httpx.Response) -> None: + self._response = response + + def _check_content_type(self) -> None: + content_type = self._response.headers.get("content-type", "").partition(";")[0] + if "text/event-stream" not in content_type: + raise SSEError( + f"Expected response header Content-Type to contain 'text/event-stream', got {content_type!r}" + ) + + def _get_charset(self) -> str: + """Extract charset from Content-Type header, fallback to UTF-8.""" + content_type = self._response.headers.get("content-type", "") + + # Parse charset parameter using regex + charset_match = re.search(r"charset=([^;\s]+)", content_type, re.IGNORECASE) + if charset_match: + charset = charset_match.group(1).strip("\"'") + # Validate that it's a known encoding + try: + # Test if the charset is valid by trying to encode/decode + "test".encode(charset).decode(charset) + return charset + except (LookupError, UnicodeError): + # If charset is invalid, fall back to UTF-8 + pass + + # Default to UTF-8 if no charset specified or invalid charset + return "utf-8" + + @property + def response(self) -> httpx.Response: + return self._response + + def iter_sse(self) -> Iterator[ServerSentEvent]: + self._check_content_type() + decoder = SSEDecoder() + charset = self._get_charset() + + buffer = "" + for chunk in self._response.iter_bytes(): + # Decode chunk using detected charset + text_chunk = chunk.decode(charset, errors="replace") + buffer += text_chunk + + # Process complete lines + while "\n" in buffer: + line, buffer = buffer.split("\n", 1) + line = line.rstrip("\r") + sse = decoder.decode(line) + # when we reach a "\n\n" => line = '' + # => decoder will attempt to return an SSE Event + if sse is not None: + yield sse + + # Process any remaining data in buffer + if buffer.strip(): + line = buffer.rstrip("\r") + sse = decoder.decode(line) + if sse is not None: + yield sse + + async def aiter_sse(self) -> AsyncGenerator[ServerSentEvent, None]: + self._check_content_type() + decoder = SSEDecoder() + lines = cast(AsyncGenerator[str, None], self._response.aiter_lines()) + try: + async for line in lines: + line = line.rstrip("\n") + sse = decoder.decode(line) + if sse is not None: + yield sse + finally: + await lines.aclose() + + +@contextmanager +def connect_sse(client: httpx.Client, method: str, url: str, **kwargs: Any) -> Iterator[EventSource]: + headers = kwargs.pop("headers", {}) + headers["Accept"] = "text/event-stream" + headers["Cache-Control"] = "no-store" + + with client.stream(method, url, headers=headers, **kwargs) as response: + yield EventSource(response) + + +@asynccontextmanager +async def aconnect_sse( + client: httpx.AsyncClient, + method: str, + url: str, + **kwargs: Any, +) -> AsyncIterator[EventSource]: + headers = kwargs.pop("headers", {}) + headers["Accept"] = "text/event-stream" + headers["Cache-Control"] = "no-store" + + async with client.stream(method, url, headers=headers, **kwargs) as response: + yield EventSource(response) diff --git a/src/newscatcher/core/http_sse/_decoders.py b/src/newscatcher/core/http_sse/_decoders.py new file mode 100644 index 0000000..339b089 --- /dev/null +++ b/src/newscatcher/core/http_sse/_decoders.py @@ -0,0 +1,61 @@ +# This file was auto-generated by Fern from our API Definition. + +from typing import List, Optional + +from ._models import ServerSentEvent + + +class SSEDecoder: + def __init__(self) -> None: + self._event = "" + self._data: List[str] = [] + self._last_event_id = "" + self._retry: Optional[int] = None + + def decode(self, line: str) -> Optional[ServerSentEvent]: + # See: https://html.spec.whatwg.org/multipage/server-sent-events.html#event-stream-interpretation # noqa: E501 + + if not line: + if not self._event and not self._data and not self._last_event_id and self._retry is None: + return None + + sse = ServerSentEvent( + event=self._event, + data="\n".join(self._data), + id=self._last_event_id, + retry=self._retry, + ) + + # NOTE: as per the SSE spec, do not reset last_event_id. + self._event = "" + self._data = [] + self._retry = None + + return sse + + if line.startswith(":"): + return None + + fieldname, _, value = line.partition(":") + + if value.startswith(" "): + value = value[1:] + + if fieldname == "event": + self._event = value + elif fieldname == "data": + self._data.append(value) + elif fieldname == "id": + if "\0" in value: + pass + else: + self._last_event_id = value + elif fieldname == "retry": + try: + self._retry = int(value) + except (TypeError, ValueError): + pass + else: + pass # Field is ignored. + + return None diff --git a/src/newscatcher/core/http_sse/_exceptions.py b/src/newscatcher/core/http_sse/_exceptions.py new file mode 100644 index 0000000..81605a8 --- /dev/null +++ b/src/newscatcher/core/http_sse/_exceptions.py @@ -0,0 +1,7 @@ +# This file was auto-generated by Fern from our API Definition. + +import httpx + + +class SSEError(httpx.TransportError): + pass diff --git a/src/newscatcher/core/http_sse/_models.py b/src/newscatcher/core/http_sse/_models.py new file mode 100644 index 0000000..1af57f8 --- /dev/null +++ b/src/newscatcher/core/http_sse/_models.py @@ -0,0 +1,17 @@ +# This file was auto-generated by Fern from our API Definition. + +import json +from dataclasses import dataclass +from typing import Any, Optional + + +@dataclass(frozen=True) +class ServerSentEvent: + event: str = "message" + data: str = "" + id: str = "" + retry: Optional[int] = None + + def json(self) -> Any: + """Parse the data field as JSON.""" + return json.loads(self.data) diff --git a/src/newscatcher/core/jsonable_encoder.py b/src/newscatcher/core/jsonable_encoder.py index afee366..f8beaea 100644 --- a/src/newscatcher/core/jsonable_encoder.py +++ b/src/newscatcher/core/jsonable_encoder.py @@ -30,6 +30,10 @@ def jsonable_encoder(obj: Any, custom_encoder: Optional[Dict[Any, Callable[[Any], Any]]] = None) -> Any: custom_encoder = custom_encoder or {} + # Generated SDKs use Ellipsis (`...`) as the sentinel value for "OMIT". + # OMIT values should be excluded from serialized payloads. + if obj is Ellipsis: + return None if custom_encoder: if type(obj) in custom_encoder: return custom_encoder[type(obj)](obj) @@ -70,6 +74,8 @@ def jsonable_encoder(obj: Any, custom_encoder: Optional[Dict[Any, Callable[[Any] allowed_keys = set(obj.keys()) for key, value in obj.items(): if key in allowed_keys: + if value is Ellipsis: + continue encoded_key = jsonable_encoder(key, custom_encoder=custom_encoder) encoded_value = jsonable_encoder(value, custom_encoder=custom_encoder) encoded_dict[encoded_key] = encoded_value @@ -77,6 +83,8 @@ def jsonable_encoder(obj: Any, custom_encoder: Optional[Dict[Any, Callable[[Any] if isinstance(obj, (list, set, frozenset, GeneratorType, tuple)): encoded_list = [] for item in obj: + if item is Ellipsis: + continue encoded_list.append(jsonable_encoder(item, custom_encoder=custom_encoder)) return encoded_list diff --git a/src/newscatcher/core/logging.py b/src/newscatcher/core/logging.py new file mode 100644 index 0000000..e5e5724 --- /dev/null +++ b/src/newscatcher/core/logging.py @@ -0,0 +1,107 @@ +# This file was auto-generated by Fern from our API Definition. + +import logging +import typing + +LogLevel = typing.Literal["debug", "info", "warn", "error"] + +_LOG_LEVEL_MAP: typing.Dict[LogLevel, int] = { + "debug": 1, + "info": 2, + "warn": 3, + "error": 4, +} + + +class ILogger(typing.Protocol): + def debug(self, message: str, **kwargs: typing.Any) -> None: ... + def info(self, message: str, **kwargs: typing.Any) -> None: ... + def warn(self, message: str, **kwargs: typing.Any) -> None: ... + def error(self, message: str, **kwargs: typing.Any) -> None: ... + + +class ConsoleLogger: + _logger: logging.Logger + + def __init__(self) -> None: + self._logger = logging.getLogger("fern") + if not self._logger.handlers: + handler = logging.StreamHandler() + handler.setFormatter(logging.Formatter("%(levelname)s - %(message)s")) + self._logger.addHandler(handler) + self._logger.setLevel(logging.DEBUG) + + def debug(self, message: str, **kwargs: typing.Any) -> None: + self._logger.debug(message, extra=kwargs) + + def info(self, message: str, **kwargs: typing.Any) -> None: + self._logger.info(message, extra=kwargs) + + def warn(self, message: str, **kwargs: typing.Any) -> None: + self._logger.warning(message, extra=kwargs) + + def error(self, message: str, **kwargs: typing.Any) -> None: + self._logger.error(message, extra=kwargs) + + +class LogConfig(typing.TypedDict, total=False): + level: LogLevel + logger: ILogger + silent: bool + + +class Logger: + _level: int + _logger: ILogger + _silent: bool + + def __init__(self, *, level: LogLevel, logger: ILogger, silent: bool) -> None: + self._level = _LOG_LEVEL_MAP[level] + self._logger = logger + self._silent = silent + + def _should_log(self, level: LogLevel) -> bool: + return not self._silent and self._level <= _LOG_LEVEL_MAP[level] + + def is_debug(self) -> bool: + return self._should_log("debug") + + def is_info(self) -> bool: + return self._should_log("info") + + def is_warn(self) -> bool: + return self._should_log("warn") + + def is_error(self) -> bool: + return self._should_log("error") + + def debug(self, message: str, **kwargs: typing.Any) -> None: + if self.is_debug(): + self._logger.debug(message, **kwargs) + + def info(self, message: str, **kwargs: typing.Any) -> None: + if self.is_info(): + self._logger.info(message, **kwargs) + + def warn(self, message: str, **kwargs: typing.Any) -> None: + if self.is_warn(): + self._logger.warn(message, **kwargs) + + def error(self, message: str, **kwargs: typing.Any) -> None: + if self.is_error(): + self._logger.error(message, **kwargs) + + +_default_logger: Logger = Logger(level="info", logger=ConsoleLogger(), silent=True) + + +def create_logger(config: typing.Optional[typing.Union[LogConfig, Logger]] = None) -> Logger: + if config is None: + return _default_logger + if isinstance(config, Logger): + return config + return Logger( + level=config.get("level", "info"), + logger=config.get("logger", ConsoleLogger()), + silent=config.get("silent", True), + ) diff --git a/src/newscatcher/core/pydantic_utilities.py b/src/newscatcher/core/pydantic_utilities.py index 7db2950..831aadc 100644 --- a/src/newscatcher/core/pydantic_utilities.py +++ b/src/newscatcher/core/pydantic_utilities.py @@ -2,22 +2,64 @@ # nopycln: file import datetime as dt +import inspect +import json +import logging from collections import defaultdict -from typing import Any, Callable, ClassVar, Dict, List, Mapping, Optional, Set, Tuple, Type, TypeVar, Union, cast +from dataclasses import asdict +from typing import ( + TYPE_CHECKING, + Any, + Callable, + ClassVar, + Dict, + List, + Mapping, + Optional, + Set, + Tuple, + Type, + TypeVar, + Union, + cast, +) import pydantic +import typing_extensions + +_logger = logging.getLogger(__name__) + +if TYPE_CHECKING: + from .http_sse._models import ServerSentEvent IS_PYDANTIC_V2 = pydantic.VERSION.startswith("2.") if IS_PYDANTIC_V2: - from pydantic.v1.datetime_parse import parse_date as parse_date - from pydantic.v1.datetime_parse import parse_datetime as parse_datetime - from pydantic.v1.fields import ModelField as ModelField - from pydantic.v1.json import ENCODERS_BY_TYPE as encoders_by_type # type: ignore[attr-defined] - from pydantic.v1.typing import get_args as get_args - from pydantic.v1.typing import get_origin as get_origin - from pydantic.v1.typing import is_literal_type as is_literal_type - from pydantic.v1.typing import is_union as is_union + import warnings + + _datetime_adapter = pydantic.TypeAdapter(dt.datetime) # type: ignore[attr-defined] + _date_adapter = pydantic.TypeAdapter(dt.date) # type: ignore[attr-defined] + + def parse_datetime(value: Any) -> dt.datetime: # type: ignore[misc] + if isinstance(value, dt.datetime): + return value + return _datetime_adapter.validate_python(value) + + def parse_date(value: Any) -> dt.date: # type: ignore[misc] + if isinstance(value, dt.datetime): + return value.date() + if isinstance(value, dt.date): + return value + return _date_adapter.validate_python(value) + + with warnings.catch_warnings(): + warnings.simplefilter("ignore", UserWarning) + from pydantic.v1.fields import ModelField as ModelField + from pydantic.v1.json import ENCODERS_BY_TYPE as encoders_by_type # type: ignore[attr-defined] + from pydantic.v1.typing import get_args as get_args + from pydantic.v1.typing import get_origin as get_origin + from pydantic.v1.typing import is_literal_type as is_literal_type + from pydantic.v1.typing import is_union as is_union else: from pydantic.datetime_parse import parse_date as parse_date # type: ignore[no-redef] from pydantic.datetime_parse import parse_datetime as parse_datetime # type: ignore[no-redef] @@ -36,8 +78,212 @@ Model = TypeVar("Model", bound=pydantic.BaseModel) +def _get_discriminator_and_variants(type_: Type[Any]) -> Tuple[Optional[str], Optional[List[Type[Any]]]]: + """ + Extract the discriminator field name and union variants from a discriminated union type. + Supports Annotated[Union[...], Field(discriminator=...)] patterns. + Returns (discriminator, variants) or (None, None) if not a discriminated union. + """ + origin = typing_extensions.get_origin(type_) + + if origin is typing_extensions.Annotated: + args = typing_extensions.get_args(type_) + if len(args) >= 2: + inner_type = args[0] + # Check annotations for discriminator + discriminator = None + for annotation in args[1:]: + if hasattr(annotation, "discriminator"): + discriminator = getattr(annotation, "discriminator", None) + break + + if discriminator: + inner_origin = typing_extensions.get_origin(inner_type) + if inner_origin is Union: + variants = list(typing_extensions.get_args(inner_type)) + return discriminator, variants + return None, None + + +def _get_field_annotation(model: Type[Any], field_name: str) -> Optional[Type[Any]]: + """Get the type annotation of a field from a Pydantic model.""" + if IS_PYDANTIC_V2: + fields = getattr(model, "model_fields", {}) + field_info = fields.get(field_name) + if field_info: + return cast(Optional[Type[Any]], field_info.annotation) + else: + fields = getattr(model, "__fields__", {}) + field_info = fields.get(field_name) + if field_info: + return cast(Optional[Type[Any]], field_info.outer_type_) + return None + + +def _find_variant_by_discriminator( + variants: List[Type[Any]], + discriminator: str, + discriminator_value: Any, +) -> Optional[Type[Any]]: + """Find the union variant that matches the discriminator value.""" + for variant in variants: + if not (inspect.isclass(variant) and issubclass(variant, pydantic.BaseModel)): + continue + + disc_annotation = _get_field_annotation(variant, discriminator) + if disc_annotation and is_literal_type(disc_annotation): + literal_args = get_args(disc_annotation) + if literal_args and literal_args[0] == discriminator_value: + return variant + return None + + +def _is_string_type(type_: Type[Any]) -> bool: + """Check if a type is str or Optional[str].""" + if type_ is str: + return True + + origin = typing_extensions.get_origin(type_) + if origin is Union: + args = typing_extensions.get_args(type_) + # Optional[str] = Union[str, None] + non_none_args = [a for a in args if a is not type(None)] + if len(non_none_args) == 1 and non_none_args[0] is str: + return True + + return False + + +def parse_sse_obj(sse: "ServerSentEvent", type_: Type[T]) -> T: + """ + Parse a ServerSentEvent into the appropriate type. + + Handles two scenarios based on where the discriminator field is located: + + 1. Data-level discrimination: The discriminator (e.g., 'type') is inside the 'data' payload. + The union describes the data content, not the SSE envelope. + -> Returns: json.loads(data) parsed into the type + + Example: ChatStreamResponse with discriminator='type' + Input: ServerSentEvent(event="message", data='{"type": "content-delta", ...}', id="") + Output: ContentDeltaEvent (parsed from data, SSE envelope stripped) + + 2. Event-level discrimination: The discriminator (e.g., 'event') is at the SSE event level. + The union describes the full SSE event structure. + -> Returns: SSE envelope with 'data' field JSON-parsed only if the variant expects non-string + + Example: JobStreamResponse with discriminator='event' + Input: ServerSentEvent(event="ERROR", data='{"code": "FAILED", ...}', id="123") + Output: JobStreamResponse_Error with data as ErrorData object + + But for variants where data is str (like STATUS_UPDATE): + Input: ServerSentEvent(event="STATUS_UPDATE", data='{"status": "processing"}', id="1") + Output: JobStreamResponse_StatusUpdate with data as string (not parsed) + + Args: + sse: The ServerSentEvent object to parse + type_: The target discriminated union type + + Returns: + The parsed object of type T + + Note: + This function is only available in SDK contexts where http_sse module exists. + """ + sse_event = asdict(sse) + discriminator, variants = _get_discriminator_and_variants(type_) + + if discriminator is None or variants is None: + # Not a discriminated union - parse the data field as JSON + data_value = sse_event.get("data") + if isinstance(data_value, str) and data_value: + try: + parsed_data = json.loads(data_value) + return parse_obj_as(type_, parsed_data) + except json.JSONDecodeError as e: + _logger.warning( + "Failed to parse SSE data field as JSON: %s, data: %s", + e, + data_value[:100] if len(data_value) > 100 else data_value, + ) + return parse_obj_as(type_, sse_event) + + data_value = sse_event.get("data") + + # Check if discriminator is at the top level (event-level discrimination) + if discriminator in sse_event: + # Case 2: Event-level discrimination + # Find the matching variant to check if 'data' field needs JSON parsing + disc_value = sse_event.get(discriminator) + matching_variant = _find_variant_by_discriminator(variants, discriminator, disc_value) + + if matching_variant is not None: + # Check what type the variant expects for 'data' + data_type = _get_field_annotation(matching_variant, "data") + if data_type is not None and not _is_string_type(data_type): + # Variant expects non-string data - parse JSON + if isinstance(data_value, str) and data_value: + try: + parsed_data = json.loads(data_value) + new_object = dict(sse_event) + new_object["data"] = parsed_data + return parse_obj_as(type_, new_object) + except json.JSONDecodeError as e: + _logger.warning( + "Failed to parse SSE data field as JSON for event-level discrimination: %s, data: %s", + e, + data_value[:100] if len(data_value) > 100 else data_value, + ) + # Either no matching variant, data is string type, or JSON parse failed + return parse_obj_as(type_, sse_event) + + else: + # Case 1: Data-level discrimination + # The discriminator is inside the data payload - extract and parse data only + if isinstance(data_value, str) and data_value: + try: + parsed_data = json.loads(data_value) + return parse_obj_as(type_, parsed_data) + except json.JSONDecodeError as e: + _logger.warning( + "Failed to parse SSE data field as JSON for data-level discrimination: %s, data: %s", + e, + data_value[:100] if len(data_value) > 100 else data_value, + ) + return parse_obj_as(type_, sse_event) + + def parse_obj_as(type_: Type[T], object_: Any) -> T: - dealiased_object = convert_and_respect_annotation_metadata(object_=object_, annotation=type_, direction="read") + # convert_and_respect_annotation_metadata is required for TypedDict aliasing. + # + # For Pydantic models, whether we should pre-dealias depends on how the model encodes aliasing: + # - If the model uses real Pydantic aliases (pydantic.Field(alias=...)), then we must pass wire keys through + # unchanged so Pydantic can validate them. + # - If the model encodes aliasing only via FieldMetadata annotations, then we MUST pre-dealias because Pydantic + # will not recognize those aliases during validation. + if inspect.isclass(type_) and issubclass(type_, pydantic.BaseModel): + has_pydantic_aliases = False + if IS_PYDANTIC_V2: + for field_name, field_info in getattr(type_, "model_fields", {}).items(): # type: ignore[attr-defined] + alias = getattr(field_info, "alias", None) + if alias is not None and alias != field_name: + has_pydantic_aliases = True + break + else: + for field in getattr(type_, "__fields__", {}).values(): + alias = getattr(field, "alias", None) + name = getattr(field, "name", None) + if alias is not None and name is not None and alias != name: + has_pydantic_aliases = True + break + + dealiased_object = ( + object_ + if has_pydantic_aliases + else convert_and_respect_annotation_metadata(object_=object_, annotation=type_, direction="read") + ) + else: + dealiased_object = convert_and_respect_annotation_metadata(object_=object_, annotation=type_, direction="read") if IS_PYDANTIC_V2: adapter = pydantic.TypeAdapter(type_) # type: ignore[attr-defined] return adapter.validate_python(dealiased_object) @@ -59,9 +305,46 @@ class UniversalBaseModel(pydantic.BaseModel): protected_namespaces=(), ) + @pydantic.model_validator(mode="before") # type: ignore[attr-defined] + @classmethod + def _coerce_field_names_to_aliases(cls, data: Any) -> Any: + """ + Accept Python field names in input by rewriting them to their Pydantic aliases, + while avoiding silent collisions when a key could refer to multiple fields. + """ + if not isinstance(data, Mapping): + return data + + fields = getattr(cls, "model_fields", {}) # type: ignore[attr-defined] + name_to_alias: Dict[str, str] = {} + alias_to_name: Dict[str, str] = {} + + for name, field_info in fields.items(): + alias = getattr(field_info, "alias", None) or name + name_to_alias[name] = alias + if alias != name: + alias_to_name[alias] = name + + # Detect ambiguous keys: a key that is an alias for one field and a name for another. + ambiguous_keys = set(alias_to_name.keys()).intersection(set(name_to_alias.keys())) + for key in ambiguous_keys: + if key in data and name_to_alias[key] not in data: + raise ValueError( + f"Ambiguous input key '{key}': it is both a field name and an alias. " + "Provide the explicit alias key to disambiguate." + ) + + original_keys = set(data.keys()) + rewritten: Dict[str, Any] = dict(data) + for name, alias in name_to_alias.items(): + if alias != name and name in original_keys and alias not in rewritten: + rewritten[alias] = rewritten.pop(name) + + return rewritten + @pydantic.model_serializer(mode="plain", when_used="json") # type: ignore[attr-defined] def serialize_model(self) -> Any: # type: ignore[name-defined] - serialized = self.model_dump() + serialized = self.dict() # type: ignore[attr-defined] data = {k: serialize_datetime(v) if isinstance(v, dt.datetime) else v for k, v in serialized.items()} return data @@ -71,6 +354,40 @@ class Config: smart_union = True json_encoders = {dt.datetime: serialize_datetime} + @pydantic.root_validator(pre=True) + def _coerce_field_names_to_aliases(cls, values: Any) -> Any: + """ + Pydantic v1 equivalent of _coerce_field_names_to_aliases. + """ + if not isinstance(values, Mapping): + return values + + fields = getattr(cls, "__fields__", {}) + name_to_alias: Dict[str, str] = {} + alias_to_name: Dict[str, str] = {} + + for name, field in fields.items(): + alias = getattr(field, "alias", None) or name + name_to_alias[name] = alias + if alias != name: + alias_to_name[alias] = name + + ambiguous_keys = set(alias_to_name.keys()).intersection(set(name_to_alias.keys())) + for key in ambiguous_keys: + if key in values and name_to_alias[key] not in values: + raise ValueError( + f"Ambiguous input key '{key}': it is both a field name and an alias. " + "Provide the explicit alias key to disambiguate." + ) + + original_keys = set(values.keys()) + rewritten: Dict[str, Any] = dict(values) + for name, alias in name_to_alias.items(): + if alias != name and name in original_keys and alias not in rewritten: + rewritten[alias] = rewritten.pop(name) + + return rewritten + @classmethod def model_construct(cls: Type["Model"], _fields_set: Optional[Set[str]] = None, **values: Any) -> "Model": dealiased_object = convert_and_respect_annotation_metadata(object_=values, annotation=cls, direction="read") @@ -147,7 +464,10 @@ def dict(self, **kwargs: Any) -> Dict[str, Any]: dict_dump = super().dict(**kwargs_with_defaults_exclude_unset_include_fields) - return convert_and_respect_annotation_metadata(object_=dict_dump, annotation=self.__class__, direction="write") + return cast( + Dict[str, Any], + convert_and_respect_annotation_metadata(object_=dict_dump, annotation=self.__class__, direction="write"), + ) def _union_list_of_pydantic_dicts(source: List[Any], destination: List[Any]) -> List[Any]: @@ -217,7 +537,9 @@ def universal_root_validator( ) -> Callable[[AnyCallable], AnyCallable]: def decorator(func: AnyCallable) -> AnyCallable: if IS_PYDANTIC_V2: - return cast(AnyCallable, pydantic.model_validator(mode="before" if pre else "after")(func)) # type: ignore[attr-defined] + # In Pydantic v2, for RootModel we always use "before" mode + # The custom validators transform the input value before the model is created + return cast(AnyCallable, pydantic.model_validator(mode="before")(func)) # type: ignore[attr-defined] return cast(AnyCallable, pydantic.root_validator(pre=pre)(func)) # type: ignore[call-overload] return decorator diff --git a/src/newscatcher/errors/__init__.py b/src/newscatcher/errors/__init__.py index ac3639a..fee5ae7 100644 --- a/src/newscatcher/errors/__init__.py +++ b/src/newscatcher/errors/__init__.py @@ -2,13 +2,48 @@ # isort: skip_file -from .bad_request_error import BadRequestError -from .forbidden_error import ForbiddenError -from .internal_server_error import InternalServerError -from .request_timeout_error import RequestTimeoutError -from .too_many_requests_error import TooManyRequestsError -from .unauthorized_error import UnauthorizedError -from .unprocessable_entity_error import UnprocessableEntityError +import typing +from importlib import import_module + +if typing.TYPE_CHECKING: + from .bad_request_error import BadRequestError + from .forbidden_error import ForbiddenError + from .internal_server_error import InternalServerError + from .request_timeout_error import RequestTimeoutError + from .too_many_requests_error import TooManyRequestsError + from .unauthorized_error import UnauthorizedError + from .unprocessable_entity_error import UnprocessableEntityError +_dynamic_imports: typing.Dict[str, str] = { + "BadRequestError": ".bad_request_error", + "ForbiddenError": ".forbidden_error", + "InternalServerError": ".internal_server_error", + "RequestTimeoutError": ".request_timeout_error", + "TooManyRequestsError": ".too_many_requests_error", + "UnauthorizedError": ".unauthorized_error", + "UnprocessableEntityError": ".unprocessable_entity_error", +} + + +def __getattr__(attr_name: str) -> typing.Any: + module_name = _dynamic_imports.get(attr_name) + if module_name is None: + raise AttributeError(f"No {attr_name} found in _dynamic_imports for module name -> {__name__}") + try: + module = import_module(module_name, __package__) + if module_name == f".{attr_name}": + return module + else: + return getattr(module, attr_name) + except ImportError as e: + raise ImportError(f"Failed to import {attr_name} from {module_name}: {e}") from e + except AttributeError as e: + raise AttributeError(f"Failed to get {attr_name} from {module_name}: {e}") from e + + +def __dir__(): + lazy_attrs = list(_dynamic_imports.keys()) + return sorted(lazy_attrs) + __all__ = [ "BadRequestError", diff --git a/src/newscatcher/latestheadlines/__init__.py b/src/newscatcher/latestheadlines/__init__.py index 79cd966..5787d6d 100644 --- a/src/newscatcher/latestheadlines/__init__.py +++ b/src/newscatcher/latestheadlines/__init__.py @@ -2,6 +2,41 @@ # isort: skip_file -from .types import LatestHeadlinesGetRequestClusteringVariable, LatestHeadlinesGetResponse, LatestHeadlinesPostResponse +import typing +from importlib import import_module + +if typing.TYPE_CHECKING: + from .types import ( + LatestHeadlinesGetRequestClusteringVariable, + LatestHeadlinesGetResponse, + LatestHeadlinesPostResponse, + ) +_dynamic_imports: typing.Dict[str, str] = { + "LatestHeadlinesGetRequestClusteringVariable": ".types", + "LatestHeadlinesGetResponse": ".types", + "LatestHeadlinesPostResponse": ".types", +} + + +def __getattr__(attr_name: str) -> typing.Any: + module_name = _dynamic_imports.get(attr_name) + if module_name is None: + raise AttributeError(f"No {attr_name} found in _dynamic_imports for module name -> {__name__}") + try: + module = import_module(module_name, __package__) + if module_name == f".{attr_name}": + return module + else: + return getattr(module, attr_name) + except ImportError as e: + raise ImportError(f"Failed to import {attr_name} from {module_name}: {e}") from e + except AttributeError as e: + raise AttributeError(f"Failed to get {attr_name} from {module_name}: {e}") from e + + +def __dir__(): + lazy_attrs = list(_dynamic_imports.keys()) + return sorted(lazy_attrs) + __all__ = ["LatestHeadlinesGetRequestClusteringVariable", "LatestHeadlinesGetResponse", "LatestHeadlinesPostResponse"] diff --git a/src/newscatcher/latestheadlines/client.py b/src/newscatcher/latestheadlines/client.py index ac90a8e..50778d0 100644 --- a/src/newscatcher/latestheadlines/client.py +++ b/src/newscatcher/latestheadlines/client.py @@ -436,12 +436,27 @@ def get( api_key="YOUR_API_KEY", ) client.latestheadlines.get( + when="7d", + lang="en", + not_lang="fr", + countries="US", + not_countries="UK", predefined_sources="top 100 US, top 5 GB", + sources="nytimes.com", + not_sources="cnn.com", + not_author_name="John Doe", + parent_url="https://www.washingtonpost.com/politics", + all_links="https://aiindex.stanford.edu/report", + all_domain_links="nvidia.com", include_translation_fields=True, include_nlp_data=True, has_nlp=True, theme="Business,Finance", not_theme="Crime", + org_entity_name="Apple", + per_entity_name="Elon Musk", + loc_entity_name="California", + misc_entity_name="Bitcoin", iptc_tags="20000199,20000209", not_iptc_tags="20000205,20000209", iab_tags="Business,Events", @@ -652,10 +667,8 @@ def post( api_key="YOUR_API_KEY", ) client.latestheadlines.post( - lang="en", - predefined_sources=["top 50 US", "top 20 GB"], - is_opinion=False, - page_size=10, + when="7d", + page_size=1, ) """ _response = self._raw_client.post( @@ -1092,12 +1105,27 @@ async def get( async def main() -> None: await client.latestheadlines.get( + when="7d", + lang="en", + not_lang="fr", + countries="US", + not_countries="UK", predefined_sources="top 100 US, top 5 GB", + sources="nytimes.com", + not_sources="cnn.com", + not_author_name="John Doe", + parent_url="https://www.washingtonpost.com/politics", + all_links="https://aiindex.stanford.edu/report", + all_domain_links="nvidia.com", include_translation_fields=True, include_nlp_data=True, has_nlp=True, theme="Business,Finance", not_theme="Crime", + org_entity_name="Apple", + per_entity_name="Elon Musk", + loc_entity_name="California", + misc_entity_name="Bitcoin", iptc_tags="20000199,20000209", not_iptc_tags="20000205,20000209", iab_tags="Business,Events", @@ -1316,10 +1344,8 @@ async def post( async def main() -> None: await client.latestheadlines.post( - lang="en", - predefined_sources=["top 50 US", "top 20 GB"], - is_opinion=False, - page_size=10, + when="7d", + page_size=1, ) diff --git a/src/newscatcher/latestheadlines/types/__init__.py b/src/newscatcher/latestheadlines/types/__init__.py index a3c6699..b476e7a 100644 --- a/src/newscatcher/latestheadlines/types/__init__.py +++ b/src/newscatcher/latestheadlines/types/__init__.py @@ -2,8 +2,39 @@ # isort: skip_file -from .latest_headlines_get_request_clustering_variable import LatestHeadlinesGetRequestClusteringVariable -from .latest_headlines_get_response import LatestHeadlinesGetResponse -from .latest_headlines_post_response import LatestHeadlinesPostResponse +import typing +from importlib import import_module + +if typing.TYPE_CHECKING: + from .latest_headlines_get_request_clustering_variable import LatestHeadlinesGetRequestClusteringVariable + from .latest_headlines_get_response import LatestHeadlinesGetResponse + from .latest_headlines_post_response import LatestHeadlinesPostResponse +_dynamic_imports: typing.Dict[str, str] = { + "LatestHeadlinesGetRequestClusteringVariable": ".latest_headlines_get_request_clustering_variable", + "LatestHeadlinesGetResponse": ".latest_headlines_get_response", + "LatestHeadlinesPostResponse": ".latest_headlines_post_response", +} + + +def __getattr__(attr_name: str) -> typing.Any: + module_name = _dynamic_imports.get(attr_name) + if module_name is None: + raise AttributeError(f"No {attr_name} found in _dynamic_imports for module name -> {__name__}") + try: + module = import_module(module_name, __package__) + if module_name == f".{attr_name}": + return module + else: + return getattr(module, attr_name) + except ImportError as e: + raise ImportError(f"Failed to import {attr_name} from {module_name}: {e}") from e + except AttributeError as e: + raise AttributeError(f"Failed to get {attr_name} from {module_name}: {e}") from e + + +def __dir__(): + lazy_attrs = list(_dynamic_imports.keys()) + return sorted(lazy_attrs) + __all__ = ["LatestHeadlinesGetRequestClusteringVariable", "LatestHeadlinesGetResponse", "LatestHeadlinesPostResponse"] diff --git a/src/newscatcher/search/__init__.py b/src/newscatcher/search/__init__.py index b2bfd44..ded5b09 100644 --- a/src/newscatcher/search/__init__.py +++ b/src/newscatcher/search/__init__.py @@ -2,14 +2,48 @@ # isort: skip_file -from .types import ( - SearchGetRequestClusteringVariable, - SearchGetRequestNewsDomainType, - SearchGetRequestPublishedDatePrecision, - SearchGetRequestSortBy, - SearchGetResponse, - SearchPostResponse, -) +import typing +from importlib import import_module + +if typing.TYPE_CHECKING: + from .types import ( + SearchGetRequestClusteringVariable, + SearchGetRequestNewsDomainType, + SearchGetRequestPublishedDatePrecision, + SearchGetRequestSortBy, + SearchGetResponse, + SearchPostResponse, + ) +_dynamic_imports: typing.Dict[str, str] = { + "SearchGetRequestClusteringVariable": ".types", + "SearchGetRequestNewsDomainType": ".types", + "SearchGetRequestPublishedDatePrecision": ".types", + "SearchGetRequestSortBy": ".types", + "SearchGetResponse": ".types", + "SearchPostResponse": ".types", +} + + +def __getattr__(attr_name: str) -> typing.Any: + module_name = _dynamic_imports.get(attr_name) + if module_name is None: + raise AttributeError(f"No {attr_name} found in _dynamic_imports for module name -> {__name__}") + try: + module = import_module(module_name, __package__) + if module_name == f".{attr_name}": + return module + else: + return getattr(module, attr_name) + except ImportError as e: + raise ImportError(f"Failed to import {attr_name} from {module_name}: {e}") from e + except AttributeError as e: + raise AttributeError(f"Failed to get {attr_name} from {module_name}: {e}") from e + + +def __dir__(): + lazy_attrs = list(_dynamic_imports.keys()) + return sorted(lazy_attrs) + __all__ = [ "SearchGetRequestClusteringVariable", diff --git a/src/newscatcher/search/client.py b/src/newscatcher/search/client.py index 40ea215..f68cc54 100644 --- a/src/newscatcher/search/client.py +++ b/src/newscatcher/search/client.py @@ -1,6 +1,5 @@ # This file was auto-generated by Fern from our API Definition. -import datetime as dt import typing from ..core.client_wrapper import AsyncClientWrapper, SyncClientWrapper @@ -50,6 +49,8 @@ from ..types.q import Q from ..types.ranked_only import RankedOnly from ..types.robots_compliant import RobotsCompliant +from ..types.search_get_request_from import SearchGetRequestFrom +from ..types.search_get_request_to import SearchGetRequestTo from ..types.search_in import SearchIn from ..types.sort_by import SortBy from ..types.source_name import SourceName @@ -91,7 +92,7 @@ def with_raw_response(self) -> RawSearchClient: def get( self, *, - q: str, + q: Q, search_in: typing.Optional[SearchIn] = None, include_translation_fields: typing.Optional[IncludeTranslationFields] = None, predefined_sources: typing.Optional[str] = None, @@ -103,8 +104,8 @@ def get( countries: typing.Optional[str] = None, not_countries: typing.Optional[str] = None, not_author_name: typing.Optional[str] = None, - from_: typing.Optional[dt.datetime] = None, - to: typing.Optional[dt.datetime] = None, + from_: typing.Optional[SearchGetRequestFrom] = None, + to: typing.Optional[SearchGetRequestTo] = None, published_date_precision: typing.Optional[SearchGetRequestPublishedDatePrecision] = None, by_parse_date: typing.Optional[bool] = None, sort_by: typing.Optional[SearchGetRequestSortBy] = None, @@ -154,17 +155,7 @@ def get( Parameters ---------- - q : str - The keyword(s) to search for in articles. Query syntax supports logical operators (`AND`, `OR`, `NOT`) and wildcards: - - - For an exact match, use double quotes. For example, `"technology news"`. - - Use `*` to search for any keyword. - - Use `+` to include and `-` to exclude specific words or phrases. - For example, `+Apple`, `-Google`. - - Use `AND`, `OR`, and `NOT` to refine search results. - For example, `technology AND (Apple OR Microsoft) NOT Google`. - - For more details, see [Advanced querying](/docs/v3/documentation/guides-and-concepts/advanced-querying). + q : Q search_in : typing.Optional[SearchIn] @@ -233,7 +224,7 @@ def get( Example: `"John Doe, Jane Doe"` - from_ : typing.Optional[dt.datetime] + from_ : typing.Optional[SearchGetRequestFrom] The starting point in time to search from. Accepts date-time strings in ISO 8601 format and plain text. The default time zone is UTC. Formats with examples: @@ -245,7 +236,7 @@ def get( **Note**: By default, applied to the publication date of the article. To use the article's parse date instead, set the `by_parse_date` parameter to `true`. - to : typing.Optional[dt.datetime] + to : typing.Optional[SearchGetRequestTo] The ending point in time to search up to. Accepts date-time strings in ISO 8601 format and plain text. The default time zone is UTC. Formats with examples: @@ -551,20 +542,36 @@ def get( api_key="YOUR_API_KEY", ) client.search.get( - q="technology AND (Apple OR Microsoft) NOT Google", + q='"supply chain" AND Amazon NOT China', search_in="title_content, title_content_translated", include_translation_fields=True, predefined_sources="top 100 US, top 5 GB", + source_name="sport", + sources="nytimes.com", + not_sources="cnn.com", + lang="en", + not_lang="fr", + countries="US", + not_countries="UK", + not_author_name="John Doe", from_=datetime.datetime.fromisoformat( "2024-07-01 00:00:00+00:00", ), to=datetime.datetime.fromisoformat( "2024-07-01 00:00:00+00:00", ), + parent_url="https://www.washingtonpost.com/politics", + all_links="https://aiindex.stanford.edu/report", + all_domain_links="nvidia.com", + news_type="General News Outlets", include_nlp_data=True, has_nlp=True, theme="Business,Finance", not_theme="Crime", + org_entity_name="Apple", + per_entity_name="Elon Musk", + loc_entity_name="California", + misc_entity_name="Bitcoin", iptc_tags="20000199,20000209", not_iptc_tags="20000205,20000209", iab_tags="Business,Events", @@ -821,25 +828,14 @@ def post( Examples -------- - import datetime - from newscatcher import NewscatcherApi client = NewscatcherApi( api_key="YOUR_API_KEY", ) client.search.post( - q="renewable energy", - predefined_sources=["top 50 US"], - lang=["en"], - from_=datetime.datetime.fromisoformat( - "2024-01-01 00:00:00+00:00", - ), - to=datetime.datetime.fromisoformat( - "2024-06-30 00:00:00+00:00", - ), - additional_domain_info=True, - is_news_domain=True, + q='"supply chain" AND Amazon NOT China', + page_size=1, ) """ _response = self._raw_client.post( @@ -922,7 +918,7 @@ def with_raw_response(self) -> AsyncRawSearchClient: async def get( self, *, - q: str, + q: Q, search_in: typing.Optional[SearchIn] = None, include_translation_fields: typing.Optional[IncludeTranslationFields] = None, predefined_sources: typing.Optional[str] = None, @@ -934,8 +930,8 @@ async def get( countries: typing.Optional[str] = None, not_countries: typing.Optional[str] = None, not_author_name: typing.Optional[str] = None, - from_: typing.Optional[dt.datetime] = None, - to: typing.Optional[dt.datetime] = None, + from_: typing.Optional[SearchGetRequestFrom] = None, + to: typing.Optional[SearchGetRequestTo] = None, published_date_precision: typing.Optional[SearchGetRequestPublishedDatePrecision] = None, by_parse_date: typing.Optional[bool] = None, sort_by: typing.Optional[SearchGetRequestSortBy] = None, @@ -985,17 +981,7 @@ async def get( Parameters ---------- - q : str - The keyword(s) to search for in articles. Query syntax supports logical operators (`AND`, `OR`, `NOT`) and wildcards: - - - For an exact match, use double quotes. For example, `"technology news"`. - - Use `*` to search for any keyword. - - Use `+` to include and `-` to exclude specific words or phrases. - For example, `+Apple`, `-Google`. - - Use `AND`, `OR`, and `NOT` to refine search results. - For example, `technology AND (Apple OR Microsoft) NOT Google`. - - For more details, see [Advanced querying](/docs/v3/documentation/guides-and-concepts/advanced-querying). + q : Q search_in : typing.Optional[SearchIn] @@ -1064,7 +1050,7 @@ async def get( Example: `"John Doe, Jane Doe"` - from_ : typing.Optional[dt.datetime] + from_ : typing.Optional[SearchGetRequestFrom] The starting point in time to search from. Accepts date-time strings in ISO 8601 format and plain text. The default time zone is UTC. Formats with examples: @@ -1076,7 +1062,7 @@ async def get( **Note**: By default, applied to the publication date of the article. To use the article's parse date instead, set the `by_parse_date` parameter to `true`. - to : typing.Optional[dt.datetime] + to : typing.Optional[SearchGetRequestTo] The ending point in time to search up to. Accepts date-time strings in ISO 8601 format and plain text. The default time zone is UTC. Formats with examples: @@ -1386,20 +1372,36 @@ async def get( async def main() -> None: await client.search.get( - q="technology AND (Apple OR Microsoft) NOT Google", + q='"supply chain" AND Amazon NOT China', search_in="title_content, title_content_translated", include_translation_fields=True, predefined_sources="top 100 US, top 5 GB", + source_name="sport", + sources="nytimes.com", + not_sources="cnn.com", + lang="en", + not_lang="fr", + countries="US", + not_countries="UK", + not_author_name="John Doe", from_=datetime.datetime.fromisoformat( "2024-07-01 00:00:00+00:00", ), to=datetime.datetime.fromisoformat( "2024-07-01 00:00:00+00:00", ), + parent_url="https://www.washingtonpost.com/politics", + all_links="https://aiindex.stanford.edu/report", + all_domain_links="nvidia.com", + news_type="General News Outlets", include_nlp_data=True, has_nlp=True, theme="Business,Finance", not_theme="Crime", + org_entity_name="Apple", + per_entity_name="Elon Musk", + loc_entity_name="California", + misc_entity_name="Bitcoin", iptc_tags="20000199,20000209", not_iptc_tags="20000205,20000209", iab_tags="Business,Events", @@ -1660,7 +1662,6 @@ async def post( Examples -------- import asyncio - import datetime from newscatcher import AsyncNewscatcherApi @@ -1671,17 +1672,8 @@ async def post( async def main() -> None: await client.search.post( - q="renewable energy", - predefined_sources=["top 50 US"], - lang=["en"], - from_=datetime.datetime.fromisoformat( - "2024-01-01 00:00:00+00:00", - ), - to=datetime.datetime.fromisoformat( - "2024-06-30 00:00:00+00:00", - ), - additional_domain_info=True, - is_news_domain=True, + q='"supply chain" AND Amazon NOT China', + page_size=1, ) diff --git a/src/newscatcher/search/raw_client.py b/src/newscatcher/search/raw_client.py index ec01ca2..014b13b 100644 --- a/src/newscatcher/search/raw_client.py +++ b/src/newscatcher/search/raw_client.py @@ -1,12 +1,10 @@ # This file was auto-generated by Fern from our API Definition. -import datetime as dt import typing from json.decoder import JSONDecodeError from ..core.api_error import ApiError from ..core.client_wrapper import AsyncClientWrapper, SyncClientWrapper -from ..core.datetime_utils import serialize_datetime from ..core.http_response import AsyncHttpResponse, HttpResponse from ..core.pydantic_utilities import parse_obj_as from ..core.request_options import RequestOptions @@ -64,6 +62,8 @@ from ..types.q import Q from ..types.ranked_only import RankedOnly from ..types.robots_compliant import RobotsCompliant +from ..types.search_get_request_from import SearchGetRequestFrom +from ..types.search_get_request_to import SearchGetRequestTo from ..types.search_in import SearchIn from ..types.sort_by import SortBy from ..types.source_name import SourceName @@ -93,7 +93,7 @@ def __init__(self, *, client_wrapper: SyncClientWrapper): def get( self, *, - q: str, + q: Q, search_in: typing.Optional[SearchIn] = None, include_translation_fields: typing.Optional[IncludeTranslationFields] = None, predefined_sources: typing.Optional[str] = None, @@ -105,8 +105,8 @@ def get( countries: typing.Optional[str] = None, not_countries: typing.Optional[str] = None, not_author_name: typing.Optional[str] = None, - from_: typing.Optional[dt.datetime] = None, - to: typing.Optional[dt.datetime] = None, + from_: typing.Optional[SearchGetRequestFrom] = None, + to: typing.Optional[SearchGetRequestTo] = None, published_date_precision: typing.Optional[SearchGetRequestPublishedDatePrecision] = None, by_parse_date: typing.Optional[bool] = None, sort_by: typing.Optional[SearchGetRequestSortBy] = None, @@ -156,17 +156,7 @@ def get( Parameters ---------- - q : str - The keyword(s) to search for in articles. Query syntax supports logical operators (`AND`, `OR`, `NOT`) and wildcards: - - - For an exact match, use double quotes. For example, `"technology news"`. - - Use `*` to search for any keyword. - - Use `+` to include and `-` to exclude specific words or phrases. - For example, `+Apple`, `-Google`. - - Use `AND`, `OR`, and `NOT` to refine search results. - For example, `technology AND (Apple OR Microsoft) NOT Google`. - - For more details, see [Advanced querying](/docs/v3/documentation/guides-and-concepts/advanced-querying). + q : Q search_in : typing.Optional[SearchIn] @@ -235,7 +225,7 @@ def get( Example: `"John Doe, Jane Doe"` - from_ : typing.Optional[dt.datetime] + from_ : typing.Optional[SearchGetRequestFrom] The starting point in time to search from. Accepts date-time strings in ISO 8601 format and plain text. The default time zone is UTC. Formats with examples: @@ -247,7 +237,7 @@ def get( **Note**: By default, applied to the publication date of the article. To use the article's parse date instead, set the `by_parse_date` parameter to `true`. - to : typing.Optional[dt.datetime] + to : typing.Optional[SearchGetRequestTo] The ending point in time to search up to. Accepts date-time strings in ISO 8601 format and plain text. The default time zone is UTC. Formats with examples: @@ -559,8 +549,12 @@ def get( "countries": countries, "not_countries": not_countries, "not_author_name": not_author_name, - "from_": serialize_datetime(from_) if from_ is not None else None, - "to_": serialize_datetime(to) if to is not None else None, + "from_": convert_and_respect_annotation_metadata( + object_=from_, annotation=SearchGetRequestFrom, direction="write" + ), + "to_": convert_and_respect_annotation_metadata( + object_=to, annotation=SearchGetRequestTo, direction="write" + ), "published_date_precision": published_date_precision, "by_parse_date": by_parse_date, "sort_by": sort_by, @@ -1087,7 +1081,7 @@ def __init__(self, *, client_wrapper: AsyncClientWrapper): async def get( self, *, - q: str, + q: Q, search_in: typing.Optional[SearchIn] = None, include_translation_fields: typing.Optional[IncludeTranslationFields] = None, predefined_sources: typing.Optional[str] = None, @@ -1099,8 +1093,8 @@ async def get( countries: typing.Optional[str] = None, not_countries: typing.Optional[str] = None, not_author_name: typing.Optional[str] = None, - from_: typing.Optional[dt.datetime] = None, - to: typing.Optional[dt.datetime] = None, + from_: typing.Optional[SearchGetRequestFrom] = None, + to: typing.Optional[SearchGetRequestTo] = None, published_date_precision: typing.Optional[SearchGetRequestPublishedDatePrecision] = None, by_parse_date: typing.Optional[bool] = None, sort_by: typing.Optional[SearchGetRequestSortBy] = None, @@ -1150,17 +1144,7 @@ async def get( Parameters ---------- - q : str - The keyword(s) to search for in articles. Query syntax supports logical operators (`AND`, `OR`, `NOT`) and wildcards: - - - For an exact match, use double quotes. For example, `"technology news"`. - - Use `*` to search for any keyword. - - Use `+` to include and `-` to exclude specific words or phrases. - For example, `+Apple`, `-Google`. - - Use `AND`, `OR`, and `NOT` to refine search results. - For example, `technology AND (Apple OR Microsoft) NOT Google`. - - For more details, see [Advanced querying](/docs/v3/documentation/guides-and-concepts/advanced-querying). + q : Q search_in : typing.Optional[SearchIn] @@ -1229,7 +1213,7 @@ async def get( Example: `"John Doe, Jane Doe"` - from_ : typing.Optional[dt.datetime] + from_ : typing.Optional[SearchGetRequestFrom] The starting point in time to search from. Accepts date-time strings in ISO 8601 format and plain text. The default time zone is UTC. Formats with examples: @@ -1241,7 +1225,7 @@ async def get( **Note**: By default, applied to the publication date of the article. To use the article's parse date instead, set the `by_parse_date` parameter to `true`. - to : typing.Optional[dt.datetime] + to : typing.Optional[SearchGetRequestTo] The ending point in time to search up to. Accepts date-time strings in ISO 8601 format and plain text. The default time zone is UTC. Formats with examples: @@ -1553,8 +1537,12 @@ async def get( "countries": countries, "not_countries": not_countries, "not_author_name": not_author_name, - "from_": serialize_datetime(from_) if from_ is not None else None, - "to_": serialize_datetime(to) if to is not None else None, + "from_": convert_and_respect_annotation_metadata( + object_=from_, annotation=SearchGetRequestFrom, direction="write" + ), + "to_": convert_and_respect_annotation_metadata( + object_=to, annotation=SearchGetRequestTo, direction="write" + ), "published_date_precision": published_date_precision, "by_parse_date": by_parse_date, "sort_by": sort_by, diff --git a/src/newscatcher/search/types/__init__.py b/src/newscatcher/search/types/__init__.py index e6be70d..240256e 100644 --- a/src/newscatcher/search/types/__init__.py +++ b/src/newscatcher/search/types/__init__.py @@ -2,12 +2,46 @@ # isort: skip_file -from .search_get_request_clustering_variable import SearchGetRequestClusteringVariable -from .search_get_request_news_domain_type import SearchGetRequestNewsDomainType -from .search_get_request_published_date_precision import SearchGetRequestPublishedDatePrecision -from .search_get_request_sort_by import SearchGetRequestSortBy -from .search_get_response import SearchGetResponse -from .search_post_response import SearchPostResponse +import typing +from importlib import import_module + +if typing.TYPE_CHECKING: + from .search_get_request_clustering_variable import SearchGetRequestClusteringVariable + from .search_get_request_news_domain_type import SearchGetRequestNewsDomainType + from .search_get_request_published_date_precision import SearchGetRequestPublishedDatePrecision + from .search_get_request_sort_by import SearchGetRequestSortBy + from .search_get_response import SearchGetResponse + from .search_post_response import SearchPostResponse +_dynamic_imports: typing.Dict[str, str] = { + "SearchGetRequestClusteringVariable": ".search_get_request_clustering_variable", + "SearchGetRequestNewsDomainType": ".search_get_request_news_domain_type", + "SearchGetRequestPublishedDatePrecision": ".search_get_request_published_date_precision", + "SearchGetRequestSortBy": ".search_get_request_sort_by", + "SearchGetResponse": ".search_get_response", + "SearchPostResponse": ".search_post_response", +} + + +def __getattr__(attr_name: str) -> typing.Any: + module_name = _dynamic_imports.get(attr_name) + if module_name is None: + raise AttributeError(f"No {attr_name} found in _dynamic_imports for module name -> {__name__}") + try: + module = import_module(module_name, __package__) + if module_name == f".{attr_name}": + return module + else: + return getattr(module, attr_name) + except ImportError as e: + raise ImportError(f"Failed to import {attr_name} from {module_name}: {e}") from e + except AttributeError as e: + raise AttributeError(f"Failed to get {attr_name} from {module_name}: {e}") from e + + +def __dir__(): + lazy_attrs = list(_dynamic_imports.keys()) + return sorted(lazy_attrs) + __all__ = [ "SearchGetRequestClusteringVariable", diff --git a/src/newscatcher/search_link/client.py b/src/newscatcher/search_link/client.py index e0ad325..ffe191e 100644 --- a/src/newscatcher/search_link/client.py +++ b/src/newscatcher/search_link/client.py @@ -11,6 +11,7 @@ from ..types.page_size import PageSize from ..types.robots_compliant import RobotsCompliant from ..types.search_response_dto import SearchResponseDto +from ..types.source import Source from ..types.to import To from .raw_client import AsyncRawSearchLinkClient, RawSearchLinkClient @@ -38,6 +39,7 @@ def search_url_get( *, ids: typing.Optional[str] = None, links: typing.Optional[str] = None, + source: typing.Optional[Source] = None, from_: typing.Optional[From] = None, to: typing.Optional[To] = None, page: typing.Optional[int] = None, @@ -64,6 +66,8 @@ def search_url_get( **Caution**: You can use either the `links` or the `ids` parameter, but not both at the same time. + source : typing.Optional[Source] + from_ : typing.Optional[From] to : typing.Optional[To] @@ -97,6 +101,9 @@ def search_url_get( api_key="YOUR_API_KEY", ) client.search_link.search_url_get( + ids="5f8d0d55b6e45e00179c6e7e", + links="https://nytimes.com/article1", + source="articles.id,articles.title,articles.link,articles.published_date", from_=datetime.datetime.fromisoformat( "2024-07-01 00:00:00+00:00", ), @@ -108,6 +115,7 @@ def search_url_get( _response = self._raw_client.search_url_get( ids=ids, links=links, + source=source, from_=from_, to=to, page=page, @@ -122,6 +130,7 @@ def search_url_post( *, ids: typing.Optional[Ids] = OMIT, links: typing.Optional[Links] = OMIT, + source: typing.Optional[Source] = OMIT, from_: typing.Optional[From] = OMIT, to: typing.Optional[To] = OMIT, page: typing.Optional[Page] = OMIT, @@ -138,6 +147,8 @@ def search_url_post( links : typing.Optional[Links] + source : typing.Optional[Source] + from_ : typing.Optional[From] The starting point in time to search from. Accepts date-time strings in ISO 8601 format and plain text strings. The default time zone is UTC. @@ -180,19 +191,14 @@ def search_url_post( api_key="YOUR_API_KEY", ) client.search_link.search_url_post( - ids=[ - "8ea8a784568ffaa05cb6d1ab2d2e84dd", - "0146a551ef05ab1c494a55e806e3ce64", - ], - links=[ - "https://www.nytimes.com/2024/08/30/technology/ai-chatbot-chatgpt-manipulation.html", - "https://www.bbc.com/news/articles/c39k379grzlo", - ], + links="https://www.reuters.com/business/energy/oil-prices-up-after-israeli-attacks-oversupply-caps-gains-2025-09-10/", + source="articles.id,articles.title,articles.link,articles.canonical_url", ) """ _response = self._raw_client.search_url_post( ids=ids, links=links, + source=source, from_=from_, to=to, page=page, @@ -223,6 +229,7 @@ async def search_url_get( *, ids: typing.Optional[str] = None, links: typing.Optional[str] = None, + source: typing.Optional[Source] = None, from_: typing.Optional[From] = None, to: typing.Optional[To] = None, page: typing.Optional[int] = None, @@ -249,6 +256,8 @@ async def search_url_get( **Caution**: You can use either the `links` or the `ids` parameter, but not both at the same time. + source : typing.Optional[Source] + from_ : typing.Optional[From] to : typing.Optional[To] @@ -286,6 +295,9 @@ async def search_url_get( async def main() -> None: await client.search_link.search_url_get( + ids="5f8d0d55b6e45e00179c6e7e", + links="https://nytimes.com/article1", + source="articles.id,articles.title,articles.link,articles.published_date", from_=datetime.datetime.fromisoformat( "2024-07-01 00:00:00+00:00", ), @@ -300,6 +312,7 @@ async def main() -> None: _response = await self._raw_client.search_url_get( ids=ids, links=links, + source=source, from_=from_, to=to, page=page, @@ -314,6 +327,7 @@ async def search_url_post( *, ids: typing.Optional[Ids] = OMIT, links: typing.Optional[Links] = OMIT, + source: typing.Optional[Source] = OMIT, from_: typing.Optional[From] = OMIT, to: typing.Optional[To] = OMIT, page: typing.Optional[Page] = OMIT, @@ -330,6 +344,8 @@ async def search_url_post( links : typing.Optional[Links] + source : typing.Optional[Source] + from_ : typing.Optional[From] The starting point in time to search from. Accepts date-time strings in ISO 8601 format and plain text strings. The default time zone is UTC. @@ -377,14 +393,8 @@ async def search_url_post( async def main() -> None: await client.search_link.search_url_post( - ids=[ - "8ea8a784568ffaa05cb6d1ab2d2e84dd", - "0146a551ef05ab1c494a55e806e3ce64", - ], - links=[ - "https://www.nytimes.com/2024/08/30/technology/ai-chatbot-chatgpt-manipulation.html", - "https://www.bbc.com/news/articles/c39k379grzlo", - ], + links="https://www.reuters.com/business/energy/oil-prices-up-after-israeli-attacks-oversupply-caps-gains-2025-09-10/", + source="articles.id,articles.title,articles.link,articles.canonical_url", ) @@ -393,6 +403,7 @@ async def main() -> None: _response = await self._raw_client.search_url_post( ids=ids, links=links, + source=source, from_=from_, to=to, page=page, diff --git a/src/newscatcher/search_link/raw_client.py b/src/newscatcher/search_link/raw_client.py index 9823286..cab7513 100644 --- a/src/newscatcher/search_link/raw_client.py +++ b/src/newscatcher/search_link/raw_client.py @@ -24,6 +24,7 @@ from ..types.page_size import PageSize from ..types.robots_compliant import RobotsCompliant from ..types.search_response_dto import SearchResponseDto +from ..types.source import Source from ..types.to import To # this is used as the default value for optional parameters @@ -39,6 +40,7 @@ def search_url_get( *, ids: typing.Optional[str] = None, links: typing.Optional[str] = None, + source: typing.Optional[Source] = None, from_: typing.Optional[From] = None, to: typing.Optional[To] = None, page: typing.Optional[int] = None, @@ -65,6 +67,8 @@ def search_url_get( **Caution**: You can use either the `links` or the `ids` parameter, but not both at the same time. + source : typing.Optional[Source] + from_ : typing.Optional[From] to : typing.Optional[To] @@ -94,6 +98,7 @@ def search_url_get( params={ "ids": ids, "links": links, + "_source": source, "from_": convert_and_respect_annotation_metadata(object_=from_, annotation=From, direction="write"), "to_": convert_and_respect_annotation_metadata(object_=to, annotation=To, direction="write"), "page": page, @@ -199,6 +204,7 @@ def search_url_post( *, ids: typing.Optional[Ids] = OMIT, links: typing.Optional[Links] = OMIT, + source: typing.Optional[Source] = OMIT, from_: typing.Optional[From] = OMIT, to: typing.Optional[To] = OMIT, page: typing.Optional[Page] = OMIT, @@ -215,6 +221,8 @@ def search_url_post( links : typing.Optional[Links] + source : typing.Optional[Source] + from_ : typing.Optional[From] The starting point in time to search from. Accepts date-time strings in ISO 8601 format and plain text strings. The default time zone is UTC. @@ -255,6 +263,7 @@ def search_url_post( json={ "ids": convert_and_respect_annotation_metadata(object_=ids, annotation=Ids, direction="write"), "links": convert_and_respect_annotation_metadata(object_=links, annotation=Links, direction="write"), + "_source": source, "from_": convert_and_respect_annotation_metadata(object_=from_, annotation=From, direction="write"), "to_": convert_and_respect_annotation_metadata(object_=to, annotation=To, direction="write"), "page": page, @@ -369,6 +378,7 @@ async def search_url_get( *, ids: typing.Optional[str] = None, links: typing.Optional[str] = None, + source: typing.Optional[Source] = None, from_: typing.Optional[From] = None, to: typing.Optional[To] = None, page: typing.Optional[int] = None, @@ -395,6 +405,8 @@ async def search_url_get( **Caution**: You can use either the `links` or the `ids` parameter, but not both at the same time. + source : typing.Optional[Source] + from_ : typing.Optional[From] to : typing.Optional[To] @@ -424,6 +436,7 @@ async def search_url_get( params={ "ids": ids, "links": links, + "_source": source, "from_": convert_and_respect_annotation_metadata(object_=from_, annotation=From, direction="write"), "to_": convert_and_respect_annotation_metadata(object_=to, annotation=To, direction="write"), "page": page, @@ -529,6 +542,7 @@ async def search_url_post( *, ids: typing.Optional[Ids] = OMIT, links: typing.Optional[Links] = OMIT, + source: typing.Optional[Source] = OMIT, from_: typing.Optional[From] = OMIT, to: typing.Optional[To] = OMIT, page: typing.Optional[Page] = OMIT, @@ -545,6 +559,8 @@ async def search_url_post( links : typing.Optional[Links] + source : typing.Optional[Source] + from_ : typing.Optional[From] The starting point in time to search from. Accepts date-time strings in ISO 8601 format and plain text strings. The default time zone is UTC. @@ -585,6 +601,7 @@ async def search_url_post( json={ "ids": convert_and_respect_annotation_metadata(object_=ids, annotation=Ids, direction="write"), "links": convert_and_respect_annotation_metadata(object_=links, annotation=Links, direction="write"), + "_source": source, "from_": convert_and_respect_annotation_metadata(object_=from_, annotation=From, direction="write"), "to_": convert_and_respect_annotation_metadata(object_=to, annotation=To, direction="write"), "page": page, diff --git a/src/newscatcher/searchsimilar/__init__.py b/src/newscatcher/searchsimilar/__init__.py index da93e88..b90322f 100644 --- a/src/newscatcher/searchsimilar/__init__.py +++ b/src/newscatcher/searchsimilar/__init__.py @@ -2,12 +2,44 @@ # isort: skip_file -from .types import ( - SearchSimilarGetRequestPublishedDatePrecision, - SearchSimilarGetRequestSortBy, - SearchSimilarGetResponse, - SearchSimilarPostResponse, -) +import typing +from importlib import import_module + +if typing.TYPE_CHECKING: + from .types import ( + SearchSimilarGetRequestPublishedDatePrecision, + SearchSimilarGetRequestSortBy, + SearchSimilarGetResponse, + SearchSimilarPostResponse, + ) +_dynamic_imports: typing.Dict[str, str] = { + "SearchSimilarGetRequestPublishedDatePrecision": ".types", + "SearchSimilarGetRequestSortBy": ".types", + "SearchSimilarGetResponse": ".types", + "SearchSimilarPostResponse": ".types", +} + + +def __getattr__(attr_name: str) -> typing.Any: + module_name = _dynamic_imports.get(attr_name) + if module_name is None: + raise AttributeError(f"No {attr_name} found in _dynamic_imports for module name -> {__name__}") + try: + module = import_module(module_name, __package__) + if module_name == f".{attr_name}": + return module + else: + return getattr(module, attr_name) + except ImportError as e: + raise ImportError(f"Failed to import {attr_name} from {module_name}: {e}") from e + except AttributeError as e: + raise AttributeError(f"Failed to get {attr_name} from {module_name}: {e}") from e + + +def __dir__(): + lazy_attrs = list(_dynamic_imports.keys()) + return sorted(lazy_attrs) + __all__ = [ "SearchSimilarGetRequestPublishedDatePrecision", diff --git a/src/newscatcher/searchsimilar/client.py b/src/newscatcher/searchsimilar/client.py index e5659d4..5af7d63 100644 --- a/src/newscatcher/searchsimilar/client.py +++ b/src/newscatcher/searchsimilar/client.py @@ -1,6 +1,5 @@ # This file was auto-generated by Fern from our API Definition. -import datetime as dt import typing from ..core.client_wrapper import AsyncClientWrapper, SyncClientWrapper @@ -38,6 +37,8 @@ from ..types.ranked_only import RankedOnly from ..types.robots_compliant import RobotsCompliant from ..types.search_in import SearchIn +from ..types.search_similar_get_request_from import SearchSimilarGetRequestFrom +from ..types.search_similar_get_request_to import SearchSimilarGetRequestTo from ..types.similar_documents_fields import SimilarDocumentsFields from ..types.similar_documents_number import SimilarDocumentsNumber from ..types.sort_by import SortBy @@ -77,7 +78,7 @@ def with_raw_response(self) -> RawSearchsimilarClient: def get( self, *, - q: str, + q: Q, search_in: typing.Optional[SearchIn] = None, include_translation_fields: typing.Optional[IncludeTranslationFields] = None, include_similar_documents: typing.Optional[bool] = None, @@ -90,8 +91,8 @@ def get( not_lang: typing.Optional[str] = None, countries: typing.Optional[str] = None, not_countries: typing.Optional[str] = None, - from_: typing.Optional[dt.datetime] = None, - to: typing.Optional[dt.datetime] = None, + from_: typing.Optional[SearchSimilarGetRequestFrom] = None, + to: typing.Optional[SearchSimilarGetRequestTo] = None, by_parse_date: typing.Optional[bool] = None, published_date_precision: typing.Optional[SearchSimilarGetRequestPublishedDatePrecision] = None, sort_by: typing.Optional[SearchSimilarGetRequestSortBy] = None, @@ -128,17 +129,7 @@ def get( Parameters ---------- - q : str - The keyword(s) to search for in articles. Query syntax supports logical operators (`AND`, `OR`, `NOT`) and wildcards: - - - For an exact match, use double quotes. For example, `"technology news"`. - - Use `*` to search for any keyword. - - Use `+` to include and `-` to exclude specific words or phrases. - For example, `+Apple`, `-Google`. - - Use `AND`, `OR`, and `NOT` to refine search results. - For example, `technology AND (Apple OR Microsoft) NOT Google`. - - For more details, see [Advanced querying](/docs/v3/documentation/guides-and-concepts/advanced-querying). + q : Q search_in : typing.Optional[SearchIn] @@ -204,7 +195,7 @@ def get( To learn more, see [Enumerated parameters > Country](/docs/v3/api-reference/overview/enumerated-parameters#country-country-and-not-country). - from_ : typing.Optional[dt.datetime] + from_ : typing.Optional[SearchSimilarGetRequestFrom] The starting point in time to search from. Accepts date-time strings in ISO 8601 format and plain text. The default time zone is UTC. Formats with examples: @@ -216,7 +207,7 @@ def get( **Note**: By default, applied to the publication date of the article. To use the article's parse date instead, set the `by_parse_date` parameter to `true`. - to : typing.Optional[dt.datetime] + to : typing.Optional[SearchSimilarGetRequestTo] The ending point in time to search up to. Accepts date-time strings in ISO 8601 format and plain text. The default time zone is UTC. Formats with examples: @@ -419,17 +410,26 @@ def get( api_key="YOUR_API_KEY", ) client.searchsimilar.get( - q="technology AND (Apple OR Microsoft) NOT Google", + q='"supply chain" AND Amazon NOT China', search_in="title_content, title_content_translated", include_translation_fields=True, similar_documents_fields="title,summary", predefined_sources="top 100 US, top 5 GB", + sources="nytimes.com", + not_sources="cnn.com", + lang="en", + not_lang="fr", + countries="US", + not_countries="UK", from_=datetime.datetime.fromisoformat( "2024-07-01 00:00:00+00:00", ), to=datetime.datetime.fromisoformat( "2024-07-01 00:00:00+00:00", ), + parent_url="https://www.washingtonpost.com/politics", + all_links="https://aiindex.stanford.edu/report", + all_domain_links="nvidia.com", include_nlp_data=True, has_nlp=True, theme="Business,Finance", @@ -647,9 +647,10 @@ def post( api_key="YOUR_API_KEY", ) client.searchsimilar.post( - q="artificial intelligence", + q='"supply chain" AND Amazon NOT China', include_similar_documents=True, similar_documents_number=5, + page_size=10, ) """ _response = self._raw_client.post( @@ -720,7 +721,7 @@ def with_raw_response(self) -> AsyncRawSearchsimilarClient: async def get( self, *, - q: str, + q: Q, search_in: typing.Optional[SearchIn] = None, include_translation_fields: typing.Optional[IncludeTranslationFields] = None, include_similar_documents: typing.Optional[bool] = None, @@ -733,8 +734,8 @@ async def get( not_lang: typing.Optional[str] = None, countries: typing.Optional[str] = None, not_countries: typing.Optional[str] = None, - from_: typing.Optional[dt.datetime] = None, - to: typing.Optional[dt.datetime] = None, + from_: typing.Optional[SearchSimilarGetRequestFrom] = None, + to: typing.Optional[SearchSimilarGetRequestTo] = None, by_parse_date: typing.Optional[bool] = None, published_date_precision: typing.Optional[SearchSimilarGetRequestPublishedDatePrecision] = None, sort_by: typing.Optional[SearchSimilarGetRequestSortBy] = None, @@ -771,17 +772,7 @@ async def get( Parameters ---------- - q : str - The keyword(s) to search for in articles. Query syntax supports logical operators (`AND`, `OR`, `NOT`) and wildcards: - - - For an exact match, use double quotes. For example, `"technology news"`. - - Use `*` to search for any keyword. - - Use `+` to include and `-` to exclude specific words or phrases. - For example, `+Apple`, `-Google`. - - Use `AND`, `OR`, and `NOT` to refine search results. - For example, `technology AND (Apple OR Microsoft) NOT Google`. - - For more details, see [Advanced querying](/docs/v3/documentation/guides-and-concepts/advanced-querying). + q : Q search_in : typing.Optional[SearchIn] @@ -847,7 +838,7 @@ async def get( To learn more, see [Enumerated parameters > Country](/docs/v3/api-reference/overview/enumerated-parameters#country-country-and-not-country). - from_ : typing.Optional[dt.datetime] + from_ : typing.Optional[SearchSimilarGetRequestFrom] The starting point in time to search from. Accepts date-time strings in ISO 8601 format and plain text. The default time zone is UTC. Formats with examples: @@ -859,7 +850,7 @@ async def get( **Note**: By default, applied to the publication date of the article. To use the article's parse date instead, set the `by_parse_date` parameter to `true`. - to : typing.Optional[dt.datetime] + to : typing.Optional[SearchSimilarGetRequestTo] The ending point in time to search up to. Accepts date-time strings in ISO 8601 format and plain text. The default time zone is UTC. Formats with examples: @@ -1066,17 +1057,26 @@ async def get( async def main() -> None: await client.searchsimilar.get( - q="technology AND (Apple OR Microsoft) NOT Google", + q='"supply chain" AND Amazon NOT China', search_in="title_content, title_content_translated", include_translation_fields=True, similar_documents_fields="title,summary", predefined_sources="top 100 US, top 5 GB", + sources="nytimes.com", + not_sources="cnn.com", + lang="en", + not_lang="fr", + countries="US", + not_countries="UK", from_=datetime.datetime.fromisoformat( "2024-07-01 00:00:00+00:00", ), to=datetime.datetime.fromisoformat( "2024-07-01 00:00:00+00:00", ), + parent_url="https://www.washingtonpost.com/politics", + all_links="https://aiindex.stanford.edu/report", + all_domain_links="nvidia.com", include_nlp_data=True, has_nlp=True, theme="Business,Finance", @@ -1302,9 +1302,10 @@ async def post( async def main() -> None: await client.searchsimilar.post( - q="artificial intelligence", + q='"supply chain" AND Amazon NOT China', include_similar_documents=True, similar_documents_number=5, + page_size=10, ) diff --git a/src/newscatcher/searchsimilar/raw_client.py b/src/newscatcher/searchsimilar/raw_client.py index 68c4769..7af2fe1 100644 --- a/src/newscatcher/searchsimilar/raw_client.py +++ b/src/newscatcher/searchsimilar/raw_client.py @@ -1,12 +1,10 @@ # This file was auto-generated by Fern from our API Definition. -import datetime as dt import typing from json.decoder import JSONDecodeError from ..core.api_error import ApiError from ..core.client_wrapper import AsyncClientWrapper, SyncClientWrapper -from ..core.datetime_utils import serialize_datetime from ..core.http_response import AsyncHttpResponse, HttpResponse from ..core.pydantic_utilities import parse_obj_as from ..core.request_options import RequestOptions @@ -52,6 +50,8 @@ from ..types.ranked_only import RankedOnly from ..types.robots_compliant import RobotsCompliant from ..types.search_in import SearchIn +from ..types.search_similar_get_request_from import SearchSimilarGetRequestFrom +from ..types.search_similar_get_request_to import SearchSimilarGetRequestTo from ..types.similar_documents_fields import SimilarDocumentsFields from ..types.similar_documents_number import SimilarDocumentsNumber from ..types.sort_by import SortBy @@ -79,7 +79,7 @@ def __init__(self, *, client_wrapper: SyncClientWrapper): def get( self, *, - q: str, + q: Q, search_in: typing.Optional[SearchIn] = None, include_translation_fields: typing.Optional[IncludeTranslationFields] = None, include_similar_documents: typing.Optional[bool] = None, @@ -92,8 +92,8 @@ def get( not_lang: typing.Optional[str] = None, countries: typing.Optional[str] = None, not_countries: typing.Optional[str] = None, - from_: typing.Optional[dt.datetime] = None, - to: typing.Optional[dt.datetime] = None, + from_: typing.Optional[SearchSimilarGetRequestFrom] = None, + to: typing.Optional[SearchSimilarGetRequestTo] = None, by_parse_date: typing.Optional[bool] = None, published_date_precision: typing.Optional[SearchSimilarGetRequestPublishedDatePrecision] = None, sort_by: typing.Optional[SearchSimilarGetRequestSortBy] = None, @@ -130,17 +130,7 @@ def get( Parameters ---------- - q : str - The keyword(s) to search for in articles. Query syntax supports logical operators (`AND`, `OR`, `NOT`) and wildcards: - - - For an exact match, use double quotes. For example, `"technology news"`. - - Use `*` to search for any keyword. - - Use `+` to include and `-` to exclude specific words or phrases. - For example, `+Apple`, `-Google`. - - Use `AND`, `OR`, and `NOT` to refine search results. - For example, `technology AND (Apple OR Microsoft) NOT Google`. - - For more details, see [Advanced querying](/docs/v3/documentation/guides-and-concepts/advanced-querying). + q : Q search_in : typing.Optional[SearchIn] @@ -206,7 +196,7 @@ def get( To learn more, see [Enumerated parameters > Country](/docs/v3/api-reference/overview/enumerated-parameters#country-country-and-not-country). - from_ : typing.Optional[dt.datetime] + from_ : typing.Optional[SearchSimilarGetRequestFrom] The starting point in time to search from. Accepts date-time strings in ISO 8601 format and plain text. The default time zone is UTC. Formats with examples: @@ -218,7 +208,7 @@ def get( **Note**: By default, applied to the publication date of the article. To use the article's parse date instead, set the `by_parse_date` parameter to `true`. - to : typing.Optional[dt.datetime] + to : typing.Optional[SearchSimilarGetRequestTo] The ending point in time to search up to. Accepts date-time strings in ISO 8601 format and plain text. The default time zone is UTC. Formats with examples: @@ -428,8 +418,12 @@ def get( "not_lang": not_lang, "countries": countries, "not_countries": not_countries, - "from_": serialize_datetime(from_) if from_ is not None else None, - "to_": serialize_datetime(to) if to is not None else None, + "from_": convert_and_respect_annotation_metadata( + object_=from_, annotation=SearchSimilarGetRequestFrom, direction="write" + ), + "to_": convert_and_respect_annotation_metadata( + object_=to, annotation=SearchSimilarGetRequestTo, direction="write" + ), "by_parse_date": by_parse_date, "published_date_precision": published_date_precision, "sort_by": sort_by, @@ -885,7 +879,7 @@ def __init__(self, *, client_wrapper: AsyncClientWrapper): async def get( self, *, - q: str, + q: Q, search_in: typing.Optional[SearchIn] = None, include_translation_fields: typing.Optional[IncludeTranslationFields] = None, include_similar_documents: typing.Optional[bool] = None, @@ -898,8 +892,8 @@ async def get( not_lang: typing.Optional[str] = None, countries: typing.Optional[str] = None, not_countries: typing.Optional[str] = None, - from_: typing.Optional[dt.datetime] = None, - to: typing.Optional[dt.datetime] = None, + from_: typing.Optional[SearchSimilarGetRequestFrom] = None, + to: typing.Optional[SearchSimilarGetRequestTo] = None, by_parse_date: typing.Optional[bool] = None, published_date_precision: typing.Optional[SearchSimilarGetRequestPublishedDatePrecision] = None, sort_by: typing.Optional[SearchSimilarGetRequestSortBy] = None, @@ -936,17 +930,7 @@ async def get( Parameters ---------- - q : str - The keyword(s) to search for in articles. Query syntax supports logical operators (`AND`, `OR`, `NOT`) and wildcards: - - - For an exact match, use double quotes. For example, `"technology news"`. - - Use `*` to search for any keyword. - - Use `+` to include and `-` to exclude specific words or phrases. - For example, `+Apple`, `-Google`. - - Use `AND`, `OR`, and `NOT` to refine search results. - For example, `technology AND (Apple OR Microsoft) NOT Google`. - - For more details, see [Advanced querying](/docs/v3/documentation/guides-and-concepts/advanced-querying). + q : Q search_in : typing.Optional[SearchIn] @@ -1012,7 +996,7 @@ async def get( To learn more, see [Enumerated parameters > Country](/docs/v3/api-reference/overview/enumerated-parameters#country-country-and-not-country). - from_ : typing.Optional[dt.datetime] + from_ : typing.Optional[SearchSimilarGetRequestFrom] The starting point in time to search from. Accepts date-time strings in ISO 8601 format and plain text. The default time zone is UTC. Formats with examples: @@ -1024,7 +1008,7 @@ async def get( **Note**: By default, applied to the publication date of the article. To use the article's parse date instead, set the `by_parse_date` parameter to `true`. - to : typing.Optional[dt.datetime] + to : typing.Optional[SearchSimilarGetRequestTo] The ending point in time to search up to. Accepts date-time strings in ISO 8601 format and plain text. The default time zone is UTC. Formats with examples: @@ -1234,8 +1218,12 @@ async def get( "not_lang": not_lang, "countries": countries, "not_countries": not_countries, - "from_": serialize_datetime(from_) if from_ is not None else None, - "to_": serialize_datetime(to) if to is not None else None, + "from_": convert_and_respect_annotation_metadata( + object_=from_, annotation=SearchSimilarGetRequestFrom, direction="write" + ), + "to_": convert_and_respect_annotation_metadata( + object_=to, annotation=SearchSimilarGetRequestTo, direction="write" + ), "by_parse_date": by_parse_date, "published_date_precision": published_date_precision, "sort_by": sort_by, diff --git a/src/newscatcher/searchsimilar/types/__init__.py b/src/newscatcher/searchsimilar/types/__init__.py index 5992030..ef4f537 100644 --- a/src/newscatcher/searchsimilar/types/__init__.py +++ b/src/newscatcher/searchsimilar/types/__init__.py @@ -2,10 +2,42 @@ # isort: skip_file -from .search_similar_get_request_published_date_precision import SearchSimilarGetRequestPublishedDatePrecision -from .search_similar_get_request_sort_by import SearchSimilarGetRequestSortBy -from .search_similar_get_response import SearchSimilarGetResponse -from .search_similar_post_response import SearchSimilarPostResponse +import typing +from importlib import import_module + +if typing.TYPE_CHECKING: + from .search_similar_get_request_published_date_precision import SearchSimilarGetRequestPublishedDatePrecision + from .search_similar_get_request_sort_by import SearchSimilarGetRequestSortBy + from .search_similar_get_response import SearchSimilarGetResponse + from .search_similar_post_response import SearchSimilarPostResponse +_dynamic_imports: typing.Dict[str, str] = { + "SearchSimilarGetRequestPublishedDatePrecision": ".search_similar_get_request_published_date_precision", + "SearchSimilarGetRequestSortBy": ".search_similar_get_request_sort_by", + "SearchSimilarGetResponse": ".search_similar_get_response", + "SearchSimilarPostResponse": ".search_similar_post_response", +} + + +def __getattr__(attr_name: str) -> typing.Any: + module_name = _dynamic_imports.get(attr_name) + if module_name is None: + raise AttributeError(f"No {attr_name} found in _dynamic_imports for module name -> {__name__}") + try: + module = import_module(module_name, __package__) + if module_name == f".{attr_name}": + return module + else: + return getattr(module, attr_name) + except ImportError as e: + raise ImportError(f"Failed to import {attr_name} from {module_name}: {e}") from e + except AttributeError as e: + raise AttributeError(f"Failed to get {attr_name} from {module_name}: {e}") from e + + +def __dir__(): + lazy_attrs = list(_dynamic_imports.keys()) + return sorted(lazy_attrs) + __all__ = [ "SearchSimilarGetRequestPublishedDatePrecision", diff --git a/src/newscatcher/sources/__init__.py b/src/newscatcher/sources/__init__.py index 6f0dd5f..ba4db9e 100644 --- a/src/newscatcher/sources/__init__.py +++ b/src/newscatcher/sources/__init__.py @@ -2,6 +2,33 @@ # isort: skip_file -from .types import SourcesGetRequestNewsDomainType +import typing +from importlib import import_module + +if typing.TYPE_CHECKING: + from .types import SourcesGetRequestNewsDomainType +_dynamic_imports: typing.Dict[str, str] = {"SourcesGetRequestNewsDomainType": ".types"} + + +def __getattr__(attr_name: str) -> typing.Any: + module_name = _dynamic_imports.get(attr_name) + if module_name is None: + raise AttributeError(f"No {attr_name} found in _dynamic_imports for module name -> {__name__}") + try: + module = import_module(module_name, __package__) + if module_name == f".{attr_name}": + return module + else: + return getattr(module, attr_name) + except ImportError as e: + raise ImportError(f"Failed to import {attr_name} from {module_name}: {e}") from e + except AttributeError as e: + raise AttributeError(f"Failed to get {attr_name} from {module_name}: {e}") from e + + +def __dir__(): + lazy_attrs = list(_dynamic_imports.keys()) + return sorted(lazy_attrs) + __all__ = ["SourcesGetRequestNewsDomainType"] diff --git a/src/newscatcher/sources/client.py b/src/newscatcher/sources/client.py index 3430e18..232fb62 100644 --- a/src/newscatcher/sources/client.py +++ b/src/newscatcher/sources/client.py @@ -146,8 +146,12 @@ def get( api_key="YOUR_API_KEY", ) client.sources.get( + lang="en", + countries="US", predefined_sources="top 100 US, top 5 GB", + source_name="sport", source_url="bbc.com", + news_type="General News Outlets", ) """ _response = self._raw_client.get( @@ -225,11 +229,7 @@ def post( api_key="YOUR_API_KEY", ) client.sources.post( - predefined_sources=["top 50 US"], - include_additional_info=True, - is_news_domain=True, - news_domain_type="Original Content", - news_type="General News Outlets", + predefined_sources="top 10 US", ) """ _response = self._raw_client.post( @@ -377,8 +377,12 @@ async def get( async def main() -> None: await client.sources.get( + lang="en", + countries="US", predefined_sources="top 100 US, top 5 GB", + source_name="sport", source_url="bbc.com", + news_type="General News Outlets", ) @@ -464,11 +468,7 @@ async def post( async def main() -> None: await client.sources.post( - predefined_sources=["top 50 US"], - include_additional_info=True, - is_news_domain=True, - news_domain_type="Original Content", - news_type="General News Outlets", + predefined_sources="top 10 US", ) diff --git a/src/newscatcher/sources/types/__init__.py b/src/newscatcher/sources/types/__init__.py index d40ef61..bd39a12 100644 --- a/src/newscatcher/sources/types/__init__.py +++ b/src/newscatcher/sources/types/__init__.py @@ -2,6 +2,33 @@ # isort: skip_file -from .sources_get_request_news_domain_type import SourcesGetRequestNewsDomainType +import typing +from importlib import import_module + +if typing.TYPE_CHECKING: + from .sources_get_request_news_domain_type import SourcesGetRequestNewsDomainType +_dynamic_imports: typing.Dict[str, str] = {"SourcesGetRequestNewsDomainType": ".sources_get_request_news_domain_type"} + + +def __getattr__(attr_name: str) -> typing.Any: + module_name = _dynamic_imports.get(attr_name) + if module_name is None: + raise AttributeError(f"No {attr_name} found in _dynamic_imports for module name -> {__name__}") + try: + module = import_module(module_name, __package__) + if module_name == f".{attr_name}": + return module + else: + return getattr(module, attr_name) + except ImportError as e: + raise ImportError(f"Failed to import {attr_name} from {module_name}: {e}") from e + except AttributeError as e: + raise AttributeError(f"Failed to get {attr_name} from {module_name}: {e}") from e + + +def __dir__(): + lazy_attrs = list(_dynamic_imports.keys()) + return sorted(lazy_attrs) + __all__ = ["SourcesGetRequestNewsDomainType"] diff --git a/src/newscatcher/types/__init__.py b/src/newscatcher/types/__init__.py index b458a49..2ca597e 100644 --- a/src/newscatcher/types/__init__.py +++ b/src/newscatcher/types/__init__.py @@ -2,112 +2,270 @@ # isort: skip_file -from .additional_domain_info import AdditionalDomainInfo -from .additional_domain_info_entity import AdditionalDomainInfoEntity -from .additional_source_info import AdditionalSourceInfo -from .aggregation_by import AggregationBy -from .aggregation_count_response_dto import AggregationCountResponseDto -from .aggregation_item import AggregationItem -from .aggregations import Aggregations -from .all_domain_links import AllDomainLinks -from .all_links import AllLinks -from .article_entity import ArticleEntity -from .article_entity_all_domain_links import ArticleEntityAllDomainLinks -from .article_entity_all_links import ArticleEntityAllLinks -from .articles import Articles -from .author_name import AuthorName -from .authors import Authors -from .base_search_response_dto import BaseSearchResponseDto -from .breaking_news_article_entity import BreakingNewsArticleEntity -from .breaking_news_article_entity_all_domain_links import BreakingNewsArticleEntityAllDomainLinks -from .breaking_news_article_entity_all_links import BreakingNewsArticleEntityAllLinks -from .breaking_news_event_entity import BreakingNewsEventEntity -from .breaking_news_response_dto import BreakingNewsResponseDto -from .by_parse_date import ByParseDate -from .cluster_entity import ClusterEntity -from .clustered_articles_dto import ClusteredArticlesDto -from .clustered_search_response_dto import ClusteredSearchResponseDto -from .clustering_enabled import ClusteringEnabled -from .clustering_threshold import ClusteringThreshold -from .clustering_variable import ClusteringVariable -from .content_sentiment_max import ContentSentimentMax -from .content_sentiment_min import ContentSentimentMin -from .countries import Countries -from .custom_tags import CustomTags -from .error import Error -from .exclude_duplicates import ExcludeDuplicates -from .failed_aggregation_count_response_dto import FailedAggregationCountResponseDto -from .failed_authors_response_dto import FailedAuthorsResponseDto -from .failed_search_similar_response_dto import FailedSearchSimilarResponseDto -from .from_ import From -from .from_rank import FromRank -from .has_nlp import HasNlp -from .iab_tags import IabTags -from .ids import Ids -from .include_additional_info import IncludeAdditionalInfo -from .include_nlp_data import IncludeNlpData -from .include_similar_documents import IncludeSimilarDocuments -from .include_translation_fields import IncludeTranslationFields -from .iptc_tags import IptcTags -from .is_headline import IsHeadline -from .is_news_domain import IsNewsDomain -from .is_opinion import IsOpinion -from .is_paid_content import IsPaidContent -from .journalists import Journalists -from .lang import Lang -from .links import Links -from .loc_entity_name import LocEntityName -from .misc_entity_name import MiscEntityName -from .named_entity_list import NamedEntityList -from .named_entity_list_item import NamedEntityListItem -from .ner_name import NerName -from .news_domain_type import NewsDomainType -from .news_type import NewsType -from .nlp_data_entity import NlpDataEntity -from .not_author_name import NotAuthorName -from .not_countries import NotCountries -from .not_iab_tags import NotIabTags -from .not_iptc_tags import NotIptcTags -from .not_lang import NotLang -from .not_sources import NotSources -from .not_theme import NotTheme -from .org_entity_name import OrgEntityName -from .page import Page -from .page_size import PageSize -from .parent_url import ParentUrl -from .per_entity_name import PerEntityName -from .predefined_sources import PredefinedSources -from .published_date_precision import PublishedDatePrecision -from .q import Q -from .ranked_only import RankedOnly -from .robots_compliant import RobotsCompliant -from .search_in import SearchIn -from .search_response_dto import SearchResponseDto -from .search_similar_response_dto import SearchSimilarResponseDto -from .sentiment_scores import SentimentScores -from .similar_article_entity import SimilarArticleEntity -from .similar_document import SimilarDocument -from .similar_documents_fields import SimilarDocumentsFields -from .similar_documents_number import SimilarDocumentsNumber -from .sort_by import SortBy -from .source_info import SourceInfo -from .source_name import SourceName -from .source_url import SourceUrl -from .sources import Sources -from .sources_response_dto import SourcesResponseDto -from .sources_response_dto_sources_item import SourcesResponseDtoSourcesItem -from .subscription_response_dto import SubscriptionResponseDto -from .theme import Theme -from .time_frame_count import TimeFrameCount -from .title_sentiment_max import TitleSentimentMax -from .title_sentiment_min import TitleSentimentMin -from .to import To -from .to_rank import ToRank -from .top_n_articles import TopNArticles -from .user_input_dto import UserInputDto -from .when import When -from .word_count_max import WordCountMax -from .word_count_min import WordCountMin +import typing +from importlib import import_module + +if typing.TYPE_CHECKING: + from .additional_domain_info import AdditionalDomainInfo + from .additional_domain_info_entity import AdditionalDomainInfoEntity + from .additional_source_info import AdditionalSourceInfo + from .aggregation_by import AggregationBy + from .aggregation_count_response_dto import AggregationCountResponseDto + from .aggregation_count_response_dto_aggregations import AggregationCountResponseDtoAggregations + from .aggregation_get_request_from import AggregationGetRequestFrom + from .aggregation_get_request_to import AggregationGetRequestTo + from .aggregation_item import AggregationItem + from .all_domain_links import AllDomainLinks + from .all_links import AllLinks + from .article_entity import ArticleEntity + from .article_entity_all_domain_links import ArticleEntityAllDomainLinks + from .article_entity_all_links import ArticleEntityAllLinks + from .article_entity_authors import ArticleEntityAuthors + from .article_entity_journalists import ArticleEntityJournalists + from .articles_dto import ArticlesDto + from .author_name import AuthorName + from .authors_get_request_from import AuthorsGetRequestFrom + from .authors_get_request_to import AuthorsGetRequestTo + from .base_search_response_dto import BaseSearchResponseDto + from .breaking_news_article_entity import BreakingNewsArticleEntity + from .breaking_news_article_entity_all_domain_links import BreakingNewsArticleEntityAllDomainLinks + from .breaking_news_article_entity_all_links import BreakingNewsArticleEntityAllLinks + from .breaking_news_article_entity_authors import BreakingNewsArticleEntityAuthors + from .breaking_news_article_entity_journalists import BreakingNewsArticleEntityJournalists + from .breaking_news_event_entity import BreakingNewsEventEntity + from .breaking_news_response_dto import BreakingNewsResponseDto + from .by_parse_date import ByParseDate + from .canonical_url import CanonicalUrl + from .cluster_entity import ClusterEntity + from .clustered_articles_dto import ClusteredArticlesDto + from .clustered_search_response_dto import ClusteredSearchResponseDto + from .clustering_enabled import ClusteringEnabled + from .clustering_threshold import ClusteringThreshold + from .clustering_variable import ClusteringVariable + from .content_sentiment_max import ContentSentimentMax + from .content_sentiment_min import ContentSentimentMin + from .countries import Countries + from .custom_tags import CustomTags + from .error import Error + from .exclude_duplicates import ExcludeDuplicates + from .failed_aggregation_count_response_dto import FailedAggregationCountResponseDto + from .failed_authors_response_dto import FailedAuthorsResponseDto + from .failed_search_similar_response_dto import FailedSearchSimilarResponseDto + from .from_ import From + from .from_rank import FromRank + from .has_nlp import HasNlp + from .iab_tags import IabTags + from .ids import Ids + from .include_additional_info import IncludeAdditionalInfo + from .include_nlp_data import IncludeNlpData + from .include_similar_documents import IncludeSimilarDocuments + from .include_translation_fields import IncludeTranslationFields + from .iptc_tags import IptcTags + from .is_headline import IsHeadline + from .is_news_domain import IsNewsDomain + from .is_opinion import IsOpinion + from .is_paid_content import IsPaidContent + from .lang import Lang + from .links import Links + from .loc_entity_name import LocEntityName + from .misc_entity_name import MiscEntityName + from .named_entity_list import NamedEntityList + from .named_entity_list_item import NamedEntityListItem + from .ner_name import NerName + from .news_domain_type import NewsDomainType + from .news_type import NewsType + from .nlp_data_entity import NlpDataEntity + from .not_author_name import NotAuthorName + from .not_countries import NotCountries + from .not_iab_tags import NotIabTags + from .not_iptc_tags import NotIptcTags + from .not_lang import NotLang + from .not_sources import NotSources + from .not_theme import NotTheme + from .org_entity_name import OrgEntityName + from .page import Page + from .page_size import PageSize + from .parent_url import ParentUrl + from .per_entity_name import PerEntityName + from .predefined_sources import PredefinedSources + from .published_date_precision import PublishedDatePrecision + from .q import Q + from .ranked_only import RankedOnly + from .robots_compliant import RobotsCompliant + from .search_get_request_from import SearchGetRequestFrom + from .search_get_request_to import SearchGetRequestTo + from .search_in import SearchIn + from .search_response_dto import SearchResponseDto + from .search_similar_get_request_from import SearchSimilarGetRequestFrom + from .search_similar_get_request_to import SearchSimilarGetRequestTo + from .search_similar_response_dto import SearchSimilarResponseDto + from .sentiment_scores import SentimentScores + from .similar_article_entity import SimilarArticleEntity + from .similar_document import SimilarDocument + from .similar_documents_fields import SimilarDocumentsFields + from .similar_documents_number import SimilarDocumentsNumber + from .sort_by import SortBy + from .source import Source + from .source_info import SourceInfo + from .source_name import SourceName + from .source_url import SourceUrl + from .sources import Sources + from .sources_response_dto import SourcesResponseDto + from .sources_response_dto_sources_item import SourcesResponseDtoSourcesItem + from .subscription_response_dto import SubscriptionResponseDto + from .theme import Theme + from .time_frame_count import TimeFrameCount + from .title_sentiment_max import TitleSentimentMax + from .title_sentiment_min import TitleSentimentMin + from .to import To + from .to_rank import ToRank + from .top_n_articles import TopNArticles + from .user_input_dto import UserInputDto + from .when import When + from .word_count_max import WordCountMax + from .word_count_min import WordCountMin +_dynamic_imports: typing.Dict[str, str] = { + "AdditionalDomainInfo": ".additional_domain_info", + "AdditionalDomainInfoEntity": ".additional_domain_info_entity", + "AdditionalSourceInfo": ".additional_source_info", + "AggregationBy": ".aggregation_by", + "AggregationCountResponseDto": ".aggregation_count_response_dto", + "AggregationCountResponseDtoAggregations": ".aggregation_count_response_dto_aggregations", + "AggregationGetRequestFrom": ".aggregation_get_request_from", + "AggregationGetRequestTo": ".aggregation_get_request_to", + "AggregationItem": ".aggregation_item", + "AllDomainLinks": ".all_domain_links", + "AllLinks": ".all_links", + "ArticleEntity": ".article_entity", + "ArticleEntityAllDomainLinks": ".article_entity_all_domain_links", + "ArticleEntityAllLinks": ".article_entity_all_links", + "ArticleEntityAuthors": ".article_entity_authors", + "ArticleEntityJournalists": ".article_entity_journalists", + "ArticlesDto": ".articles_dto", + "AuthorName": ".author_name", + "AuthorsGetRequestFrom": ".authors_get_request_from", + "AuthorsGetRequestTo": ".authors_get_request_to", + "BaseSearchResponseDto": ".base_search_response_dto", + "BreakingNewsArticleEntity": ".breaking_news_article_entity", + "BreakingNewsArticleEntityAllDomainLinks": ".breaking_news_article_entity_all_domain_links", + "BreakingNewsArticleEntityAllLinks": ".breaking_news_article_entity_all_links", + "BreakingNewsArticleEntityAuthors": ".breaking_news_article_entity_authors", + "BreakingNewsArticleEntityJournalists": ".breaking_news_article_entity_journalists", + "BreakingNewsEventEntity": ".breaking_news_event_entity", + "BreakingNewsResponseDto": ".breaking_news_response_dto", + "ByParseDate": ".by_parse_date", + "CanonicalUrl": ".canonical_url", + "ClusterEntity": ".cluster_entity", + "ClusteredArticlesDto": ".clustered_articles_dto", + "ClusteredSearchResponseDto": ".clustered_search_response_dto", + "ClusteringEnabled": ".clustering_enabled", + "ClusteringThreshold": ".clustering_threshold", + "ClusteringVariable": ".clustering_variable", + "ContentSentimentMax": ".content_sentiment_max", + "ContentSentimentMin": ".content_sentiment_min", + "Countries": ".countries", + "CustomTags": ".custom_tags", + "Error": ".error", + "ExcludeDuplicates": ".exclude_duplicates", + "FailedAggregationCountResponseDto": ".failed_aggregation_count_response_dto", + "FailedAuthorsResponseDto": ".failed_authors_response_dto", + "FailedSearchSimilarResponseDto": ".failed_search_similar_response_dto", + "From": ".from_", + "FromRank": ".from_rank", + "HasNlp": ".has_nlp", + "IabTags": ".iab_tags", + "Ids": ".ids", + "IncludeAdditionalInfo": ".include_additional_info", + "IncludeNlpData": ".include_nlp_data", + "IncludeSimilarDocuments": ".include_similar_documents", + "IncludeTranslationFields": ".include_translation_fields", + "IptcTags": ".iptc_tags", + "IsHeadline": ".is_headline", + "IsNewsDomain": ".is_news_domain", + "IsOpinion": ".is_opinion", + "IsPaidContent": ".is_paid_content", + "Lang": ".lang", + "Links": ".links", + "LocEntityName": ".loc_entity_name", + "MiscEntityName": ".misc_entity_name", + "NamedEntityList": ".named_entity_list", + "NamedEntityListItem": ".named_entity_list_item", + "NerName": ".ner_name", + "NewsDomainType": ".news_domain_type", + "NewsType": ".news_type", + "NlpDataEntity": ".nlp_data_entity", + "NotAuthorName": ".not_author_name", + "NotCountries": ".not_countries", + "NotIabTags": ".not_iab_tags", + "NotIptcTags": ".not_iptc_tags", + "NotLang": ".not_lang", + "NotSources": ".not_sources", + "NotTheme": ".not_theme", + "OrgEntityName": ".org_entity_name", + "Page": ".page", + "PageSize": ".page_size", + "ParentUrl": ".parent_url", + "PerEntityName": ".per_entity_name", + "PredefinedSources": ".predefined_sources", + "PublishedDatePrecision": ".published_date_precision", + "Q": ".q", + "RankedOnly": ".ranked_only", + "RobotsCompliant": ".robots_compliant", + "SearchGetRequestFrom": ".search_get_request_from", + "SearchGetRequestTo": ".search_get_request_to", + "SearchIn": ".search_in", + "SearchResponseDto": ".search_response_dto", + "SearchSimilarGetRequestFrom": ".search_similar_get_request_from", + "SearchSimilarGetRequestTo": ".search_similar_get_request_to", + "SearchSimilarResponseDto": ".search_similar_response_dto", + "SentimentScores": ".sentiment_scores", + "SimilarArticleEntity": ".similar_article_entity", + "SimilarDocument": ".similar_document", + "SimilarDocumentsFields": ".similar_documents_fields", + "SimilarDocumentsNumber": ".similar_documents_number", + "SortBy": ".sort_by", + "Source": ".source", + "SourceInfo": ".source_info", + "SourceName": ".source_name", + "SourceUrl": ".source_url", + "Sources": ".sources", + "SourcesResponseDto": ".sources_response_dto", + "SourcesResponseDtoSourcesItem": ".sources_response_dto_sources_item", + "SubscriptionResponseDto": ".subscription_response_dto", + "Theme": ".theme", + "TimeFrameCount": ".time_frame_count", + "TitleSentimentMax": ".title_sentiment_max", + "TitleSentimentMin": ".title_sentiment_min", + "To": ".to", + "ToRank": ".to_rank", + "TopNArticles": ".top_n_articles", + "UserInputDto": ".user_input_dto", + "When": ".when", + "WordCountMax": ".word_count_max", + "WordCountMin": ".word_count_min", +} + + +def __getattr__(attr_name: str) -> typing.Any: + module_name = _dynamic_imports.get(attr_name) + if module_name is None: + raise AttributeError(f"No {attr_name} found in _dynamic_imports for module name -> {__name__}") + try: + module = import_module(module_name, __package__) + if module_name == f".{attr_name}": + return module + else: + return getattr(module, attr_name) + except ImportError as e: + raise ImportError(f"Failed to import {attr_name} from {module_name}: {e}") from e + except AttributeError as e: + raise AttributeError(f"Failed to get {attr_name} from {module_name}: {e}") from e + + +def __dir__(): + lazy_attrs = list(_dynamic_imports.keys()) + return sorted(lazy_attrs) + __all__ = [ "AdditionalDomainInfo", @@ -115,23 +273,31 @@ "AdditionalSourceInfo", "AggregationBy", "AggregationCountResponseDto", + "AggregationCountResponseDtoAggregations", + "AggregationGetRequestFrom", + "AggregationGetRequestTo", "AggregationItem", - "Aggregations", "AllDomainLinks", "AllLinks", "ArticleEntity", "ArticleEntityAllDomainLinks", "ArticleEntityAllLinks", - "Articles", + "ArticleEntityAuthors", + "ArticleEntityJournalists", + "ArticlesDto", "AuthorName", - "Authors", + "AuthorsGetRequestFrom", + "AuthorsGetRequestTo", "BaseSearchResponseDto", "BreakingNewsArticleEntity", "BreakingNewsArticleEntityAllDomainLinks", "BreakingNewsArticleEntityAllLinks", + "BreakingNewsArticleEntityAuthors", + "BreakingNewsArticleEntityJournalists", "BreakingNewsEventEntity", "BreakingNewsResponseDto", "ByParseDate", + "CanonicalUrl", "ClusterEntity", "ClusteredArticlesDto", "ClusteredSearchResponseDto", @@ -161,7 +327,6 @@ "IsNewsDomain", "IsOpinion", "IsPaidContent", - "Journalists", "Lang", "Links", "LocEntityName", @@ -189,8 +354,12 @@ "Q", "RankedOnly", "RobotsCompliant", + "SearchGetRequestFrom", + "SearchGetRequestTo", "SearchIn", "SearchResponseDto", + "SearchSimilarGetRequestFrom", + "SearchSimilarGetRequestTo", "SearchSimilarResponseDto", "SentimentScores", "SimilarArticleEntity", @@ -198,6 +367,7 @@ "SimilarDocumentsFields", "SimilarDocumentsNumber", "SortBy", + "Source", "SourceInfo", "SourceName", "SourceUrl", diff --git a/src/newscatcher/types/additional_source_info.py b/src/newscatcher/types/additional_source_info.py index c9551f1..d67f2f1 100644 --- a/src/newscatcher/types/additional_source_info.py +++ b/src/newscatcher/types/additional_source_info.py @@ -14,12 +14,13 @@ class AdditionalSourceInfo(UniversalBaseModel): """ nb_articles_for_7_d: typing_extensions.Annotated[ - typing.Optional[int], FieldMetadata(alias="nb_articles_for_7d") - ] = pydantic.Field(default=None) - """ - The number of articles published by the source in the last seven days. - """ - + typing.Optional[int], + FieldMetadata(alias="nb_articles_for_7d"), + pydantic.Field( + alias="nb_articles_for_7d", + description="The number of articles published by the source in the last seven days.", + ), + ] = None country: typing.Optional[str] = pydantic.Field(default=None) """ The country of origin of the news source. diff --git a/src/newscatcher/types/aggregation_count_response_dto.py b/src/newscatcher/types/aggregation_count_response_dto.py index 8492c80..3b3e96a 100644 --- a/src/newscatcher/types/aggregation_count_response_dto.py +++ b/src/newscatcher/types/aggregation_count_response_dto.py @@ -4,7 +4,7 @@ import pydantic from ..core.pydantic_utilities import IS_PYDANTIC_V2 -from .aggregations import Aggregations +from .aggregation_count_response_dto_aggregations import AggregationCountResponseDtoAggregations from .base_search_response_dto import BaseSearchResponseDto from .user_input_dto import UserInputDto @@ -16,7 +16,7 @@ class AggregationCountResponseDto(BaseSearchResponseDto): - Optional fields may be `null` or `undefined` if the data point is not presented or couldn't be extracted during processing. """ - aggregations: typing.Optional[Aggregations] = pydantic.Field(default=None) + aggregations: typing.Optional[AggregationCountResponseDtoAggregations] = pydantic.Field(default=None) """ The aggregation results. Can be either a dictionary or a list of dictionaries. """ diff --git a/src/newscatcher/types/aggregations.py b/src/newscatcher/types/aggregation_count_response_dto_aggregations.py similarity index 55% rename from src/newscatcher/types/aggregations.py rename to src/newscatcher/types/aggregation_count_response_dto_aggregations.py index 16b1d5f..aa0eb09 100644 --- a/src/newscatcher/types/aggregations.py +++ b/src/newscatcher/types/aggregation_count_response_dto_aggregations.py @@ -4,4 +4,4 @@ from .aggregation_item import AggregationItem -Aggregations = typing.Union[AggregationItem, typing.List[AggregationItem]] +AggregationCountResponseDtoAggregations = typing.Union[AggregationItem, typing.List[AggregationItem]] diff --git a/src/newscatcher/types/aggregation_get_request_from.py b/src/newscatcher/types/aggregation_get_request_from.py new file mode 100644 index 0000000..4fd8bc3 --- /dev/null +++ b/src/newscatcher/types/aggregation_get_request_from.py @@ -0,0 +1,6 @@ +# This file was auto-generated by Fern from our API Definition. + +import datetime as dt +import typing + +AggregationGetRequestFrom = typing.Union[dt.datetime, str] diff --git a/src/newscatcher/types/aggregation_get_request_to.py b/src/newscatcher/types/aggregation_get_request_to.py new file mode 100644 index 0000000..bd46a8e --- /dev/null +++ b/src/newscatcher/types/aggregation_get_request_to.py @@ -0,0 +1,6 @@ +# This file was auto-generated by Fern from our API Definition. + +import datetime as dt +import typing + +AggregationGetRequestTo = typing.Union[dt.datetime, str] diff --git a/src/newscatcher/types/article_entity.py b/src/newscatcher/types/article_entity.py index ff89b24..7a7613c 100644 --- a/src/newscatcher/types/article_entity.py +++ b/src/newscatcher/types/article_entity.py @@ -7,8 +7,8 @@ from .additional_domain_info_entity import AdditionalDomainInfoEntity from .article_entity_all_domain_links import ArticleEntityAllDomainLinks from .article_entity_all_links import ArticleEntityAllLinks -from .authors import Authors -from .journalists import Journalists +from .article_entity_authors import ArticleEntityAuthors +from .article_entity_journalists import ArticleEntityJournalists from .nlp_data_entity import NlpDataEntity @@ -17,7 +17,7 @@ class ArticleEntity(UniversalBaseModel): The data model representing a single article in the search results. """ - title: str = pydantic.Field() + title: typing.Optional[str] = pydantic.Field(default=None) """ The title of the article. """ @@ -27,12 +27,12 @@ class ArticleEntity(UniversalBaseModel): The primary author of the article. """ - authors: typing.Optional[Authors] = pydantic.Field(default=None) + authors: typing.Optional[ArticleEntityAuthors] = pydantic.Field(default=None) """ A list of authors of the article. """ - journalists: typing.Optional[Journalists] = pydantic.Field(default=None) + journalists: typing.Optional[ArticleEntityJournalists] = pydantic.Field(default=None) """ A list of journalists associated with the article. """ @@ -62,17 +62,22 @@ class ArticleEntity(UniversalBaseModel): The date the article was parsed. """ - link: str = pydantic.Field() + link: typing.Optional[str] = pydantic.Field(default=None) """ The URL link to the article. """ - domain_url: str = pydantic.Field() + canonical_url: typing.Optional[bool] = pydantic.Field(default=None) + """ + Indicates whether the article URL is canonical. + """ + + domain_url: typing.Optional[str] = pydantic.Field(default=None) """ The domain URL of the article. """ - full_domain_url: str = pydantic.Field() + full_domain_url: typing.Optional[str] = pydantic.Field(default=None) """ The full domain URL of the article. """ @@ -92,7 +97,7 @@ class ArticleEntity(UniversalBaseModel): Indicates if the article is paid content. """ - parent_url: str = pydantic.Field() + parent_url: typing.Optional[str] = pydantic.Field(default=None) """ The categorical URL of the article. """ @@ -107,7 +112,7 @@ class ArticleEntity(UniversalBaseModel): The rights information for the article. """ - rank: int = pydantic.Field() + rank: typing.Optional[int] = pydantic.Field(default=None) """ The rank of the article's source. """ @@ -127,7 +132,7 @@ class ArticleEntity(UniversalBaseModel): A brief description of the article. """ - content: str = pydantic.Field() + content: typing.Optional[str] = pydantic.Field(default=None) """ The content of the article. """ @@ -168,12 +173,12 @@ class ArticleEntity(UniversalBaseModel): """ nlp: typing.Optional[NlpDataEntity] = None - id: str = pydantic.Field() + id: typing.Optional[str] = pydantic.Field(default=None) """ The unique identifier for the article. """ - score: float = pydantic.Field() + score: typing.Optional[float] = pydantic.Field(default=None) """ The relevance score of the article. """ diff --git a/src/newscatcher/types/authors.py b/src/newscatcher/types/article_entity_authors.py similarity index 57% rename from src/newscatcher/types/authors.py rename to src/newscatcher/types/article_entity_authors.py index 2393876..f1dd11f 100644 --- a/src/newscatcher/types/authors.py +++ b/src/newscatcher/types/article_entity_authors.py @@ -2,4 +2,4 @@ import typing -Authors = typing.Union[typing.List[str], str] +ArticleEntityAuthors = typing.Union[typing.List[str], str] diff --git a/src/newscatcher/types/journalists.py b/src/newscatcher/types/article_entity_journalists.py similarity index 55% rename from src/newscatcher/types/journalists.py rename to src/newscatcher/types/article_entity_journalists.py index 5dfe0f6..c9dabe1 100644 --- a/src/newscatcher/types/journalists.py +++ b/src/newscatcher/types/article_entity_journalists.py @@ -2,4 +2,4 @@ import typing -Journalists = typing.Union[typing.List[str], str] +ArticleEntityJournalists = typing.Union[typing.List[str], str] diff --git a/src/newscatcher/types/articles.py b/src/newscatcher/types/articles_dto.py similarity index 75% rename from src/newscatcher/types/articles.py rename to src/newscatcher/types/articles_dto.py index 14d2ac6..839bb31 100644 --- a/src/newscatcher/types/articles.py +++ b/src/newscatcher/types/articles_dto.py @@ -4,4 +4,4 @@ from .article_entity import ArticleEntity -Articles = typing.List[ArticleEntity] +ArticlesDto = typing.List[ArticleEntity] diff --git a/src/newscatcher/types/authors_get_request_from.py b/src/newscatcher/types/authors_get_request_from.py new file mode 100644 index 0000000..b35ebe1 --- /dev/null +++ b/src/newscatcher/types/authors_get_request_from.py @@ -0,0 +1,6 @@ +# This file was auto-generated by Fern from our API Definition. + +import datetime as dt +import typing + +AuthorsGetRequestFrom = typing.Union[dt.datetime, str] diff --git a/src/newscatcher/types/authors_get_request_to.py b/src/newscatcher/types/authors_get_request_to.py new file mode 100644 index 0000000..2101d1f --- /dev/null +++ b/src/newscatcher/types/authors_get_request_to.py @@ -0,0 +1,6 @@ +# This file was auto-generated by Fern from our API Definition. + +import datetime as dt +import typing + +AuthorsGetRequestTo = typing.Union[dt.datetime, str] diff --git a/src/newscatcher/types/base_search_response_dto.py b/src/newscatcher/types/base_search_response_dto.py index 07c2357..fe3aabd 100644 --- a/src/newscatcher/types/base_search_response_dto.py +++ b/src/newscatcher/types/base_search_response_dto.py @@ -11,27 +11,27 @@ class BaseSearchResponseDto(UniversalBaseModel): The base response model containing common fields for search operations. """ - status: str = pydantic.Field() + status: typing.Optional[str] = pydantic.Field(default=None) """ The status of the response. """ - total_hits: int = pydantic.Field() + total_hits: typing.Optional[int] = pydantic.Field(default=None) """ The total number of articles matching the search criteria. """ - page: int = pydantic.Field() + page: typing.Optional[int] = pydantic.Field(default=None) """ The current page number of the results. """ - total_pages: int = pydantic.Field() + total_pages: typing.Optional[int] = pydantic.Field(default=None) """ The total number of pages available for the given search criteria. """ - page_size: int = pydantic.Field() + page_size: typing.Optional[int] = pydantic.Field(default=None) """ The number of articles per page. """ diff --git a/src/newscatcher/types/breaking_news_article_entity.py b/src/newscatcher/types/breaking_news_article_entity.py index 0a76272..94f1324 100644 --- a/src/newscatcher/types/breaking_news_article_entity.py +++ b/src/newscatcher/types/breaking_news_article_entity.py @@ -4,10 +4,10 @@ import pydantic from ..core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel -from .authors import Authors from .breaking_news_article_entity_all_domain_links import BreakingNewsArticleEntityAllDomainLinks from .breaking_news_article_entity_all_links import BreakingNewsArticleEntityAllLinks -from .journalists import Journalists +from .breaking_news_article_entity_authors import BreakingNewsArticleEntityAuthors +from .breaking_news_article_entity_journalists import BreakingNewsArticleEntityJournalists from .nlp_data_entity import NlpDataEntity @@ -26,12 +26,12 @@ class BreakingNewsArticleEntity(UniversalBaseModel): The primary author of the article. """ - authors: typing.Optional[Authors] = pydantic.Field(default=None) + authors: typing.Optional[BreakingNewsArticleEntityAuthors] = pydantic.Field(default=None) """ A list of authors of the article. """ - journalists: typing.Optional[Journalists] = pydantic.Field(default=None) + journalists: typing.Optional[BreakingNewsArticleEntityJournalists] = pydantic.Field(default=None) """ A list of journalists associated with the article. """ diff --git a/src/newscatcher/types/breaking_news_article_entity_authors.py b/src/newscatcher/types/breaking_news_article_entity_authors.py new file mode 100644 index 0000000..63be474 --- /dev/null +++ b/src/newscatcher/types/breaking_news_article_entity_authors.py @@ -0,0 +1,5 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +BreakingNewsArticleEntityAuthors = typing.Union[typing.List[str], str] diff --git a/src/newscatcher/types/breaking_news_article_entity_journalists.py b/src/newscatcher/types/breaking_news_article_entity_journalists.py new file mode 100644 index 0000000..e541fff --- /dev/null +++ b/src/newscatcher/types/breaking_news_article_entity_journalists.py @@ -0,0 +1,5 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +BreakingNewsArticleEntityJournalists = typing.Union[typing.List[str], str] diff --git a/src/newscatcher/types/canonical_url.py b/src/newscatcher/types/canonical_url.py new file mode 100644 index 0000000..862fc95 --- /dev/null +++ b/src/newscatcher/types/canonical_url.py @@ -0,0 +1,3 @@ +# This file was auto-generated by Fern from our API Definition. + +CanonicalUrl = bool diff --git a/src/newscatcher/types/failed_authors_response_dto.py b/src/newscatcher/types/failed_authors_response_dto.py index 7345a65..43f4181 100644 --- a/src/newscatcher/types/failed_authors_response_dto.py +++ b/src/newscatcher/types/failed_authors_response_dto.py @@ -13,7 +13,7 @@ class FailedAuthorsResponseDto(BaseSearchResponseDto): The response model for a failed `Authors` search request. """ - articles: typing.Optional[typing.List[typing.Dict[str, typing.Optional[typing.Any]]]] = pydantic.Field(default=None) + articles: typing.Optional[typing.List[typing.Dict[str, typing.Any]]] = pydantic.Field(default=None) """ An empty list of articles, as no matches were found. """ diff --git a/src/newscatcher/types/failed_search_similar_response_dto.py b/src/newscatcher/types/failed_search_similar_response_dto.py index 2416b4a..f53ee84 100644 --- a/src/newscatcher/types/failed_search_similar_response_dto.py +++ b/src/newscatcher/types/failed_search_similar_response_dto.py @@ -13,7 +13,7 @@ class FailedSearchSimilarResponseDto(BaseSearchResponseDto): The response model for a failed `Search Similar` request. """ - articles: typing.Optional[typing.List[typing.Dict[str, typing.Optional[typing.Any]]]] = pydantic.Field(default=None) + articles: typing.Optional[typing.List[typing.Dict[str, typing.Any]]] = pydantic.Field(default=None) """ An empty list of articles, as no matches were found. """ diff --git a/src/newscatcher/types/nlp_data_entity.py b/src/newscatcher/types/nlp_data_entity.py index 7bff2b1..8f1b938 100644 --- a/src/newscatcher/types/nlp_data_entity.py +++ b/src/newscatcher/types/nlp_data_entity.py @@ -38,62 +38,69 @@ class NlpDataEntity(UniversalBaseModel): **Note**: The `new_embedding` field is only available in the `v3_local_news_nlp_embeddings` subscription plan. """ - ner_per: typing_extensions.Annotated[typing.Optional[NamedEntityList], FieldMetadata(alias="ner_PER")] = ( - pydantic.Field(default=None) - ) - """ - Named Entity Recognition for person entities (individuals' names). - """ - - ner_org: typing_extensions.Annotated[typing.Optional[NamedEntityList], FieldMetadata(alias="ner_ORG")] = ( - pydantic.Field(default=None) - ) - """ - Named Entity Recognition for organization entities (company names, institutions). - """ - - ner_misc: typing_extensions.Annotated[typing.Optional[NamedEntityList], FieldMetadata(alias="ner_MISC")] = ( - pydantic.Field(default=None) - ) - """ - Named Entity Recognition for miscellaneous entities (events, nationalities, products). - """ - - ner_loc: typing_extensions.Annotated[typing.Optional[NamedEntityList], FieldMetadata(alias="ner_LOC")] = ( - pydantic.Field(default=None) - ) - """ - Named Entity Recognition for location entities (cities, countries, geographic features). - """ - + ner_per: typing_extensions.Annotated[ + typing.Optional[NamedEntityList], + FieldMetadata(alias="ner_PER"), + pydantic.Field( + alias="ner_PER", description="Named Entity Recognition for person entities (individuals' names)." + ), + ] = None + ner_org: typing_extensions.Annotated[ + typing.Optional[NamedEntityList], + FieldMetadata(alias="ner_ORG"), + pydantic.Field( + alias="ner_ORG", + description="Named Entity Recognition for organization entities (company names, institutions).", + ), + ] = None + ner_misc: typing_extensions.Annotated[ + typing.Optional[NamedEntityList], + FieldMetadata(alias="ner_MISC"), + pydantic.Field( + alias="ner_MISC", + description="Named Entity Recognition for miscellaneous entities (events, nationalities, products).", + ), + ] = None + ner_loc: typing_extensions.Annotated[ + typing.Optional[NamedEntityList], + FieldMetadata(alias="ner_LOC"), + pydantic.Field( + alias="ner_LOC", + description="Named Entity Recognition for location entities (cities, countries, geographic features).", + ), + ] = None translation_ner_per: typing_extensions.Annotated[ - typing.Optional[NamedEntityList], FieldMetadata(alias="translation_ner_PER") - ] = pydantic.Field(default=None) - """ - Named Entity Recognition for person entities (individuals' names) extracted from the English translation of the article. - """ - + typing.Optional[NamedEntityList], + FieldMetadata(alias="translation_ner_PER"), + pydantic.Field( + alias="translation_ner_PER", + description="Named Entity Recognition for person entities (individuals' names) extracted from the English translation of the article.", + ), + ] = None translation_ner_org: typing_extensions.Annotated[ - typing.Optional[NamedEntityList], FieldMetadata(alias="translation_ner_ORG") - ] = pydantic.Field(default=None) - """ - Named Entity Recognition for organization entities (company names, institutions) extracted from the English translation of the article. - """ - + typing.Optional[NamedEntityList], + FieldMetadata(alias="translation_ner_ORG"), + pydantic.Field( + alias="translation_ner_ORG", + description="Named Entity Recognition for organization entities (company names, institutions) extracted from the English translation of the article.", + ), + ] = None translation_ner_misc: typing_extensions.Annotated[ - typing.Optional[NamedEntityList], FieldMetadata(alias="translation_ner_MISC") - ] = pydantic.Field(default=None) - """ - Named Entity Recognition for miscellaneous entities (events, nationalities, products) extracted from the English translation of the article. - """ - + typing.Optional[NamedEntityList], + FieldMetadata(alias="translation_ner_MISC"), + pydantic.Field( + alias="translation_ner_MISC", + description="Named Entity Recognition for miscellaneous entities (events, nationalities, products) extracted from the English translation of the article.", + ), + ] = None translation_ner_loc: typing_extensions.Annotated[ - typing.Optional[NamedEntityList], FieldMetadata(alias="translation_ner_LOC") - ] = pydantic.Field(default=None) - """ - Named Entity Recognition for location entities (cities, countries, geographic features) extracted from the English translation of the article. - """ - + typing.Optional[NamedEntityList], + FieldMetadata(alias="translation_ner_LOC"), + pydantic.Field( + alias="translation_ner_LOC", + description="Named Entity Recognition for location entities (cities, countries, geographic features) extracted from the English translation of the article.", + ), + ] = None iptc_tags_name: typing.Optional[typing.List[str]] = pydantic.Field(default=None) """ IPTC media topic taxonomy paths identified in the article content. Each path represents a hierarchical category following the IPTC standard. diff --git a/src/newscatcher/types/search_get_request_from.py b/src/newscatcher/types/search_get_request_from.py new file mode 100644 index 0000000..7b91efd --- /dev/null +++ b/src/newscatcher/types/search_get_request_from.py @@ -0,0 +1,6 @@ +# This file was auto-generated by Fern from our API Definition. + +import datetime as dt +import typing + +SearchGetRequestFrom = typing.Union[dt.datetime, str] diff --git a/src/newscatcher/types/search_get_request_to.py b/src/newscatcher/types/search_get_request_to.py new file mode 100644 index 0000000..07ec2a7 --- /dev/null +++ b/src/newscatcher/types/search_get_request_to.py @@ -0,0 +1,6 @@ +# This file was auto-generated by Fern from our API Definition. + +import datetime as dt +import typing + +SearchGetRequestTo = typing.Union[dt.datetime, str] diff --git a/src/newscatcher/types/search_response_dto.py b/src/newscatcher/types/search_response_dto.py index f0d7abb..0d08579 100644 --- a/src/newscatcher/types/search_response_dto.py +++ b/src/newscatcher/types/search_response_dto.py @@ -4,7 +4,7 @@ import pydantic from ..core.pydantic_utilities import IS_PYDANTIC_V2 -from .articles import Articles +from .articles_dto import ArticlesDto from .base_search_response_dto import BaseSearchResponseDto from .user_input_dto import UserInputDto @@ -18,7 +18,7 @@ class SearchResponseDto(BaseSearchResponseDto): - The `nlp` property within the article object `articles[n].nlp` is only available with NLP-enabled subscription plans. """ - articles: typing.Optional[Articles] = None + articles: typing.Optional[ArticlesDto] = None user_input: typing.Optional[UserInputDto] = None if IS_PYDANTIC_V2: diff --git a/src/newscatcher/types/search_similar_get_request_from.py b/src/newscatcher/types/search_similar_get_request_from.py new file mode 100644 index 0000000..4218be8 --- /dev/null +++ b/src/newscatcher/types/search_similar_get_request_from.py @@ -0,0 +1,6 @@ +# This file was auto-generated by Fern from our API Definition. + +import datetime as dt +import typing + +SearchSimilarGetRequestFrom = typing.Union[dt.datetime, str] diff --git a/src/newscatcher/types/search_similar_get_request_to.py b/src/newscatcher/types/search_similar_get_request_to.py new file mode 100644 index 0000000..9689631 --- /dev/null +++ b/src/newscatcher/types/search_similar_get_request_to.py @@ -0,0 +1,6 @@ +# This file was auto-generated by Fern from our API Definition. + +import datetime as dt +import typing + +SearchSimilarGetRequestTo = typing.Union[dt.datetime, str] diff --git a/src/newscatcher/types/source.py b/src/newscatcher/types/source.py new file mode 100644 index 0000000..ee32d6d --- /dev/null +++ b/src/newscatcher/types/source.py @@ -0,0 +1,3 @@ +# This file was auto-generated by Fern from our API Definition. + +Source = str diff --git a/src/newscatcher/types/sources_response_dto.py b/src/newscatcher/types/sources_response_dto.py index e06ba3e..1565fa8 100644 --- a/src/newscatcher/types/sources_response_dto.py +++ b/src/newscatcher/types/sources_response_dto.py @@ -24,7 +24,7 @@ class SourcesResponseDto(UniversalBaseModel): A list of news sources that match the specified criteria. """ - user_input: typing.Dict[str, typing.Optional[typing.Any]] = pydantic.Field() + user_input: typing.Dict[str, typing.Any] = pydantic.Field() """ The user input parameters for the request. """ diff --git a/src/newscatcher/types/user_input_dto.py b/src/newscatcher/types/user_input_dto.py index f5da8ea..bec72e4 100644 --- a/src/newscatcher/types/user_input_dto.py +++ b/src/newscatcher/types/user_input_dto.py @@ -2,4 +2,4 @@ import typing -UserInputDto = typing.Dict[str, typing.Optional[typing.Any]] +UserInputDto = typing.Dict[str, typing.Any] diff --git a/tests/utils/test_http_client.py b/tests/utils/test_http_client.py index 07e534a..2bccf94 100644 --- a/tests/utils/test_http_client.py +++ b/tests/utils/test_http_client.py @@ -1,13 +1,57 @@ # This file was auto-generated by Fern from our API Definition. -from newscatcher.core.http_client import get_request_body +from typing import Any, Dict + +import pytest + +from newscatcher.core.http_client import ( + AsyncHttpClient, + HttpClient, + _build_url, + get_request_body, + remove_none_from_dict, +) from newscatcher.core.request_options import RequestOptions +# Stub clients for testing HttpClient and AsyncHttpClient +class _DummySyncClient: + """A minimal stub for httpx.Client that records request arguments.""" + + def __init__(self) -> None: + self.last_request_kwargs: Dict[str, Any] = {} + + def request(self, **kwargs: Any) -> "_DummyResponse": + self.last_request_kwargs = kwargs + return _DummyResponse() + + +class _DummyAsyncClient: + """A minimal stub for httpx.AsyncClient that records request arguments.""" + + def __init__(self) -> None: + self.last_request_kwargs: Dict[str, Any] = {} + + async def request(self, **kwargs: Any) -> "_DummyResponse": + self.last_request_kwargs = kwargs + return _DummyResponse() + + +class _DummyResponse: + """A minimal stub for httpx.Response.""" + + status_code = 200 + headers: Dict[str, str] = {} + + def get_request_options() -> RequestOptions: return {"additional_body_parameters": {"see you": "later"}} +def get_request_options_with_none() -> RequestOptions: + return {"additional_body_parameters": {"see you": "later", "optional": None}} + + def test_get_json_request_body() -> None: json_body, data_body = get_request_body(json={"hello": "world"}, data=None, request_options=None, omit=None) assert json_body == {"hello": "world"} @@ -48,14 +92,209 @@ def test_get_none_request_body() -> None: def test_get_empty_json_request_body() -> None: + """Test that implicit empty bodies (json=None) are collapsed to None.""" unrelated_request_options: RequestOptions = {"max_retries": 3} json_body, data_body = get_request_body(json=None, data=None, request_options=unrelated_request_options, omit=None) assert json_body is None assert data_body is None - json_body_extras, data_body_extras = get_request_body( - json={}, data=None, request_options=unrelated_request_options, omit=None + +def test_explicit_empty_json_body_is_preserved() -> None: + """Test that explicit empty bodies (json={}) are preserved and sent as {}. + + This is important for endpoints where the request body is required but all + fields are optional. The server expects valid JSON ({}) not an empty body. + """ + unrelated_request_options: RequestOptions = {"max_retries": 3} + + # Explicit json={} should be preserved + json_body, data_body = get_request_body(json={}, data=None, request_options=unrelated_request_options, omit=None) + assert json_body == {} + assert data_body is None + + # Explicit data={} should also be preserved + json_body2, data_body2 = get_request_body(json=None, data={}, request_options=unrelated_request_options, omit=None) + assert json_body2 is None + assert data_body2 == {} + + +def test_json_body_preserves_none_values() -> None: + """Test that JSON bodies preserve None values (they become JSON null).""" + json_body, data_body = get_request_body( + json={"hello": "world", "optional": None}, data=None, request_options=None, omit=None ) + # JSON bodies should preserve None values + assert json_body == {"hello": "world", "optional": None} + assert data_body is None - assert json_body_extras is None - assert data_body_extras is None + +def test_data_body_preserves_none_values_without_multipart() -> None: + """Test that data bodies preserve None values when not using multipart. + + The filtering of None values happens in HttpClient.request/stream methods, + not in get_request_body. This test verifies get_request_body doesn't filter None. + """ + json_body, data_body = get_request_body( + json=None, data={"hello": "world", "optional": None}, request_options=None, omit=None + ) + # get_request_body should preserve None values in data body + # The filtering happens later in HttpClient.request when multipart is detected + assert data_body == {"hello": "world", "optional": None} + assert json_body is None + + +def test_remove_none_from_dict_filters_none_values() -> None: + """Test that remove_none_from_dict correctly filters out None values.""" + original = {"hello": "world", "optional": None, "another": "value", "also_none": None} + filtered = remove_none_from_dict(original) + assert filtered == {"hello": "world", "another": "value"} + # Original should not be modified + assert original == {"hello": "world", "optional": None, "another": "value", "also_none": None} + + +def test_remove_none_from_dict_empty_dict() -> None: + """Test that remove_none_from_dict handles empty dict.""" + assert remove_none_from_dict({}) == {} + + +def test_remove_none_from_dict_all_none() -> None: + """Test that remove_none_from_dict handles dict with all None values.""" + assert remove_none_from_dict({"a": None, "b": None}) == {} + + +def test_http_client_does_not_pass_empty_params_list() -> None: + """Test that HttpClient passes params=None when params are empty. + + This prevents httpx from stripping existing query parameters from the URL, + which happens when params=[] or params={} is passed. + """ + dummy_client = _DummySyncClient() + http_client = HttpClient( + httpx_client=dummy_client, # type: ignore[arg-type] + base_timeout=lambda: None, + base_headers=lambda: {}, + base_url=lambda: "https://example.com", + ) + + # Use a path with query params (e.g., pagination cursor URL) + http_client.request( + path="resource?after=123", + method="GET", + params=None, + request_options=None, + ) + + # We care that httpx receives params=None, not [] or {} + assert "params" in dummy_client.last_request_kwargs + assert dummy_client.last_request_kwargs["params"] is None + + # Verify the query string in the URL is preserved + url = str(dummy_client.last_request_kwargs["url"]) + assert "after=123" in url, f"Expected query param 'after=123' in URL, got: {url}" + + +def test_http_client_passes_encoded_params_when_present() -> None: + """Test that HttpClient passes encoded params when params are provided.""" + dummy_client = _DummySyncClient() + http_client = HttpClient( + httpx_client=dummy_client, # type: ignore[arg-type] + base_timeout=lambda: None, + base_headers=lambda: {}, + base_url=lambda: "https://example.com/resource", + ) + + http_client.request( + path="", + method="GET", + params={"after": "456"}, + request_options=None, + ) + + params = dummy_client.last_request_kwargs["params"] + # For a simple dict, encode_query should give a single (key, value) tuple + assert params == [("after", "456")] + + +@pytest.mark.asyncio +async def test_async_http_client_does_not_pass_empty_params_list() -> None: + """Test that AsyncHttpClient passes params=None when params are empty. + + This prevents httpx from stripping existing query parameters from the URL, + which happens when params=[] or params={} is passed. + """ + dummy_client = _DummyAsyncClient() + http_client = AsyncHttpClient( + httpx_client=dummy_client, # type: ignore[arg-type] + base_timeout=lambda: None, + base_headers=lambda: {}, + base_url=lambda: "https://example.com", + async_base_headers=None, + ) + + # Use a path with query params (e.g., pagination cursor URL) + await http_client.request( + path="resource?after=123", + method="GET", + params=None, + request_options=None, + ) + + # We care that httpx receives params=None, not [] or {} + assert "params" in dummy_client.last_request_kwargs + assert dummy_client.last_request_kwargs["params"] is None + + # Verify the query string in the URL is preserved + url = str(dummy_client.last_request_kwargs["url"]) + assert "after=123" in url, f"Expected query param 'after=123' in URL, got: {url}" + + +@pytest.mark.asyncio +async def test_async_http_client_passes_encoded_params_when_present() -> None: + """Test that AsyncHttpClient passes encoded params when params are provided.""" + dummy_client = _DummyAsyncClient() + http_client = AsyncHttpClient( + httpx_client=dummy_client, # type: ignore[arg-type] + base_timeout=lambda: None, + base_headers=lambda: {}, + base_url=lambda: "https://example.com/resource", + async_base_headers=None, + ) + + await http_client.request( + path="", + method="GET", + params={"after": "456"}, + request_options=None, + ) + + params = dummy_client.last_request_kwargs["params"] + # For a simple dict, encode_query should give a single (key, value) tuple + assert params == [("after", "456")] + + +def test_basic_url_joining() -> None: + """Test basic URL joining with a simple base URL and path.""" + result = _build_url("https://api.example.com", "/users") + assert result == "https://api.example.com/users" + + +def test_basic_url_joining_trailing_slash() -> None: + """Test basic URL joining with a simple base URL and path.""" + result = _build_url("https://api.example.com/", "/users") + assert result == "https://api.example.com/users" + + +def test_preserves_base_url_path_prefix() -> None: + """Test that path prefixes in base URL are preserved. + + This is the critical bug fix - urllib.parse.urljoin() would strip + the path prefix when the path starts with '/'. + """ + result = _build_url("https://cloud.example.com/org/tenant/api", "/users") + assert result == "https://cloud.example.com/org/tenant/api/users" + + +def test_preserves_base_url_path_prefix_trailing_slash() -> None: + """Test that path prefixes in base URL are preserved.""" + result = _build_url("https://cloud.example.com/org/tenant/api/", "/users") + assert result == "https://cloud.example.com/org/tenant/api/users" diff --git a/tests/utils/test_query_encoding.py b/tests/utils/test_query_encoding.py index 3a3919e..b6a0971 100644 --- a/tests/utils/test_query_encoding.py +++ b/tests/utils/test_query_encoding.py @@ -1,6 +1,5 @@ # This file was auto-generated by Fern from our API Definition. - from newscatcher.core.query_encoder import encode_query