docpull 5.0.1__tar.gz → 5.0.2__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 (159) hide show
  1. {docpull-5.0.1/src/docpull.egg-info → docpull-5.0.2}/PKG-INFO +2 -2
  2. {docpull-5.0.1 → docpull-5.0.2}/README.md +1 -1
  3. {docpull-5.0.1 → docpull-5.0.2}/pyproject.toml +1 -1
  4. {docpull-5.0.1 → docpull-5.0.2}/src/docpull/__init__.py +6 -1
  5. {docpull-5.0.1 → docpull-5.0.2}/src/docpull/cli.py +5 -0
  6. docpull-5.0.2/src/docpull/share.py +547 -0
  7. {docpull-5.0.1 → docpull-5.0.2/src/docpull.egg-info}/PKG-INFO +2 -2
  8. {docpull-5.0.1 → docpull-5.0.2}/src/docpull.egg-info/SOURCES.txt +2 -0
  9. docpull-5.0.2/tests/test_share.py +101 -0
  10. {docpull-5.0.1 → docpull-5.0.2}/tests/test_surface_contract.py +5 -0
  11. {docpull-5.0.1 → docpull-5.0.2}/LICENSE +0 -0
  12. {docpull-5.0.1 → docpull-5.0.2}/setup.cfg +0 -0
  13. {docpull-5.0.1 → docpull-5.0.2}/src/docpull/__main__.py +0 -0
  14. {docpull-5.0.1 → docpull-5.0.2}/src/docpull/accounting.py +0 -0
  15. {docpull-5.0.1 → docpull-5.0.2}/src/docpull/auth_cli.py +0 -0
  16. {docpull-5.0.1 → docpull-5.0.2}/src/docpull/benchmark.py +0 -0
  17. {docpull-5.0.1 → docpull-5.0.2}/src/docpull/cache/__init__.py +0 -0
  18. {docpull-5.0.1 → docpull-5.0.2}/src/docpull/cache/frontier.py +0 -0
  19. {docpull-5.0.1 → docpull-5.0.2}/src/docpull/cache/manager.py +0 -0
  20. {docpull-5.0.1 → docpull-5.0.2}/src/docpull/cache/streaming_dedup.py +0 -0
  21. {docpull-5.0.1 → docpull-5.0.2}/src/docpull/conversion/__init__.py +0 -0
  22. {docpull-5.0.1 → docpull-5.0.2}/src/docpull/conversion/chunking.py +0 -0
  23. {docpull-5.0.1 → docpull-5.0.2}/src/docpull/conversion/extractor.py +0 -0
  24. {docpull-5.0.1 → docpull-5.0.2}/src/docpull/conversion/filings.py +0 -0
  25. {docpull-5.0.1 → docpull-5.0.2}/src/docpull/conversion/markdown.py +0 -0
  26. {docpull-5.0.1 → docpull-5.0.2}/src/docpull/conversion/protocols.py +0 -0
  27. {docpull-5.0.1 → docpull-5.0.2}/src/docpull/conversion/special_cases.py +0 -0
  28. {docpull-5.0.1 → docpull-5.0.2}/src/docpull/conversion/trafilatura_extractor.py +0 -0
  29. {docpull-5.0.1 → docpull-5.0.2}/src/docpull/core/__init__.py +0 -0
  30. {docpull-5.0.1 → docpull-5.0.2}/src/docpull/core/fetcher.py +0 -0
  31. {docpull-5.0.1 → docpull-5.0.2}/src/docpull/discovery/__init__.py +0 -0
  32. {docpull-5.0.1 → docpull-5.0.2}/src/docpull/discovery/_fetch.py +0 -0
  33. {docpull-5.0.1 → docpull-5.0.2}/src/docpull/discovery/composite.py +0 -0
  34. {docpull-5.0.1 → docpull-5.0.2}/src/docpull/discovery/contracts.py +0 -0
  35. {docpull-5.0.1 → docpull-5.0.2}/src/docpull/discovery/crawler.py +0 -0
  36. {docpull-5.0.1 → docpull-5.0.2}/src/docpull/discovery/filters.py +0 -0
  37. {docpull-5.0.1 → docpull-5.0.2}/src/docpull/discovery/link_extractors/__init__.py +0 -0
  38. {docpull-5.0.1 → docpull-5.0.2}/src/docpull/discovery/link_extractors/enhanced.py +0 -0
  39. {docpull-5.0.1 → docpull-5.0.2}/src/docpull/discovery/link_extractors/protocols.py +0 -0
  40. {docpull-5.0.1 → docpull-5.0.2}/src/docpull/discovery/link_extractors/static.py +0 -0
  41. {docpull-5.0.1 → docpull-5.0.2}/src/docpull/discovery/protocols.py +0 -0
  42. {docpull-5.0.1 → docpull-5.0.2}/src/docpull/discovery/sitemap.py +0 -0
  43. {docpull-5.0.1 → docpull-5.0.2}/src/docpull/discovery_cli.py +0 -0
  44. {docpull-5.0.1 → docpull-5.0.2}/src/docpull/doctor.py +0 -0
  45. {docpull-5.0.1 → docpull-5.0.2}/src/docpull/evidence_pack.py +0 -0
  46. {docpull-5.0.1 → docpull-5.0.2}/src/docpull/exports.py +0 -0
  47. {docpull-5.0.1 → docpull-5.0.2}/src/docpull/fixtures/__init__.py +0 -0
  48. {docpull-5.0.1 → docpull-5.0.2}/src/docpull/fixtures/parallel-search-extract.json +0 -0
  49. {docpull-5.0.1 → docpull-5.0.2}/src/docpull/graph.py +0 -0
  50. {docpull-5.0.1 → docpull-5.0.2}/src/docpull/http/__init__.py +0 -0
  51. {docpull-5.0.1 → docpull-5.0.2}/src/docpull/http/client.py +0 -0
  52. {docpull-5.0.1 → docpull-5.0.2}/src/docpull/http/protocols.py +0 -0
  53. {docpull-5.0.1 → docpull-5.0.2}/src/docpull/http/rate_limiter.py +0 -0
  54. {docpull-5.0.1 → docpull-5.0.2}/src/docpull/judge.py +0 -0
  55. {docpull-5.0.1 → docpull-5.0.2}/src/docpull/local_workflows.py +0 -0
  56. {docpull-5.0.1 → docpull-5.0.2}/src/docpull/mcp/__init__.py +0 -0
  57. {docpull-5.0.1 → docpull-5.0.2}/src/docpull/mcp/server.py +0 -0
  58. {docpull-5.0.1 → docpull-5.0.2}/src/docpull/mcp/sources.py +0 -0
  59. {docpull-5.0.1 → docpull-5.0.2}/src/docpull/mcp/tools.py +0 -0
  60. {docpull-5.0.1 → docpull-5.0.2}/src/docpull/metadata_extractor.py +0 -0
  61. {docpull-5.0.1 → docpull-5.0.2}/src/docpull/models/__init__.py +0 -0
  62. {docpull-5.0.1 → docpull-5.0.2}/src/docpull/models/config.py +0 -0
  63. {docpull-5.0.1 → docpull-5.0.2}/src/docpull/models/document.py +0 -0
  64. {docpull-5.0.1 → docpull-5.0.2}/src/docpull/models/events.py +0 -0
  65. {docpull-5.0.1 → docpull-5.0.2}/src/docpull/models/profiles.py +0 -0
  66. {docpull-5.0.1 → docpull-5.0.2}/src/docpull/models/run.py +0 -0
  67. {docpull-5.0.1 → docpull-5.0.2}/src/docpull/monitor.py +0 -0
  68. {docpull-5.0.1 → docpull-5.0.2}/src/docpull/pack_reader.py +0 -0
  69. {docpull-5.0.1 → docpull-5.0.2}/src/docpull/pack_tools.py +0 -0
  70. {docpull-5.0.1 → docpull-5.0.2}/src/docpull/parallel_workflows.py +0 -0
  71. {docpull-5.0.1 → docpull-5.0.2}/src/docpull/parity.py +0 -0
  72. {docpull-5.0.1 → docpull-5.0.2}/src/docpull/parity_cli.py +0 -0
  73. {docpull-5.0.1 → docpull-5.0.2}/src/docpull/passk.py +0 -0
  74. {docpull-5.0.1 → docpull-5.0.2}/src/docpull/pipeline/__init__.py +0 -0
  75. {docpull-5.0.1 → docpull-5.0.2}/src/docpull/pipeline/base.py +0 -0
  76. {docpull-5.0.1 → docpull-5.0.2}/src/docpull/pipeline/manifest.py +0 -0
  77. {docpull-5.0.1 → docpull-5.0.2}/src/docpull/pipeline/steps/__init__.py +0 -0
  78. {docpull-5.0.1 → docpull-5.0.2}/src/docpull/pipeline/steps/chunk.py +0 -0
  79. {docpull-5.0.1 → docpull-5.0.2}/src/docpull/pipeline/steps/convert.py +0 -0
  80. {docpull-5.0.1 → docpull-5.0.2}/src/docpull/pipeline/steps/dedup.py +0 -0
  81. {docpull-5.0.1 → docpull-5.0.2}/src/docpull/pipeline/steps/fetch.py +0 -0
  82. {docpull-5.0.1 → docpull-5.0.2}/src/docpull/pipeline/steps/metadata.py +0 -0
  83. {docpull-5.0.1 → docpull-5.0.2}/src/docpull/pipeline/steps/render.py +0 -0
  84. {docpull-5.0.1 → docpull-5.0.2}/src/docpull/pipeline/steps/save.py +0 -0
  85. {docpull-5.0.1 → docpull-5.0.2}/src/docpull/pipeline/steps/save_json.py +0 -0
  86. {docpull-5.0.1 → docpull-5.0.2}/src/docpull/pipeline/steps/save_ndjson.py +0 -0
  87. {docpull-5.0.1 → docpull-5.0.2}/src/docpull/pipeline/steps/save_okf.py +0 -0
  88. {docpull-5.0.1 → docpull-5.0.2}/src/docpull/pipeline/steps/save_sqlite.py +0 -0
  89. {docpull-5.0.1 → docpull-5.0.2}/src/docpull/pipeline/steps/validate.py +0 -0
  90. {docpull-5.0.1 → docpull-5.0.2}/src/docpull/policy.py +0 -0
  91. {docpull-5.0.1 → docpull-5.0.2}/src/docpull/policy_cli.py +0 -0
  92. {docpull-5.0.1 → docpull-5.0.2}/src/docpull/provider_adapters.py +0 -0
  93. {docpull-5.0.1 → docpull-5.0.2}/src/docpull/provider_capabilities.py +0 -0
  94. {docpull-5.0.1 → docpull-5.0.2}/src/docpull/provider_cli.py +0 -0
  95. {docpull-5.0.1 → docpull-5.0.2}/src/docpull/provider_keys.py +0 -0
  96. {docpull-5.0.1 → docpull-5.0.2}/src/docpull/provider_probes.py +0 -0
  97. {docpull-5.0.1 → docpull-5.0.2}/src/docpull/py.typed +0 -0
  98. {docpull-5.0.1 → docpull-5.0.2}/src/docpull/rendering.py +0 -0
  99. {docpull-5.0.1 → docpull-5.0.2}/src/docpull/scraper.py +0 -0
  100. {docpull-5.0.1 → docpull-5.0.2}/src/docpull/security/__init__.py +0 -0
  101. {docpull-5.0.1 → docpull-5.0.2}/src/docpull/security/download_policy.py +0 -0
  102. {docpull-5.0.1 → docpull-5.0.2}/src/docpull/security/robots.py +0 -0
  103. {docpull-5.0.1 → docpull-5.0.2}/src/docpull/security/url_validator.py +0 -0
  104. {docpull-5.0.1 → docpull-5.0.2}/src/docpull/server.py +0 -0
  105. {docpull-5.0.1 → docpull-5.0.2}/src/docpull/skill_export.py +0 -0
  106. {docpull-5.0.1 → docpull-5.0.2}/src/docpull/source_scoring.py +0 -0
  107. {docpull-5.0.1 → docpull-5.0.2}/src/docpull/time_utils.py +0 -0
  108. {docpull-5.0.1 → docpull-5.0.2}/src/docpull.egg-info/dependency_links.txt +0 -0
  109. {docpull-5.0.1 → docpull-5.0.2}/src/docpull.egg-info/entry_points.txt +0 -0
  110. {docpull-5.0.1 → docpull-5.0.2}/src/docpull.egg-info/requires.txt +0 -0
  111. {docpull-5.0.1 → docpull-5.0.2}/src/docpull.egg-info/top_level.txt +0 -0
  112. {docpull-5.0.1 → docpull-5.0.2}/tests/test_accounting.py +0 -0
  113. {docpull-5.0.1 → docpull-5.0.2}/tests/test_auth_cli.py +0 -0
  114. {docpull-5.0.1 → docpull-5.0.2}/tests/test_benchmark.py +0 -0
  115. {docpull-5.0.1 → docpull-5.0.2}/tests/test_cache_conditional_get.py +0 -0
  116. {docpull-5.0.1 → docpull-5.0.2}/tests/test_chunking.py +0 -0
  117. {docpull-5.0.1 → docpull-5.0.2}/tests/test_ci_policy.py +0 -0
  118. {docpull-5.0.1 → docpull-5.0.2}/tests/test_cli.py +0 -0
  119. {docpull-5.0.1 → docpull-5.0.2}/tests/test_conversion.py +0 -0
  120. {docpull-5.0.1 → docpull-5.0.2}/tests/test_convert_step_new.py +0 -0
  121. {docpull-5.0.1 → docpull-5.0.2}/tests/test_discovery.py +0 -0
  122. {docpull-5.0.1 → docpull-5.0.2}/tests/test_discovery_contracts.py +0 -0
  123. {docpull-5.0.1 → docpull-5.0.2}/tests/test_doctor.py +0 -0
  124. {docpull-5.0.1 → docpull-5.0.2}/tests/test_document_record.py +0 -0
  125. {docpull-5.0.1 → docpull-5.0.2}/tests/test_evidence_pack.py +0 -0
  126. {docpull-5.0.1 → docpull-5.0.2}/tests/test_exports.py +0 -0
  127. {docpull-5.0.1 → docpull-5.0.2}/tests/test_frontier_resume.py +0 -0
  128. {docpull-5.0.1 → docpull-5.0.2}/tests/test_graph.py +0 -0
  129. {docpull-5.0.1 → docpull-5.0.2}/tests/test_integration.py +0 -0
  130. {docpull-5.0.1 → docpull-5.0.2}/tests/test_judge.py +0 -0
  131. {docpull-5.0.1 → docpull-5.0.2}/tests/test_link_extractors.py +0 -0
  132. {docpull-5.0.1 → docpull-5.0.2}/tests/test_local_workflows.py +0 -0
  133. {docpull-5.0.1 → docpull-5.0.2}/tests/test_mcp_server.py +0 -0
  134. {docpull-5.0.1 → docpull-5.0.2}/tests/test_mcp_tools.py +0 -0
  135. {docpull-5.0.1 → docpull-5.0.2}/tests/test_metadata_extractor.py +0 -0
  136. {docpull-5.0.1 → docpull-5.0.2}/tests/test_monitor.py +0 -0
  137. {docpull-5.0.1 → docpull-5.0.2}/tests/test_naming.py +0 -0
  138. {docpull-5.0.1 → docpull-5.0.2}/tests/test_outputs_e2e.py +0 -0
  139. {docpull-5.0.1 → docpull-5.0.2}/tests/test_pack_server.py +0 -0
  140. {docpull-5.0.1 → docpull-5.0.2}/tests/test_pack_tools.py +0 -0
  141. {docpull-5.0.1 → docpull-5.0.2}/tests/test_parallel_workflows.py +0 -0
  142. {docpull-5.0.1 → docpull-5.0.2}/tests/test_parity_workflows.py +0 -0
  143. {docpull-5.0.1 → docpull-5.0.2}/tests/test_passk.py +0 -0
  144. {docpull-5.0.1 → docpull-5.0.2}/tests/test_pipeline.py +0 -0
  145. {docpull-5.0.1 → docpull-5.0.2}/tests/test_policy.py +0 -0
  146. {docpull-5.0.1 → docpull-5.0.2}/tests/test_policy_cli.py +0 -0
  147. {docpull-5.0.1 → docpull-5.0.2}/tests/test_provider_adapters.py +0 -0
  148. {docpull-5.0.1 → docpull-5.0.2}/tests/test_provider_cli.py +0 -0
  149. {docpull-5.0.1 → docpull-5.0.2}/tests/test_provider_keys.py +0 -0
  150. {docpull-5.0.1 → docpull-5.0.2}/tests/test_provider_probes.py +0 -0
  151. {docpull-5.0.1 → docpull-5.0.2}/tests/test_rate_limiter.py +0 -0
  152. {docpull-5.0.1 → docpull-5.0.2}/tests/test_real_site_regressions.py +0 -0
  153. {docpull-5.0.1 → docpull-5.0.2}/tests/test_rendering.py +0 -0
  154. {docpull-5.0.1 → docpull-5.0.2}/tests/test_save_ndjson.py +0 -0
  155. {docpull-5.0.1 → docpull-5.0.2}/tests/test_save_sqlite.py +0 -0
  156. {docpull-5.0.1 → docpull-5.0.2}/tests/test_security_hardening.py +0 -0
  157. {docpull-5.0.1 → docpull-5.0.2}/tests/test_special_cases.py +0 -0
  158. {docpull-5.0.1 → docpull-5.0.2}/tests/test_time_utils.py +0 -0
  159. {docpull-5.0.1 → docpull-5.0.2}/tests/test_trafilatura_extractor.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: docpull
3
- Version: 5.0.1
3
+ Version: 5.0.2
4
4
  Summary: Convert public web pages into clean Markdown for AI agents and RAG
5
5
  Author-email: Zachary Roth <support@raintree.technology>
6
6
  Maintainer-email: Raintree Technology <support@raintree.technology>
@@ -501,7 +501,7 @@ part of the package release contract.
501
501
  rendering, authenticated-source checks, and cron-friendly monitors:
502
502
  `docpull policy`, `docpull discover`, `docpull refresh`,
503
503
  `docpull pack audit`, `docpull answer-pack`, `docpull export`,
504
- `docpull serve`, `docpull render`, `docpull auth check`, and
504
+ `docpull serve`, `docpull share`, `docpull render`, `docpull auth check`, and
505
505
  `docpull monitor`.
506
506
  - `docpull export` writes local files for OpenAI vector JSONL, LangChain,
507
507
  LlamaIndex, DSPy, Sheets CSV/TSV, n8n workflow JSON, Vercel AI SDK JSON,
@@ -389,7 +389,7 @@ part of the package release contract.
389
389
  rendering, authenticated-source checks, and cron-friendly monitors:
390
390
  `docpull policy`, `docpull discover`, `docpull refresh`,
391
391
  `docpull pack audit`, `docpull answer-pack`, `docpull export`,
392
- `docpull serve`, `docpull render`, `docpull auth check`, and
392
+ `docpull serve`, `docpull share`, `docpull render`, `docpull auth check`, and
393
393
  `docpull monitor`.
394
394
  - `docpull export` writes local files for OpenAI vector JSONL, LangChain,
395
395
  LlamaIndex, DSPy, Sheets CSV/TSV, n8n workflow JSON, Vercel AI SDK JSON,
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "docpull"
7
- version = "5.0.1"
7
+ version = "5.0.2"
8
8
  dynamic = []
9
9
  description = "Convert public web pages into clean Markdown for AI agents and RAG"
10
10
  readme = {file = "README.md", content-type = "text/markdown"}
@@ -14,7 +14,7 @@ Usage:
14
14
  print(event)
15
15
  """
16
16
 
17
- __version__ = "5.0.1"
17
+ __version__ = "5.0.2"
18
18
 
19
19
  from .cache import CacheManager, StreamingDeduplicator
20
20
  from .conversion.chunking import Chunk, TokenCounter, chunk_markdown
@@ -94,6 +94,7 @@ from .scraper import (
94
94
  scrape_site,
95
95
  )
96
96
  from .server import PackASGIApp, PackServerError, create_pack_app
97
+ from .share import ReportHTTPServer, ShareError, create_report_server, render_report_document
97
98
 
98
99
  __all__ = [
99
100
  "__version__",
@@ -134,6 +135,10 @@ __all__ = [
134
135
  "create_pack_app",
135
136
  "PackASGIApp",
136
137
  "PackServerError",
138
+ "create_report_server",
139
+ "render_report_document",
140
+ "ReportHTTPServer",
141
+ "ShareError",
137
142
  "ScrapeResult",
138
143
  "ScrapeRunResult",
139
144
  "Scraper",
@@ -206,6 +206,7 @@ Subcommands:
206
206
  answer-pack Answer from local pack evidence with citations
207
207
  export Export a pack for agent or RAG tools
208
208
  serve Serve a local pack over localhost JSON routes
209
+ share Serve a Markdown or HTML report at a local URL
209
210
  monitor Run cron-friendly local pack monitors
210
211
  parallel Build optional Parallel-backed context packs
211
212
  provider Check provider keys and run provider-backed context packs
@@ -1369,6 +1370,10 @@ def main(argv: list[str] | None = None) -> int:
1369
1370
  from .server import run_serve_cli
1370
1371
 
1371
1372
  return run_serve_cli(raw_argv[1:])
1373
+ if raw_argv and raw_argv[0] == "share":
1374
+ from .share import run_share_cli
1375
+
1376
+ return run_share_cli(raw_argv[1:])
1372
1377
  if raw_argv and raw_argv[0] == "export":
1373
1378
  from .exports import run_export_cli
1374
1379
 
@@ -0,0 +1,547 @@
1
+ """Local-first report sharing for Markdown and HTML files."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import html
7
+ import json
8
+ import re
9
+ import webbrowser
10
+ from http import HTTPStatus
11
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
12
+ from pathlib import Path
13
+ from typing import Any
14
+ from urllib.parse import quote, urlparse
15
+
16
+ from rich.console import Console
17
+ from rich.markup import escape
18
+
19
+ from .server import PackServerError, validate_bind_host
20
+
21
+ DEFAULT_SHARE_HOST = "127.0.0.1"
22
+ DEFAULT_SHARE_PORT = 0
23
+ SHARE_SCHEMA_VERSION = 1
24
+
25
+ _HEADING_RE = re.compile(r"^(#{1,6})\s+(.+?)\s*$")
26
+ _UNORDERED_RE = re.compile(r"^\s{0,3}[-*+]\s+(.+?)\s*$")
27
+ _ORDERED_RE = re.compile(r"^\s{0,3}\d+[.)]\s+(.+?)\s*$")
28
+ _FENCE_RE = re.compile(r"^\s*(```|~~~)\s*([A-Za-z0-9_+.-]*)\s*$")
29
+ _TABLE_SEPARATOR_RE = re.compile(r"^\s*\|?\s*:?-{3,}:?\s*(?:\|\s*:?-{3,}:?\s*)+\|?\s*$")
30
+ _LINK_RE = re.compile(r"\[([^\]]+)]\(([^)\s]+)(?:\s+\"[^\"]*\")?\)")
31
+ _CODE_SPAN_RE = re.compile(r"`([^`]+)`")
32
+ _BOLD_RE = re.compile(r"\*\*([^*]+)\*\*|__([^_]+)__")
33
+ _ITALIC_RE = re.compile(r"(?<!\*)\*([^*\n]+)\*(?!\*)")
34
+ _HTML_DOCUMENT_RE = re.compile(r"<html(?:\s|>)", re.IGNORECASE)
35
+
36
+
37
+ class ShareError(RuntimeError):
38
+ """User-facing report sharing error."""
39
+
40
+
41
+ class ReportHTTPServer(ThreadingHTTPServer):
42
+ """Threading HTTP server with reusable sockets for report previews."""
43
+
44
+ allow_reuse_address = True
45
+
46
+
47
+ def create_share_parser() -> argparse.ArgumentParser:
48
+ parser = argparse.ArgumentParser(
49
+ prog="docpull share",
50
+ description="Serve a local Markdown or HTML report at a simple URL",
51
+ )
52
+ parser.add_argument("report", type=Path, help="Markdown or HTML report file")
53
+ parser.add_argument("--host", default=DEFAULT_SHARE_HOST, help="Bind host (default: 127.0.0.1)")
54
+ parser.add_argument(
55
+ "--port",
56
+ type=int,
57
+ default=DEFAULT_SHARE_PORT,
58
+ help="Bind port (default: 0, choose an available port)",
59
+ )
60
+ parser.add_argument("--title", help="Override the rendered page title")
61
+ parser.add_argument("--open", action="store_true", help="Open the share URL in the default browser")
62
+ parser.add_argument(
63
+ "--allow-network-bind",
64
+ action="store_true",
65
+ help="Allow non-loopback hosts such as 0.0.0.0",
66
+ )
67
+ return parser
68
+
69
+
70
+ def run_share_cli(argv: list[str] | None = None) -> int:
71
+ parser = create_share_parser()
72
+ args = parser.parse_args(argv)
73
+ console = Console()
74
+
75
+ try:
76
+ server = create_report_server(
77
+ args.report,
78
+ host=args.host,
79
+ port=args.port,
80
+ title=args.title,
81
+ allow_network_bind=args.allow_network_bind,
82
+ )
83
+ except (ShareError, PackServerError) as err:
84
+ console.print("[red]Share error:[/red] " + escape(str(err)))
85
+ return 1
86
+
87
+ url = report_url(args.host, server.server_port)
88
+ console.print(f"[green]Sharing:[/green] {Path(args.report).expanduser().resolve()}")
89
+ console.print(f"[green]URL:[/green] {url}")
90
+ console.print("Press Ctrl-C to stop.")
91
+ if args.open:
92
+ webbrowser.open(url)
93
+ try:
94
+ server.serve_forever()
95
+ except KeyboardInterrupt:
96
+ console.print("\nStopped sharing.")
97
+ finally:
98
+ server.server_close()
99
+ return 0
100
+
101
+
102
+ def create_report_server(
103
+ report: Path | str,
104
+ *,
105
+ host: str = DEFAULT_SHARE_HOST,
106
+ port: int = DEFAULT_SHARE_PORT,
107
+ title: str | None = None,
108
+ allow_network_bind: bool = False,
109
+ ) -> ReportHTTPServer:
110
+ """Create a local HTTP server for one Markdown or HTML report."""
111
+ validate_bind_host(host, allow_network_bind=allow_network_bind)
112
+ report_path = _validate_report_path(Path(report))
113
+ handler = _handler_for_report(report_path, title=title)
114
+ return ReportHTTPServer((host, port), handler)
115
+
116
+
117
+ def report_url(host: str, port: int) -> str:
118
+ """Build the browser URL printed for a report server."""
119
+ display_host = host.strip() or DEFAULT_SHARE_HOST
120
+ # Display fallback only; socket binding is validated before server creation.
121
+ if display_host == "0.0.0.0": # nosec B104
122
+ display_host = DEFAULT_SHARE_HOST
123
+ if ":" in display_host and not display_host.startswith("["):
124
+ display_host = f"[{display_host}]"
125
+ return f"http://{display_host}:{port}/"
126
+
127
+
128
+ def render_report_document(report: Path | str, *, title: str | None = None) -> bytes:
129
+ """Render a Markdown or HTML report into bytes suitable for text/html."""
130
+ report_path = _validate_report_path(Path(report))
131
+ text = report_path.read_text(encoding="utf-8")
132
+ suffix = report_path.suffix.lower()
133
+ page_title = title or _derive_title(text, fallback=report_path.stem)
134
+ if suffix in {".html", ".htm"}:
135
+ if _HTML_DOCUMENT_RE.search(text):
136
+ return text.encode("utf-8")
137
+ return _html_shell(_inline_fragment(text), title=page_title).encode("utf-8")
138
+ body = _markdown_to_html(text)
139
+ return _html_shell(body, title=page_title).encode("utf-8")
140
+
141
+
142
+ def _handler_for_report(report_path: Path, *, title: str | None) -> type[BaseHTTPRequestHandler]:
143
+ class ReportRequestHandler(BaseHTTPRequestHandler):
144
+ server_version = "DocPullShare/1"
145
+
146
+ def do_GET(self) -> None: # noqa: N802
147
+ self._handle(send_body=True)
148
+
149
+ def do_HEAD(self) -> None: # noqa: N802
150
+ self._handle(send_body=False)
151
+
152
+ def log_message(self, format: str, *args: Any) -> None: # noqa: A002
153
+ return
154
+
155
+ def _handle(self, *, send_body: bool) -> None:
156
+ path = urlparse(self.path).path.rstrip("/") or "/"
157
+ if path == "/health":
158
+ self._write_json(_health_payload(report_path), send_body=send_body)
159
+ return
160
+ if path == "/source":
161
+ body = report_path.read_bytes()
162
+ if _is_markdown(report_path):
163
+ content_type = "text/markdown; charset=utf-8"
164
+ else:
165
+ content_type = "text/html; charset=utf-8"
166
+ self._write_response(HTTPStatus.OK, content_type, body, send_body=send_body)
167
+ return
168
+ if path in {"/", "/report", "/report.html"}:
169
+ try:
170
+ body = render_report_document(report_path, title=title)
171
+ except OSError as err:
172
+ self._write_json(
173
+ {"error": f"Could not read report: {err}"},
174
+ status=HTTPStatus.INTERNAL_SERVER_ERROR,
175
+ )
176
+ return
177
+ self._write_response(HTTPStatus.OK, "text/html; charset=utf-8", body, send_body=send_body)
178
+ return
179
+ self._write_json(
180
+ {"error": "Not found", "path": path},
181
+ status=HTTPStatus.NOT_FOUND,
182
+ send_body=send_body,
183
+ )
184
+
185
+ def _write_json(
186
+ self,
187
+ payload: dict[str, Any],
188
+ *,
189
+ status: HTTPStatus = HTTPStatus.OK,
190
+ send_body: bool = True,
191
+ ) -> None:
192
+ body = json.dumps(payload, ensure_ascii=False, sort_keys=True).encode("utf-8")
193
+ self._write_response(status, "application/json; charset=utf-8", body, send_body=send_body)
194
+
195
+ def _write_response(
196
+ self,
197
+ status: HTTPStatus,
198
+ content_type: str,
199
+ body: bytes,
200
+ *,
201
+ send_body: bool,
202
+ ) -> None:
203
+ self.send_response(int(status))
204
+ self.send_header("Content-Type", content_type)
205
+ self.send_header("Content-Length", str(len(body)))
206
+ self.send_header("Cache-Control", "no-store")
207
+ self.send_header("X-Content-Type-Options", "nosniff")
208
+ self.send_header(
209
+ "Content-Security-Policy",
210
+ "default-src 'none'; img-src data: http: https:; style-src 'unsafe-inline'; "
211
+ "base-uri 'none'; form-action 'none'; frame-ancestors 'none'",
212
+ )
213
+ self.end_headers()
214
+ if send_body:
215
+ self.wfile.write(body)
216
+
217
+ return ReportRequestHandler
218
+
219
+
220
+ def _validate_report_path(path: Path) -> Path:
221
+ report_path = path.expanduser().resolve()
222
+ if not report_path.exists():
223
+ raise ShareError(f"Report file does not exist: {report_path}")
224
+ if not report_path.is_file():
225
+ raise ShareError(f"Report path is not a file: {report_path}")
226
+ if report_path.suffix.lower() not in {"", ".md", ".markdown", ".html", ".htm", ".txt"}:
227
+ raise ShareError("Report must be Markdown, HTML, or plain text.")
228
+ return report_path
229
+
230
+
231
+ def _health_payload(report_path: Path) -> dict[str, Any]:
232
+ stat = report_path.stat()
233
+ return {
234
+ "schema_version": SHARE_SCHEMA_VERSION,
235
+ "status": "ok",
236
+ "report_path": str(report_path),
237
+ "report_bytes": stat.st_size,
238
+ "format": "markdown" if _is_markdown(report_path) else "html",
239
+ }
240
+
241
+
242
+ def _is_markdown(path: Path) -> bool:
243
+ return path.suffix.lower() in {"", ".md", ".markdown", ".txt"}
244
+
245
+
246
+ def _derive_title(text: str, *, fallback: str) -> str:
247
+ for line in text.splitlines():
248
+ stripped = line.strip()
249
+ if stripped.startswith("# "):
250
+ return stripped.removeprefix("# ").strip() or fallback
251
+ if stripped:
252
+ plain = re.sub(r"<[^>]+>", "", stripped)
253
+ return plain[:80] or fallback
254
+ return fallback
255
+
256
+
257
+ def _html_shell(body: str, *, title: str) -> str:
258
+ escaped_title = html.escape(title, quote=True)
259
+ return f"""<!doctype html>
260
+ <html lang="en">
261
+ <head>
262
+ <meta charset="utf-8">
263
+ <meta name="viewport" content="width=device-width, initial-scale=1">
264
+ <title>{escaped_title}</title>
265
+ <style>
266
+ :root {{
267
+ color-scheme: light dark;
268
+ --bg: #f8f7f4;
269
+ --text: #202124;
270
+ --muted: #696b70;
271
+ --line: #d8d6cf;
272
+ --panel: #ffffff;
273
+ --accent: #0f766e;
274
+ --code: #f1eee8;
275
+ }}
276
+ @media (prefers-color-scheme: dark) {{
277
+ :root {{
278
+ --bg: #151515;
279
+ --text: #f3f2ee;
280
+ --muted: #b7b4ad;
281
+ --line: #3d3a34;
282
+ --panel: #1f1f1f;
283
+ --accent: #5eead4;
284
+ --code: #2b2925;
285
+ }}
286
+ }}
287
+ * {{ box-sizing: border-box; }}
288
+ body {{
289
+ margin: 0;
290
+ background: var(--bg);
291
+ color: var(--text);
292
+ font: 16px/1.58 ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
293
+ }}
294
+ main {{
295
+ width: min(920px, calc(100% - 32px));
296
+ margin: 0 auto;
297
+ padding: 48px 0 64px;
298
+ }}
299
+ article {{
300
+ background: var(--panel);
301
+ border: 1px solid var(--line);
302
+ border-radius: 8px;
303
+ padding: clamp(20px, 4vw, 44px);
304
+ overflow-wrap: anywhere;
305
+ }}
306
+ h1, h2, h3, h4, h5, h6 {{
307
+ line-height: 1.22;
308
+ margin: 1.8em 0 0.55em;
309
+ letter-spacing: 0;
310
+ }}
311
+ h1 {{ margin-top: 0; font-size: 2rem; }}
312
+ h2 {{ font-size: 1.45rem; border-bottom: 1px solid var(--line); padding-bottom: 0.25rem; }}
313
+ h3 {{ font-size: 1.15rem; }}
314
+ p, ul, ol, blockquote, table, pre {{ margin: 0 0 1rem; }}
315
+ a {{ color: var(--accent); }}
316
+ blockquote {{
317
+ border-left: 3px solid var(--accent);
318
+ color: var(--muted);
319
+ margin-left: 0;
320
+ padding-left: 1rem;
321
+ }}
322
+ code {{
323
+ background: var(--code);
324
+ border-radius: 4px;
325
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
326
+ font-size: 0.92em;
327
+ padding: 0.12rem 0.28rem;
328
+ }}
329
+ pre {{
330
+ background: var(--code);
331
+ border: 1px solid var(--line);
332
+ border-radius: 8px;
333
+ overflow-x: auto;
334
+ padding: 1rem;
335
+ }}
336
+ pre code {{ background: transparent; padding: 0; }}
337
+ table {{
338
+ border-collapse: collapse;
339
+ display: block;
340
+ overflow-x: auto;
341
+ width: 100%;
342
+ }}
343
+ th, td {{
344
+ border: 1px solid var(--line);
345
+ padding: 0.55rem 0.7rem;
346
+ text-align: left;
347
+ vertical-align: top;
348
+ }}
349
+ th {{ background: var(--code); }}
350
+ </style>
351
+ </head>
352
+ <body>
353
+ <main>
354
+ <article>
355
+ {body}
356
+ </article>
357
+ </main>
358
+ </body>
359
+ </html>
360
+ """
361
+
362
+
363
+ def _markdown_to_html(markdown: str) -> str:
364
+ lines = markdown.splitlines()
365
+ blocks: list[str] = []
366
+ paragraph: list[str] = []
367
+ list_items: list[str] = []
368
+ list_kind: str | None = None
369
+ code_lines: list[str] | None = None
370
+ code_language = ""
371
+ index = 0
372
+
373
+ def flush_paragraph() -> None:
374
+ if paragraph:
375
+ blocks.append(f"<p>{_inline_markdown(' '.join(item.strip() for item in paragraph))}</p>")
376
+ paragraph.clear()
377
+
378
+ def flush_list() -> None:
379
+ nonlocal list_kind
380
+ if list_items and list_kind:
381
+ tag = "ol" if list_kind == "ol" else "ul"
382
+ blocks.append(f"<{tag}>\n" + "\n".join(f"<li>{item}</li>" for item in list_items) + f"\n</{tag}>")
383
+ list_items.clear()
384
+ list_kind = None
385
+
386
+ def flush_flow() -> None:
387
+ flush_paragraph()
388
+ flush_list()
389
+
390
+ while index < len(lines):
391
+ line = lines[index]
392
+ fence = _FENCE_RE.match(line)
393
+ if code_lines is not None:
394
+ if fence:
395
+ language_attr = (
396
+ f' class="language-{html.escape(code_language, quote=True)}"' if code_language else ""
397
+ )
398
+ blocks.append(
399
+ f"<pre><code{language_attr}>{html.escape(chr(10).join(code_lines))}</code></pre>"
400
+ )
401
+ code_lines = None
402
+ code_language = ""
403
+ else:
404
+ code_lines.append(line)
405
+ index += 1
406
+ continue
407
+ if fence:
408
+ flush_flow()
409
+ code_lines = []
410
+ code_language = fence.group(2)
411
+ index += 1
412
+ continue
413
+ if not line.strip():
414
+ flush_flow()
415
+ index += 1
416
+ continue
417
+ if _is_table_start(lines, index):
418
+ flush_flow()
419
+ table_lines = [line, lines[index + 1]]
420
+ index += 2
421
+ while index < len(lines) and "|" in lines[index] and lines[index].strip():
422
+ table_lines.append(lines[index])
423
+ index += 1
424
+ blocks.append(_render_table(table_lines))
425
+ continue
426
+ heading = _HEADING_RE.match(line)
427
+ if heading:
428
+ flush_flow()
429
+ level = len(heading.group(1))
430
+ blocks.append(f"<h{level}>{_inline_markdown(heading.group(2))}</h{level}>")
431
+ index += 1
432
+ continue
433
+ if line.strip() in {"---", "***", "___"}:
434
+ flush_flow()
435
+ blocks.append("<hr>")
436
+ index += 1
437
+ continue
438
+ unordered = _UNORDERED_RE.match(line)
439
+ ordered = _ORDERED_RE.match(line)
440
+ if unordered or ordered:
441
+ flush_paragraph()
442
+ if unordered:
443
+ next_kind = "ul"
444
+ item = unordered.group(1)
445
+ else:
446
+ next_kind = "ol"
447
+ assert ordered is not None
448
+ item = ordered.group(1)
449
+ if list_kind != next_kind:
450
+ flush_list()
451
+ list_kind = next_kind
452
+ list_items.append(_inline_markdown(item))
453
+ index += 1
454
+ continue
455
+ if line.lstrip().startswith(">"):
456
+ flush_flow()
457
+ quote_lines = []
458
+ while index < len(lines) and lines[index].lstrip().startswith(">"):
459
+ quote_lines.append(lines[index].lstrip()[1:].strip())
460
+ index += 1
461
+ blocks.append(f"<blockquote><p>{_inline_markdown(' '.join(quote_lines))}</p></blockquote>")
462
+ continue
463
+ flush_list()
464
+ paragraph.append(line)
465
+ index += 1
466
+
467
+ if code_lines is not None:
468
+ language_attr = f' class="language-{html.escape(code_language, quote=True)}"' if code_language else ""
469
+ blocks.append(f"<pre><code{language_attr}>{html.escape(chr(10).join(code_lines))}</code></pre>")
470
+ flush_flow()
471
+ return "\n".join(f" {block}" for block in blocks)
472
+
473
+
474
+ def _is_table_start(lines: list[str], index: int) -> bool:
475
+ return (
476
+ index + 1 < len(lines) and "|" in lines[index] and bool(_TABLE_SEPARATOR_RE.match(lines[index + 1]))
477
+ )
478
+
479
+
480
+ def _render_table(table_lines: list[str]) -> str:
481
+ rows = [_split_table_row(line) for line in table_lines]
482
+ header = rows[0]
483
+ body_rows = rows[2:]
484
+ cells = "".join(f"<th>{_inline_markdown(cell)}</th>" for cell in header)
485
+ html_lines = ["<table>", f"<thead><tr>{cells}</tr></thead>", "<tbody>"]
486
+ for row in body_rows:
487
+ row_cells = row + [""] * max(0, len(header) - len(row))
488
+ rendered_cells = "".join(f"<td>{_inline_markdown(cell)}</td>" for cell in row_cells[: len(header)])
489
+ html_lines.append(f"<tr>{rendered_cells}</tr>")
490
+ html_lines.append("</tbody></table>")
491
+ return "\n".join(html_lines)
492
+
493
+
494
+ def _split_table_row(line: str) -> list[str]:
495
+ stripped = line.strip()
496
+ if stripped.startswith("|"):
497
+ stripped = stripped[1:]
498
+ if stripped.endswith("|"):
499
+ stripped = stripped[:-1]
500
+ return [cell.strip() for cell in stripped.split("|")]
501
+
502
+
503
+ def _inline_fragment(fragment: str) -> str:
504
+ return "\n".join(f" {line}" for line in fragment.splitlines())
505
+
506
+
507
+ def _inline_markdown(text: str) -> str:
508
+ placeholders: list[str] = []
509
+
510
+ def code_repl(match: re.Match[str]) -> str:
511
+ placeholders.append(f"<code>{html.escape(match.group(1))}</code>")
512
+ return f"DOCPULLCODE{len(placeholders) - 1}TOKEN"
513
+
514
+ escaped = html.escape(_CODE_SPAN_RE.sub(code_repl, text), quote=True)
515
+
516
+ def link_repl(match: re.Match[str]) -> str:
517
+ label = match.group(1)
518
+ href = html.unescape(match.group(2))
519
+ safe_href = _safe_href(href)
520
+ return f'<a href="{html.escape(safe_href, quote=True)}" rel="noreferrer">{label}</a>'
521
+
522
+ escaped = _LINK_RE.sub(link_repl, escaped)
523
+ escaped = _BOLD_RE.sub(lambda match: f"<strong>{match.group(1) or match.group(2)}</strong>", escaped)
524
+ escaped = _ITALIC_RE.sub(r"<em>\1</em>", escaped)
525
+ for placeholder_index, replacement in enumerate(placeholders):
526
+ escaped = escaped.replace(f"DOCPULLCODE{placeholder_index}TOKEN", replacement)
527
+ return escaped
528
+
529
+
530
+ def _safe_href(href: str) -> str:
531
+ parsed = urlparse(href.strip())
532
+ if parsed.scheme and parsed.scheme.lower() not in {"http", "https", "mailto"}:
533
+ return "#"
534
+ if not parsed.scheme and not href.startswith(("#", "/")):
535
+ return "#"
536
+ return quote(href, safe="/:#?&=%@+.,;!$'()*[]~")
537
+
538
+
539
+ __all__ = [
540
+ "ReportHTTPServer",
541
+ "ShareError",
542
+ "create_report_server",
543
+ "create_share_parser",
544
+ "render_report_document",
545
+ "report_url",
546
+ "run_share_cli",
547
+ ]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: docpull
3
- Version: 5.0.1
3
+ Version: 5.0.2
4
4
  Summary: Convert public web pages into clean Markdown for AI agents and RAG
5
5
  Author-email: Zachary Roth <support@raintree.technology>
6
6
  Maintainer-email: Raintree Technology <support@raintree.technology>
@@ -501,7 +501,7 @@ part of the package release contract.
501
501
  rendering, authenticated-source checks, and cron-friendly monitors:
502
502
  `docpull policy`, `docpull discover`, `docpull refresh`,
503
503
  `docpull pack audit`, `docpull answer-pack`, `docpull export`,
504
- `docpull serve`, `docpull render`, `docpull auth check`, and
504
+ `docpull serve`, `docpull share`, `docpull render`, `docpull auth check`, and
505
505
  `docpull monitor`.
506
506
  - `docpull export` writes local files for OpenAI vector JSONL, LangChain,
507
507
  LlamaIndex, DSPy, Sheets CSV/TSV, n8n workflow JSON, Vercel AI SDK JSON,
@@ -33,6 +33,7 @@ src/docpull/py.typed
33
33
  src/docpull/rendering.py
34
34
  src/docpull/scraper.py
35
35
  src/docpull/server.py
36
+ src/docpull/share.py
36
37
  src/docpull/skill_export.py
37
38
  src/docpull/source_scoring.py
38
39
  src/docpull/time_utils.py
@@ -149,6 +150,7 @@ tests/test_rendering.py
149
150
  tests/test_save_ndjson.py
150
151
  tests/test_save_sqlite.py
151
152
  tests/test_security_hardening.py
153
+ tests/test_share.py
152
154
  tests/test_special_cases.py
153
155
  tests/test_surface_contract.py
154
156
  tests/test_time_utils.py