esuls 0.2.2__tar.gz → 0.2.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. {esuls-0.2.2/src/esuls.egg-info → esuls-0.2.4}/PKG-INFO +1 -1
  2. {esuls-0.2.2 → esuls-0.2.4}/pyproject.toml +1 -1
  3. {esuls-0.2.2 → esuls-0.2.4}/src/esuls/request_cli.py +168 -64
  4. {esuls-0.2.2 → esuls-0.2.4/src/esuls.egg-info}/PKG-INFO +1 -1
  5. {esuls-0.2.2 → esuls-0.2.4}/LICENSE +0 -0
  6. {esuls-0.2.2 → esuls-0.2.4}/README.md +0 -0
  7. {esuls-0.2.2 → esuls-0.2.4}/setup.cfg +0 -0
  8. {esuls-0.2.2 → esuls-0.2.4}/src/esuls/__init__.py +0 -0
  9. {esuls-0.2.2 → esuls-0.2.4}/src/esuls/db_cli.py +0 -0
  10. {esuls-0.2.2 → esuls-0.2.4}/src/esuls/download_icon.py +0 -0
  11. {esuls-0.2.2 → esuls-0.2.4}/src/esuls/pdf/__init__.py +0 -0
  12. {esuls-0.2.2 → esuls-0.2.4}/src/esuls/pdf/checker.py +0 -0
  13. {esuls-0.2.2 → esuls-0.2.4}/src/esuls/pdf/cli.py +0 -0
  14. {esuls-0.2.2 → esuls-0.2.4}/src/esuls/pdf/inspect.py +0 -0
  15. {esuls-0.2.2 → esuls-0.2.4}/src/esuls/pdf/metadata.py +0 -0
  16. {esuls-0.2.2 → esuls-0.2.4}/src/esuls/tests/_common.py +0 -0
  17. {esuls-0.2.2 → esuls-0.2.4}/src/esuls/tests/conftest.py +0 -0
  18. {esuls-0.2.2 → esuls-0.2.4}/src/esuls/tests/test_cross_loop.py +0 -0
  19. {esuls-0.2.2 → esuls-0.2.4}/src/esuls/tests/test_db_advanced.py +0 -0
  20. {esuls-0.2.2 → esuls-0.2.4}/src/esuls/tests/test_db_concurrent.py +0 -0
  21. {esuls-0.2.2 → esuls-0.2.4}/src/esuls/tests/test_db_fixes.py +0 -0
  22. {esuls-0.2.2 → esuls-0.2.4}/src/esuls/tests/test_db_migrations.py +0 -0
  23. {esuls-0.2.2 → esuls-0.2.4}/src/esuls/tests/test_db_sqla.py +0 -0
  24. {esuls-0.2.2 → esuls-0.2.4}/src/esuls/tests/test_pdf.py +0 -0
  25. {esuls-0.2.2 → esuls-0.2.4}/src/esuls/tests/test_request_cli.py +0 -0
  26. {esuls-0.2.2 → esuls-0.2.4}/src/esuls/utils.py +0 -0
  27. {esuls-0.2.2 → esuls-0.2.4}/src/esuls.egg-info/SOURCES.txt +0 -0
  28. {esuls-0.2.2 → esuls-0.2.4}/src/esuls.egg-info/dependency_links.txt +0 -0
  29. {esuls-0.2.2 → esuls-0.2.4}/src/esuls.egg-info/entry_points.txt +0 -0
  30. {esuls-0.2.2 → esuls-0.2.4}/src/esuls.egg-info/requires.txt +0 -0
  31. {esuls-0.2.2 → esuls-0.2.4}/src/esuls.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: esuls
3
- Version: 0.2.2
3
+ Version: 0.2.4
4
4
  Summary: Utility library for async database operations, HTTP requests, and parallel execution
5
5
  Author-email: IperGiove <ipergiove@gmail.com>
6
6
  License: MIT
@@ -7,7 +7,7 @@ esuls-pdf = "esuls.pdf.cli:app"
7
7
 
8
8
  [project]
9
9
  name = "esuls"
10
- version = "0.2.2"
10
+ version = "0.2.4"
11
11
  description = "Utility library for async database operations, HTTP requests, and parallel execution"
12
12
  readme = "README.md"
13
13
  requires-python = ">=3.14"
@@ -812,14 +812,19 @@ async def _get_playwright_browser():
812
812
  cached = state["playwright_browser"]
813
813
  if cached is not None and not cached.is_connected():
814
814
  logger.debug("Cached Playwright browser is disconnected; refreshing.")
815
+ # Bound the teardown: a dead/wedged browser can hang its own
816
+ # close()/stop(), and this runs while holding playwright_lock —
817
+ # an unbounded await here would block every other caller.
815
818
  try:
816
- await cached.close()
819
+ async with asyncio.timeout(5):
820
+ await cached.close()
817
821
  except Exception:
818
822
  pass
819
823
  try:
820
824
  instance = state["playwright_instance"]
821
825
  if instance is not None:
822
- await instance.stop()
826
+ async with asyncio.timeout(5):
827
+ await instance.stop()
823
828
  except Exception:
824
829
  pass
825
830
  state["playwright_browser"] = None
@@ -827,20 +832,69 @@ async def _get_playwright_browser():
827
832
 
828
833
  if state["playwright_browser"] is None:
829
834
  from playwright.async_api import async_playwright
830
- state["playwright_instance"] = await async_playwright().start()
831
- # Stealth handles fingerprint surface (webdriver flag,
832
- # plugins, WebGL, chrome runtime, UA, etc.); browser args
833
- # here are only sandbox/IPC concerns for headless runtime.
834
- state["playwright_browser"] = await state["playwright_instance"].chromium.launch(
835
- headless=True,
836
- args=[
837
- "--no-sandbox",
838
- "--disable-dev-shm-usage",
839
- ],
840
- )
835
+ # Bound start()+launch(): a hung driver spawn or Chromium launch
836
+ # would otherwise hold playwright_lock forever and deadlock every
837
+ # make_request_playwright caller. On failure, clear any half-built
838
+ # state so we don't leak a driver subprocess or reuse a broken
839
+ # cache slot, then re-raise for the caller's retry path.
840
+ try:
841
+ async with asyncio.timeout(60):
842
+ state["playwright_instance"] = await async_playwright().start()
843
+ # Stealth handles fingerprint surface (webdriver flag,
844
+ # plugins, WebGL, chrome runtime, UA, etc.); browser args
845
+ # here are only sandbox/IPC concerns for headless runtime.
846
+ state["playwright_browser"] = await state["playwright_instance"].chromium.launch(
847
+ headless=True,
848
+ args=[
849
+ "--no-sandbox",
850
+ "--disable-dev-shm-usage",
851
+ ],
852
+ )
853
+ except BaseException:
854
+ inst = state["playwright_instance"]
855
+ state["playwright_browser"] = None
856
+ state["playwright_instance"] = None
857
+ if inst is not None:
858
+ try:
859
+ async with asyncio.timeout(5):
860
+ await inst.stop()
861
+ except Exception:
862
+ pass
863
+ raise
841
864
  return state["playwright_browser"]
842
865
 
843
866
 
867
+ async def _reset_playwright_browser() -> None:
868
+ """Force-discard the cached Chromium so the next _get_playwright_browser()
869
+ relaunches a fresh process.
870
+
871
+ Used when a browser-level await hangs: a single page can wedge the shared
872
+ browser (the process stays alive, so is_connected() keeps returning True)
873
+ and then every new_page() on it blocks forever. The cache slot is cleared
874
+ under the lock first; the actual close() runs *outside* the lock and is
875
+ bounded, so a hung close() can't stop other callers from acquiring the
876
+ lock to launch a replacement.
877
+ """
878
+ state = _req_loop_state()
879
+ async with state["playwright_lock"]:
880
+ browser = state["playwright_browser"]
881
+ instance = state["playwright_instance"]
882
+ state["playwright_browser"] = None
883
+ state["playwright_instance"] = None
884
+ if browser is not None:
885
+ try:
886
+ async with asyncio.timeout(5):
887
+ await browser.close()
888
+ except Exception:
889
+ pass
890
+ if instance is not None:
891
+ try:
892
+ async with asyncio.timeout(5):
893
+ await instance.stop()
894
+ except Exception:
895
+ pass
896
+
897
+
844
898
  async def make_request_playwright(
845
899
  url: str,
846
900
  timeout_request: int = 15,
@@ -867,60 +921,110 @@ async def make_request_playwright(
867
921
  (analytics, polling, websockets) are cut off at `wait_seconds`
868
922
  and we proceed with the partial render.
869
923
  """
870
- browser = await _get_playwright_browser()
924
+ # set_default_timeout() below governs only Playwright actions issued
925
+ # AFTER it; it never covers browser.new_page(), stealth_async(), or
926
+ # page.close(). A wedged Chromium (a prior page hung the process while
927
+ # is_connected() still reports True) makes those awaits block forever
928
+ # with no bound, deadlocking every caller that shares the browser. Wrap
929
+ # each attempt's whole page lifecycle in one hard wall-clock budget so a
930
+ # single bad site can never hang the process. The budget comfortably
931
+ # exceeds the sum of the inner per-action timeouts (goto + networkidle +
932
+ # content), so it only trips on a genuine hang, not a merely slow page.
933
+ attempt_budget = 2 * timeout_request + wait_seconds + 15
871
934
 
872
935
  for attempt in range(max_attempt):
873
936
  page = None
874
937
  try:
875
- page = await browser.new_page()
876
- await stealth_async(page)
877
- page.set_default_timeout(timeout_request * 1000)
878
-
879
- resp = await page.goto(url, wait_until="domcontentloaded")
880
- # Wait for network activity to settle (more robust than a fixed
881
- # sleep for SPAs). Bounded by `wait_seconds` so pages with
882
- # endless background traffic don't hang forever.
883
- if wait_seconds > 0:
884
- try:
885
- await page.wait_for_load_state(
886
- "networkidle",
887
- timeout=int(wait_seconds * 1000),
888
- )
889
- except Exception as wait_err:
890
- # Most likely a Playwright TimeoutError: page kept
891
- # making requests beyond wait_seconds. Proceed with
892
- # whatever has rendered so far.
938
+ browser = await _get_playwright_browser()
939
+ async with asyncio.timeout(attempt_budget):
940
+ page = await browser.new_page()
941
+ await stealth_async(page)
942
+ page.set_default_timeout(timeout_request * 1000)
943
+
944
+ # Track the final main-frame document response. JS cookie
945
+ # challenges (Sucuri CloudProxy, some Cloudflare/HSTS 307s)
946
+ # answer the first request with a 307 + a script that sets a
947
+ # cookie and calls location.reload(); the real page then loads
948
+ # with a fresh 200. page.goto() only returns that first
949
+ # response (the 307), so we follow the main frame's last
950
+ # navigation response to report the status/headers of the
951
+ # document actually rendered.
952
+ final_doc: dict = {"resp": None}
953
+
954
+ def _track_main_doc(r):
955
+ try:
956
+ if (r.request.is_navigation_request()
957
+ and r.frame is page.main_frame):
958
+ final_doc["resp"] = r
959
+ except Exception:
960
+ # Frame/request may already be torn down mid-event.
961
+ pass
962
+
963
+ page.on("response", _track_main_doc)
964
+
965
+ resp = await page.goto(url, wait_until="domcontentloaded")
966
+ # Wait for network activity to settle (more robust than a fixed
967
+ # sleep for SPAs). Bounded by `wait_seconds` so pages with
968
+ # endless background traffic don't hang forever.
969
+ if wait_seconds > 0:
970
+ try:
971
+ await page.wait_for_load_state(
972
+ "networkidle",
973
+ timeout=int(wait_seconds * 1000),
974
+ )
975
+ except Exception as wait_err:
976
+ # Most likely a Playwright TimeoutError: page kept
977
+ # making requests beyond wait_seconds. Proceed with
978
+ # whatever has rendered so far.
979
+ logger.debug(
980
+ f"networkidle wait expired, proceeding: {wait_err}"
981
+ )
982
+
983
+ doc_resp = final_doc["resp"] or resp
984
+ page_source = await page.content()
985
+ final_url = page.url
986
+ status_code = doc_resp.status if doc_resp else 200
987
+
988
+ response = Response(
989
+ status_code=status_code,
990
+ headers=dict(doc_resp.headers) if doc_resp else {},
991
+ _content=page_source.encode("utf-8"),
992
+ text=page_source,
993
+ url=final_url,
994
+ )
995
+
996
+ if status_code not in _SUCCESS_STATUS_RANGE:
893
997
  logger.debug(
894
- f"networkidle wait expired, proceeding: {wait_err}"
998
+ f"playwright request: {status_code} - {url} "
999
+ f"- attempt {attempt + 1}/{max_attempt}"
895
1000
  )
1001
+ if (no_retry_status_codes
1002
+ and status_code in no_retry_status_codes):
1003
+ return response if force_response else None
1004
+ if attempt + 1 == max_attempt:
1005
+ return response if force_response else None
1006
+ await asyncio.sleep(_apply_jitter(exception_sleep, jitter))
1007
+ continue
896
1008
 
897
- page_source = await page.content()
898
- final_url = page.url
899
- status_code = resp.status if resp else 200
1009
+ return response
900
1010
 
901
- response = Response(
902
- status_code=status_code,
903
- headers=dict(resp.headers) if resp else {},
904
- _content=page_source.encode("utf-8"),
905
- text=page_source,
906
- url=final_url,
1011
+ except TimeoutError:
1012
+ # Hard wall-clock budget exceeded. This is the builtin
1013
+ # TimeoutError from asyncio.timeout() — distinct from Playwright's
1014
+ # own TimeoutError (a separate class, caught below as an ordinary
1015
+ # slow-page retry). Reaching here means an await with no inner
1016
+ # bound hung (almost always new_page() on a wedged browser), so
1017
+ # discard the shared browser; the next attempt relaunches a clean
1018
+ # Chromium instead of hanging on the same dead process.
1019
+ logger.debug(
1020
+ f"playwright attempt exceeded {attempt_budget}s budget "
1021
+ f"(browser likely wedged) - {url} "
1022
+ f"- attempt {attempt + 1}/{max_attempt}"
907
1023
  )
908
-
909
- if status_code not in _SUCCESS_STATUS_RANGE:
910
- logger.debug(
911
- f"playwright request: {status_code} - {url} "
912
- f"- attempt {attempt + 1}/{max_attempt}"
913
- )
914
- if (no_retry_status_codes
915
- and status_code in no_retry_status_codes):
916
- return response if force_response else None
917
- if attempt + 1 == max_attempt:
918
- return response if force_response else None
919
- await asyncio.sleep(_apply_jitter(exception_sleep, jitter))
920
- continue
921
-
922
- return response
923
-
1024
+ await _reset_playwright_browser()
1025
+ if attempt + 1 == max_attempt:
1026
+ return None
1027
+ await asyncio.sleep(_apply_jitter(exception_sleep, jitter))
924
1028
  except Exception as e:
925
1029
  logger.debug(
926
1030
  f"playwright request error: {e} - {url} "
@@ -930,14 +1034,14 @@ async def make_request_playwright(
930
1034
  return None
931
1035
  await asyncio.sleep(_apply_jitter(exception_sleep, jitter))
932
1036
  finally:
933
- # page.close() can itself fail when the browser is in a bad
934
- # state. Swallow that error so the retry loop can run its
935
- # remaining attempts; we'd rather lose the page (it's about
936
- # to be GC'd anyway) than abort the request with an unrelated
937
- # cleanup exception.
1037
+ # page.close() can itself hang or fail when the browser is in a
1038
+ # bad state. Bound it and swallow errors so the retry loop can run
1039
+ # its remaining attempts; we'd rather lose the page (it's about to
1040
+ # be GC'd anyway) than block on cleanup.
938
1041
  if page is not None:
939
1042
  try:
940
- await page.close()
1043
+ async with asyncio.timeout(5):
1044
+ await page.close()
941
1045
  except Exception as close_err:
942
1046
  logger.debug(f"page.close() failed (ignored): {close_err}")
943
1047
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: esuls
3
- Version: 0.2.2
3
+ Version: 0.2.4
4
4
  Summary: Utility library for async database operations, HTTP requests, and parallel execution
5
5
  Author-email: IperGiove <ipergiove@gmail.com>
6
6
  License: MIT
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes