sphinxcontrib-screenshot 0.1.4__py3-none-any.whl → 0.3.0__py3-none-any.whl

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.

Potentially problematic release.


This version of sphinxcontrib-screenshot might be problematic. Click here for more details.

@@ -0,0 +1,552 @@
1
+ # Copyright 2023 Google LLC
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ import hashlib
16
+ import importlib
17
+ import importlib.metadata
18
+ import os
19
+ import threading
20
+ import typing
21
+ import wsgiref.simple_server
22
+ from concurrent.futures import ThreadPoolExecutor
23
+ from pathlib import Path
24
+ from urllib.parse import urlparse
25
+
26
+ from docutils import nodes
27
+ from docutils.parsers.rst import directives
28
+ from docutils.parsers.rst.directives.images import Figure
29
+ from playwright._impl._helper import ColorScheme
30
+ from playwright.sync_api import Browser, BrowserContext
31
+ from playwright.sync_api import TimeoutError as PlaywrightTimeoutError
32
+ from playwright.sync_api import sync_playwright
33
+ from portpicker import pick_unused_port
34
+ from sphinx.application import Sphinx
35
+ from sphinx.config import Config
36
+ from sphinx.util import logging as sphinx_logging
37
+ from sphinx.util.docutils import SphinxDirective
38
+ from sphinx.util.fileutil import copy_asset
39
+
40
+ logger = sphinx_logging.getLogger(__name__)
41
+
42
+ Meta = typing.TypedDict('Meta', {
43
+ 'version': str,
44
+ 'parallel_read_safe': bool,
45
+ 'parallel_write_safe': bool
46
+ })
47
+
48
+ ContextBuilder = typing.Optional[typing.Callable[[Browser, str, str],
49
+ BrowserContext]]
50
+
51
+
52
+ def parse_expected_status_codes(codes_str: str) -> typing.List[int]:
53
+ """Parse a comma-separated string of HTTP status codes into a list.
54
+
55
+ Args:
56
+ codes_str: Comma-separated status codes like "200, 201, 302".
57
+
58
+ Returns:
59
+ List of integer status codes.
60
+ """
61
+ return [int(code.strip()) for code in codes_str.split(',')]
62
+
63
+
64
+ class ScreenshotDirective(SphinxDirective, Figure):
65
+ """Sphinx Screenshot Dirctive.
66
+
67
+ This directive embeds a screenshot of a webpage.
68
+
69
+ # Example
70
+
71
+ You can simply pass a URL for a webpage that you want to take a screenshot.
72
+
73
+ ```rst
74
+ .. screenshot:: http://www.example.com
75
+ ```
76
+
77
+ You can also specify the screen size for the screenshot with
78
+ `viewport-width` and `viewport-height` parameters in pixel.
79
+
80
+ ```rst
81
+ .. screenshot:: http://www.example.com
82
+ :viewport-width: 1280
83
+ :viewport-height: 960
84
+ ```
85
+
86
+ You can describe the interaction that you want to have with the webpage
87
+ before taking a screenshot in JavaScript.
88
+
89
+ ```rst
90
+ .. screenshot:: http://www.example.com
91
+
92
+ document.querySelector('button').click();
93
+ ```
94
+
95
+ It also generates a PDF file when `pdf` option is given, which might be
96
+ useful when you need scalable image assets.
97
+
98
+ ```rst
99
+ .. screenshot:: http://www.example.com
100
+ :pdf:
101
+ ```
102
+
103
+ You can automatically generate both light and dark mode screenshots by
104
+ setting `:color-scheme: auto`. This creates two screenshots with appropriate
105
+ CSS classes (`only-light` and `only-dark`) that are automatically shown
106
+ based on the user's theme preference.
107
+
108
+ ```rst
109
+ .. screenshot:: http://www.example.com
110
+ :color-scheme: auto
111
+ ```
112
+
113
+ You can take a screenshot of a local file using a root-relative path.
114
+
115
+ ```rst
116
+ .. screenshot:: /static/example.html
117
+ ```
118
+
119
+ Or you can use a document-relative path.
120
+
121
+ ```rst
122
+ .. screenshot:: ./example.html
123
+ ```
124
+
125
+ The `file://` protocol is also supported.
126
+
127
+ ```rst
128
+ .. screenshot:: file:///path/to/your/file.html
129
+ ```
130
+ """
131
+
132
+ required_arguments = 1 # URL
133
+ option_spec = {
134
+ **(Figure.option_spec or {}),
135
+ 'browser': str,
136
+ 'viewport-height': directives.positive_int,
137
+ 'viewport-width': directives.positive_int,
138
+ 'interactions': str,
139
+ 'pdf': directives.flag,
140
+ 'color-scheme': str,
141
+ 'full-page': directives.flag,
142
+ 'context': str,
143
+ 'headers': directives.unchanged,
144
+ 'locale': str,
145
+ 'timezone': str,
146
+ 'device-scale-factor': directives.positive_int,
147
+ 'status-code': str,
148
+ }
149
+ pool = ThreadPoolExecutor()
150
+
151
+ @staticmethod
152
+ def take_screenshot(url: str,
153
+ browser_name: str,
154
+ viewport_width: int,
155
+ viewport_height: int,
156
+ filepath: str,
157
+ init_script: str,
158
+ interactions: str,
159
+ generate_pdf: bool,
160
+ color_scheme: ColorScheme,
161
+ full_page: bool,
162
+ context_builder: ContextBuilder,
163
+ headers: dict,
164
+ device_scale_factor: int,
165
+ locale: typing.Optional[str],
166
+ timezone: typing.Optional[str],
167
+ expected_status_codes: typing.Optional[str] = None,
168
+ location: typing.Optional[str] = None):
169
+ """Takes a screenshot with Playwright's Chromium browser.
170
+
171
+ Args:
172
+ url (str): The HTTP/HTTPS URL of the webpage to screenshot.
173
+ browser_name (str): Browser to use ('chromium', 'firefox' or 'webkit').
174
+ viewport_width (int): The width of the screenshot in pixels.
175
+ viewport_height (int): The height of the screenshot in pixels.
176
+ filepath (str): The path to save the screenshot to.
177
+ init_script (str): JavaScript code to be evaluated after the document
178
+ was created but before any of its scripts were run. See more details at
179
+ https://playwright.dev/python/docs/api/class-page#page-add-init-script
180
+ interactions (str): JavaScript code to run before taking the screenshot
181
+ after the page was loaded.
182
+ generate_pdf (bool): Generate a PDF file along with the screenshot.
183
+ color_scheme (str): The preferred color scheme. Can be 'light' or 'dark'.
184
+ full_page (bool): Take a full page screenshot.
185
+ context: A method to build the Playwright context.
186
+ headers (dict): Custom request header.
187
+ device_scale_factor (int): The device scale factor for the screenshot.
188
+ This can be thought of as DPR (device pixel ratio).
189
+ locale (str, optional): User locale for the request.
190
+ timezone (str, optional): User timezone for the request.
191
+ expected_status_codes (str, optional): Expected HTTP status codes.
192
+ Format: comma-separated list of codes (e.g., "200,201,302").
193
+ Defaults to "200,302" (OK and redirect).
194
+ location (str, optional): Document location for warning messages.
195
+ """
196
+ if expected_status_codes is None:
197
+ expected_status_codes = "200,302"
198
+
199
+ valid_codes = parse_expected_status_codes(expected_status_codes)
200
+
201
+ with sync_playwright() as playwright:
202
+ browser: Browser = getattr(playwright, browser_name).launch()
203
+
204
+ if context_builder:
205
+ try:
206
+ context = context_builder(browser, url, color_scheme)
207
+ except PlaywrightTimeoutError:
208
+ raise RuntimeError(
209
+ 'Timeout error occured at %s in executing py init script %s' %
210
+ (url, context_builder.__name__))
211
+ else:
212
+ context = browser.new_context(
213
+ color_scheme=color_scheme,
214
+ locale=locale,
215
+ timezone_id=timezone,
216
+ device_scale_factor=device_scale_factor)
217
+
218
+ page = context.new_page()
219
+ page.set_default_timeout(10000)
220
+ page.set_viewport_size({
221
+ 'width': viewport_width,
222
+ 'height': viewport_height
223
+ })
224
+
225
+ try:
226
+ if init_script:
227
+ page.add_init_script(init_script)
228
+ page.set_extra_http_headers(headers)
229
+ response = page.goto(url)
230
+
231
+ if response and response.status not in valid_codes:
232
+ logger.warning(
233
+ f'Page {url} returned status code {response.status}, '
234
+ f'expected one of: {expected_status_codes}',
235
+ type='screenshot',
236
+ subtype='status_code',
237
+ location=location)
238
+
239
+ page.wait_for_load_state('networkidle')
240
+
241
+ # Execute interactions
242
+ if interactions:
243
+ page.evaluate(interactions)
244
+ page.wait_for_load_state('networkidle')
245
+ except PlaywrightTimeoutError:
246
+ raise RuntimeError('Timeout error occured at %s in executing\n%s' %
247
+ (url, interactions))
248
+ page.screenshot(path=filepath, full_page=full_page)
249
+ if generate_pdf:
250
+ page.emulate_media(media='screen')
251
+ root, ext = os.path.splitext(filepath)
252
+ page.pdf(
253
+ width=f'{viewport_width}px',
254
+ height=f'{viewport_height}px',
255
+ path=root + '.pdf')
256
+ page.close()
257
+ browser.close()
258
+
259
+ def evaluate_substitutions(self, text: str) -> str:
260
+ substitutions = self.state.document.substitution_defs
261
+ for key, value in substitutions.items():
262
+ text = text.replace(f"|{key}|", value.astext())
263
+ return text
264
+
265
+ def _add_css_class_to_nodes(self, nodes_list: typing.Sequence[nodes.Node],
266
+ css_class: str) -> typing.Sequence[nodes.Node]:
267
+ """Add a CSS class to image or figure nodes.
268
+
269
+ Args:
270
+ nodes_list: List of docutils nodes to modify.
271
+ css_class: CSS class name to add (e.g., 'only-light' or 'only-dark').
272
+
273
+ Returns:
274
+ The modified list of nodes.
275
+ """
276
+ for node in nodes_list:
277
+ if isinstance(node, (nodes.image, nodes.figure)):
278
+ existing_classes: list = node.get('classes', [])
279
+ node['classes'] = existing_classes + [css_class]
280
+ if isinstance(node, nodes.figure):
281
+ for child in node.children:
282
+ if isinstance(child, nodes.image):
283
+ existing_classes = child.get('classes', [])
284
+ child['classes'] = existing_classes + [css_class]
285
+ return nodes_list
286
+
287
+ def _generate_single_screenshot(
288
+ self,
289
+ color_scheme: typing.Optional[str] = None
290
+ ) -> typing.Sequence[nodes.Node]:
291
+ """Generate a single screenshot and return the docutils nodes.
292
+
293
+ Args:
294
+ color_scheme: Optional color scheme to override the directive
295
+ option. Used when generating dual-theme screenshots.
296
+
297
+ Returns:
298
+ List of docutils nodes representing the screenshot.
299
+ """
300
+ screenshot_init_script: str = self.env.config.screenshot_init_script or ''
301
+ docdir = os.path.dirname(self.env.doc2path(self.env.docname))
302
+
303
+ # Ensure the screenshots directory exists
304
+ ss_dirpath = os.path.join(self.env.app.outdir, '_static', 'screenshots')
305
+ os.makedirs(ss_dirpath, exist_ok=True)
306
+
307
+ raw_path = self.arguments[0]
308
+ url_or_filepath = self.evaluate_substitutions(raw_path)
309
+
310
+ # Check if the path is a local file path.
311
+ if urlparse(url_or_filepath).scheme == '':
312
+ # root-relative path
313
+ if url_or_filepath.startswith('/'):
314
+ url_or_filepath = os.path.join(self.env.srcdir,
315
+ url_or_filepath.lstrip('/'))
316
+ # document-relative path
317
+ else:
318
+ url_or_filepath = os.path.join(docdir, url_or_filepath)
319
+ url_or_filepath = "file://" + os.path.normpath(url_or_filepath)
320
+
321
+ if urlparse(url_or_filepath).scheme not in {'http', 'https', 'file'}:
322
+ raise RuntimeError(
323
+ f'Invalid URL: {url_or_filepath}. ' +
324
+ 'Only HTTP/HTTPS/FILE URLs or root/document-relative file paths ' +
325
+ 'are supported.')
326
+
327
+ interactions = self.options.get('interactions', '')
328
+ browser = self.options.get('browser',
329
+ self.env.config.screenshot_default_browser)
330
+ viewport_height = self.options.get(
331
+ 'viewport-height', self.env.config.screenshot_default_viewport_height)
332
+ viewport_width = self.options.get(
333
+ 'viewport-width', self.env.config.screenshot_default_viewport_width)
334
+ color_scheme = color_scheme or self.options.get(
335
+ 'color-scheme', self.env.config.screenshot_default_color_scheme)
336
+ pdf = 'pdf' in self.options
337
+ full_page = ('full-page' in self.options or
338
+ self.env.config.screenshot_default_full_page)
339
+ locale = self.options.get('locale',
340
+ self.env.config.screenshot_default_locale)
341
+ timezone = self.options.get('timezone',
342
+ self.env.config.screenshot_default_timezone)
343
+ context = self.options.get('context', '')
344
+ headers = self.options.get('headers', '')
345
+ device_scale_factor = self.options.get(
346
+ 'device-scale-factor',
347
+ self.env.config.screenshot_default_device_scale_factor)
348
+ status_code = self.options.get('status-code', None)
349
+ request_headers = {**self.env.config.screenshot_default_headers}
350
+ if headers:
351
+ for header in headers.strip().split("\n"):
352
+ name, value = header.split(" ", 1)
353
+ request_headers[name] = value
354
+
355
+ # Generate filename based on hash of parameters
356
+ hash_input = "_".join([
357
+ raw_path, browser,
358
+ str(viewport_height),
359
+ str(viewport_width), color_scheme, context, interactions,
360
+ str(full_page),
361
+ str(device_scale_factor),
362
+ str(status_code or "")
363
+ ])
364
+ filename = hashlib.md5(hash_input.encode()).hexdigest() + '.png'
365
+ filepath = os.path.join(ss_dirpath, filename)
366
+
367
+ if context:
368
+ context_builder_path = self.config.screenshot_contexts[context]
369
+ context_builder = resolve_python_method(context_builder_path)
370
+ else:
371
+ context_builder = None
372
+
373
+ # Check if the file already exists. If not, take a screenshot
374
+ if not os.path.exists(filepath):
375
+ fut = self.pool.submit(ScreenshotDirective.take_screenshot,
376
+ url_or_filepath, browser, viewport_width,
377
+ viewport_height, filepath, screenshot_init_script,
378
+ interactions, pdf,
379
+ typing.cast(ColorScheme, color_scheme), full_page,
380
+ context_builder, request_headers,
381
+ device_scale_factor, locale, timezone,
382
+ status_code, self.env.docname)
383
+ fut.result()
384
+
385
+ rel_ss_dirpath = os.path.relpath(ss_dirpath, start=docdir)
386
+ rel_filepath = os.path.join(rel_ss_dirpath, filename).replace(os.sep, '/')
387
+
388
+ self.arguments[0] = rel_filepath
389
+ return super().run()
390
+
391
+ def _generate_dual_theme_screenshots(self) -> typing.Sequence[nodes.Node]:
392
+ """Generate two screenshots (light and dark mode) with CSS classes.
393
+
394
+ Returns:
395
+ List of docutils nodes containing both light and dark mode screenshots.
396
+ """
397
+ original_arguments = self.arguments[:]
398
+ original_options = self.options.copy()
399
+
400
+ light_nodes = self._generate_single_screenshot(color_scheme='light')
401
+ light_nodes = self._add_css_class_to_nodes(light_nodes, 'only-light')
402
+
403
+ self.arguments = original_arguments[:]
404
+ self.options = original_options.copy()
405
+
406
+ dark_nodes = self._generate_single_screenshot(color_scheme='dark')
407
+ dark_nodes = self._add_css_class_to_nodes(dark_nodes, 'only-dark')
408
+
409
+ self.arguments = original_arguments
410
+ self.options = original_options
411
+
412
+ return list(light_nodes) + list(dark_nodes)
413
+
414
+ def run(self) -> typing.Sequence[nodes.Node]:
415
+ """Process the screenshot directive and generate appropriate nodes.
416
+
417
+ Returns:
418
+ List of docutils nodes. If color-scheme is 'auto', returns two sets
419
+ of nodes (one for light mode, one for dark mode). Otherwise returns
420
+ a single screenshot node.
421
+ """
422
+ color_scheme = self.options.get(
423
+ 'color-scheme', self.env.config.screenshot_default_color_scheme)
424
+
425
+ if color_scheme == 'auto':
426
+ return self._generate_dual_theme_screenshots()
427
+ else:
428
+ return self._generate_single_screenshot()
429
+
430
+
431
+ app_threads = {}
432
+
433
+
434
+ def resolve_python_method(import_path: str):
435
+ module_path, method_name = import_path.split(":")
436
+ module = importlib.import_module(module_path)
437
+ method = getattr(module, method_name)
438
+ return method
439
+
440
+
441
+ def setup_apps(app: Sphinx, config: Config):
442
+ """Start the WSGI application threads.
443
+
444
+ A new replacement is created for each WSGI app."""
445
+ for wsgi_app_name, wsgi_app_path in config.screenshot_apps.items():
446
+ port = pick_unused_port()
447
+ config.rst_prolog = (
448
+ config.rst_prolog or
449
+ "") + f"\n.. |{wsgi_app_name}| replace:: http://localhost:{port}\n"
450
+ app_builder = resolve_python_method(wsgi_app_path)
451
+ wsgi_app = app_builder(app)
452
+ httpd = wsgiref.simple_server.make_server("localhost", port, wsgi_app)
453
+ thread = threading.Thread(target=httpd.serve_forever)
454
+ thread.start()
455
+ app_threads[wsgi_app_name] = (httpd, thread)
456
+
457
+
458
+ def teardown_apps(app: Sphinx, exception: typing.Optional[Exception]):
459
+ """Shut down the WSGI application threads."""
460
+ for httpd, thread in app_threads.values():
461
+ httpd.shutdown()
462
+ thread.join()
463
+
464
+
465
+ def copy_static_files(app: Sphinx, exception: typing.Optional[Exception]):
466
+ """Copy static CSS files from the extension to the build output directory.
467
+
468
+ This function is called during the build-finished event to copy the
469
+ screenshot-theme.css file to the output directory.
470
+
471
+ Args:
472
+ app: The Sphinx application instance.
473
+ exception: Exception that occurred during build, if any.
474
+ """
475
+ if exception is None and app.builder.format == 'html':
476
+ static_source_dir = Path(__file__).parent / 'static'
477
+ static_dest_dir = Path(app.outdir) / '_static' / 'sphinxcontrib-screenshot'
478
+ static_dest_dir.mkdir(parents=True, exist_ok=True)
479
+
480
+ css_source = str(static_source_dir / 'screenshot-theme.css')
481
+ css_dest = str(static_dest_dir)
482
+ copy_asset(css_source, css_dest)
483
+
484
+
485
+ def setup(app: Sphinx) -> Meta:
486
+ app.add_directive('screenshot', ScreenshotDirective)
487
+ app.add_config_value('screenshot_init_script', '', 'env')
488
+ app.add_config_value(
489
+ 'screenshot_default_viewport_width',
490
+ 1280,
491
+ 'env',
492
+ description="The default width for screenshots")
493
+ app.add_config_value(
494
+ 'screenshot_default_viewport_height',
495
+ 960,
496
+ 'env',
497
+ description="The default height for screenshots")
498
+ app.add_config_value(
499
+ 'screenshot_default_browser',
500
+ 'chromium',
501
+ 'env',
502
+ description="The default browser for screenshots")
503
+ app.add_config_value(
504
+ 'screenshot_default_full_page',
505
+ False,
506
+ 'env',
507
+ description="Whether to take full page screenshots")
508
+ app.add_config_value(
509
+ 'screenshot_default_color_scheme',
510
+ 'null',
511
+ 'env',
512
+ description="The default color scheme for screenshots. " +
513
+ "Use 'auto' to generate both light and dark mode screenshots")
514
+ app.add_config_value(
515
+ 'screenshot_contexts', {},
516
+ 'env',
517
+ types=[dict[str, str]],
518
+ description="A dict of paths to Playwright context build methods")
519
+ app.add_config_value(
520
+ 'screenshot_default_headers', {},
521
+ 'env',
522
+ description="The default headers to pass in requests")
523
+ app.add_config_value(
524
+ 'screenshot_default_device_scale_factor',
525
+ 1,
526
+ 'env',
527
+ description="The default device scale factor " +
528
+ "a.k.a. DPR (device pixel ratio)")
529
+ app.add_config_value(
530
+ 'screenshot_default_locale',
531
+ None,
532
+ 'env',
533
+ description="The default locale in requests")
534
+ app.add_config_value(
535
+ 'screenshot_default_timezone',
536
+ None,
537
+ 'env',
538
+ description="The default timezone in requests")
539
+ app.add_config_value(
540
+ 'screenshot_apps', {},
541
+ 'env',
542
+ types=[dict[str, str]],
543
+ description="A dict of WSGI apps")
544
+ app.connect('config-inited', setup_apps)
545
+ app.connect('build-finished', teardown_apps)
546
+ app.connect('build-finished', copy_static_files)
547
+ app.add_css_file('sphinxcontrib-screenshot/screenshot-theme.css')
548
+ return {
549
+ 'version': importlib.metadata.version('sphinxcontrib-screenshot'),
550
+ 'parallel_read_safe': True,
551
+ 'parallel_write_safe': True,
552
+ }
@@ -0,0 +1,12 @@
1
+ .only-dark {
2
+ display: none;
3
+ }
4
+
5
+ @media (prefers-color-scheme: dark) {
6
+ .only-light {
7
+ display: none;
8
+ }
9
+ .only-dark {
10
+ display: revert;
11
+ }
12
+ }
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.2
1
+ Metadata-Version: 2.4
2
2
  Name: sphinxcontrib-screenshot
3
- Version: 0.1.4
3
+ Version: 0.3.0
4
4
  Summary: A Sphinx extension to embed webpage screenshots.
5
5
  Author-email: Shuhei Iitsuka <tushuhei@gmail.com>
6
6
  License: Apache-2.0
@@ -8,12 +8,12 @@ Project-URL: repository, https://github.com/tushuhei/sphinxcontrib-screenshot/
8
8
  Classifier: Development Status :: 3 - Alpha
9
9
  Classifier: Operating System :: OS Independent
10
10
  Classifier: License :: OSI Approved :: Apache Software License
11
- Classifier: Programming Language :: Python :: 3.9
12
11
  Classifier: Programming Language :: Python :: 3.10
13
12
  Classifier: Programming Language :: Python :: 3.11
14
13
  Classifier: Programming Language :: Python :: 3.12
15
14
  Classifier: Programming Language :: Python :: 3.13
16
- Requires-Python: >=3.9
15
+ Classifier: Programming Language :: Python :: 3.14
16
+ Requires-Python: >=3.10
17
17
  Description-Content-Type: text/markdown
18
18
  License-File: LICENSE
19
19
  Requires-Dist: playwright
@@ -45,6 +45,7 @@ Provides-Extra: doc
45
45
  Requires-Dist: myst-parser; extra == "doc"
46
46
  Requires-Dist: shibuya; extra == "doc"
47
47
  Requires-Dist: sphinx; extra == "doc"
48
+ Dynamic: license-file
48
49
 
49
50
  # sphinxcontrib-screenshot
50
51
 
@@ -53,9 +54,10 @@ A Sphinx extension to embed website screenshots.
53
54
  ```rst
54
55
  .. screenshot:: http://www.example.com
55
56
  :browser: chromium
56
- :width: 1280
57
- :height: 960
57
+ :viewport-width: 1280
58
+ :viewport-height: 960
58
59
  :color-scheme: dark
60
+ :status-code: 200,302
59
61
  ```
60
62
 
61
63
  Read more in the [documentation](https://sphinxcontrib-screenshot.readthedocs.io).
@@ -0,0 +1,7 @@
1
+ sphinxcontrib/screenshot/__init__.py,sha256=mEfCLA41_698Bn46AtBBi0aBNKdcQUDOU3PlkxqitV4,19806
2
+ sphinxcontrib/screenshot/static/screenshot-theme.css,sha256=c2coCn_dBSK5rzuESy2t_qdnzjZVawe5qcAUqhPgjEI,170
3
+ sphinxcontrib_screenshot-0.3.0.dist-info/licenses/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
4
+ sphinxcontrib_screenshot-0.3.0.dist-info/METADATA,sha256=3m89bDVOcIqto22KJedNE_IguAc6LA8WOUOk4XaFbfU,2916
5
+ sphinxcontrib_screenshot-0.3.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
6
+ sphinxcontrib_screenshot-0.3.0.dist-info/top_level.txt,sha256=VJrV3_vaiKQVgVpR0I1iecxoO0drzGu-M0j40PVP2QQ,14
7
+ sphinxcontrib_screenshot-0.3.0.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (75.8.0)
2
+ Generator: setuptools (80.9.0)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
@@ -1,345 +0,0 @@
1
- # Copyright 2023 Google LLC
2
- #
3
- # Licensed under the Apache License, Version 2.0 (the "License");
4
- # you may not use this file except in compliance with the License.
5
- # You may obtain a copy of the License at
6
- #
7
- # http://www.apache.org/licenses/LICENSE-2.0
8
- #
9
- # Unless required by applicable law or agreed to in writing, software
10
- # distributed under the License is distributed on an "AS IS" BASIS,
11
- # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
- # See the License for the specific language governing permissions and
13
- # limitations under the License.
14
-
15
- import hashlib
16
- import importlib
17
- import importlib.metadata
18
- import os
19
- import threading
20
- import typing
21
- import wsgiref.simple_server
22
- from concurrent.futures import ThreadPoolExecutor
23
- from urllib.parse import urlparse
24
-
25
- from docutils import nodes
26
- from docutils.parsers.rst import directives
27
- from docutils.statemachine import ViewList
28
- from playwright._impl._helper import ColorScheme
29
- from playwright.sync_api import Browser, BrowserContext
30
- from playwright.sync_api import TimeoutError as PlaywrightTimeoutError
31
- from playwright.sync_api import sync_playwright
32
- from portpicker import pick_unused_port
33
- from sphinx.application import Sphinx
34
- from sphinx.config import Config
35
- from sphinx.util.docutils import SphinxDirective
36
-
37
- Meta = typing.TypedDict('Meta', {
38
- 'version': str,
39
- 'parallel_read_safe': bool,
40
- 'parallel_write_safe': bool
41
- })
42
-
43
-
44
- class ScreenshotDirective(SphinxDirective):
45
- """Sphinx Screenshot Dirctive.
46
-
47
- This directive embeds a screenshot of a webpage.
48
-
49
- # Example
50
-
51
- You can simply pass a URL for a webpage that you want to take a screenshot.
52
-
53
- ```rst
54
- .. screenshot:: http://www.example.com
55
- ```
56
-
57
- You can also specify the screen size for the screenshot with `width` and
58
- `height` parameters in pixel.
59
-
60
- ```rst
61
- .. screenshot:: http://www.example.com
62
- :width: 1280
63
- :height: 960
64
- ```
65
-
66
- You can include a caption for the screenshot's `figure` directive.
67
-
68
- ```rst
69
- .. screenshot:: http://www.example.com
70
- :caption: This is a screenshot for www.example.com
71
- ```
72
-
73
- You can describe the interaction that you want to have with the webpage
74
- before taking a screenshot in JavaScript.
75
-
76
- ```rst
77
- .. screenshot:: http://www.example.com
78
-
79
- document.querySelector('button').click();
80
- ```
81
-
82
- Use `figclass` option if you want to specify a class name to the image.
83
-
84
- ```rst
85
- .. screenshot:: http://www.example.com
86
- :figclass: foo
87
- ```
88
-
89
- It also generates a PDF file when `pdf` option is given, which might be
90
- useful when you need scalable image assets.
91
-
92
- ```rst
93
- .. screenshot:: http://www.example.com
94
- :pdf:
95
- ```
96
- """
97
-
98
- required_arguments = 1 # URL
99
- has_content = True
100
- option_spec = {
101
- 'browser': str,
102
- 'height': directives.positive_int,
103
- 'width': directives.positive_int,
104
- 'caption': directives.unchanged,
105
- 'figclass': directives.unchanged,
106
- 'pdf': directives.flag,
107
- 'color-scheme': str,
108
- 'full-page': directives.flag,
109
- 'context': str,
110
- 'headers': directives.unchanged,
111
- }
112
- pool = ThreadPoolExecutor()
113
-
114
- @staticmethod
115
- def take_screenshot(
116
- url: str, browser_name: str, width: int, height: int, filepath: str,
117
- init_script: str, interactions: str, generate_pdf: bool,
118
- color_scheme: ColorScheme, full_page: bool,
119
- context_builder: typing.Optional[typing.Callable[[Browser, str, str],
120
- BrowserContext]],
121
- headers: dict):
122
- """Takes a screenshot with Playwright's Chromium browser.
123
-
124
- Args:
125
- url (str): The HTTP/HTTPS URL of the webpage to screenshot.
126
- width (int): The width of the screenshot in pixels.
127
- height (int): The height of the screenshot in pixels.
128
- filepath (str): The path to save the screenshot to.
129
- init_script (str): JavaScript code to be evaluated after the document
130
- was created but before any of its scripts were run. See more details at
131
- https://playwright.dev/python/docs/api/class-page#page-add-init-script
132
- interactions (str): JavaScript code to run before taking the screenshot
133
- after the page was loaded.
134
- generate_pdf (bool): Generate a PDF file along with the screenshot.
135
- color_scheme (str): The preferred color scheme. Can be 'light' or 'dark'.
136
- context: A method to build the Playwright context.
137
- """
138
- with sync_playwright() as playwright:
139
- browser = getattr(playwright, browser_name).launch()
140
-
141
- if context_builder:
142
- try:
143
- context = context_builder(browser, url, color_scheme)
144
- except PlaywrightTimeoutError:
145
- raise RuntimeError(
146
- 'Timeout error occured at %s in executing py init script %s' %
147
- (url, context_builder.__name__))
148
- else:
149
- context = browser.new_context(color_scheme=color_scheme)
150
-
151
- page = context.new_page()
152
- page.set_default_timeout(10000)
153
- page.set_viewport_size({'width': width, 'height': height})
154
-
155
- try:
156
- if init_script:
157
- page.add_init_script(init_script)
158
- page.set_extra_http_headers(headers)
159
- page.goto(url)
160
- page.wait_for_load_state('networkidle')
161
-
162
- # Execute interactions
163
- if interactions:
164
- page.evaluate(interactions)
165
- page.wait_for_load_state('networkidle')
166
- except PlaywrightTimeoutError:
167
- raise RuntimeError('Timeout error occured at %s in executing\n%s' %
168
- (url, interactions))
169
- page.screenshot(path=filepath, full_page=full_page)
170
- if generate_pdf:
171
- page.emulate_media(media='screen')
172
- root, ext = os.path.splitext(filepath)
173
- page.pdf(width=f'{width}px', height=f'{height}px', path=root + '.pdf')
174
- page.close()
175
- browser.close()
176
-
177
- def evaluate_substitutions(self, text: str) -> str:
178
- substitutions = self.state.document.substitution_defs
179
- for key, value in substitutions.items():
180
- text = text.replace(f"|{key}|", value.astext())
181
- return text
182
-
183
- def run(self) -> typing.List[nodes.Node]:
184
- screenshot_init_script: str = self.env.config.screenshot_init_script or ''
185
-
186
- # Ensure the screenshots directory exists
187
- ss_dirpath = os.path.join(self.env.app.outdir, '_static', 'screenshots')
188
- os.makedirs(ss_dirpath, exist_ok=True)
189
-
190
- # Parse parameters
191
- raw_url = self.arguments[0]
192
- url = self.evaluate_substitutions(raw_url)
193
- browser = self.options.get('browser',
194
- self.env.config.screenshot_default_browser)
195
- height = self.options.get('height',
196
- self.env.config.screenshot_default_height)
197
- width = self.options.get('width', self.env.config.screenshot_default_width)
198
- color_scheme = self.options.get(
199
- 'color-scheme', self.env.config.screenshot_default_color_scheme)
200
- caption_text = self.options.get('caption', '')
201
- figclass = self.options.get('figclass', '')
202
- pdf = 'pdf' in self.options
203
- full_page = ('full-page' in self.options or
204
- self.env.config.screenshot_default_full_page)
205
- context = self.options.get('context', '')
206
- interactions = '\n'.join(self.content)
207
- headers = self.options.get('headers', '')
208
-
209
- request_headers = {**self.env.config.screenshot_default_headers}
210
- if headers:
211
- for header in headers.strip().split("\n"):
212
- name, value = header.split(" ", 1)
213
- request_headers[name] = value
214
-
215
- if urlparse(url).scheme not in {'http', 'https'}:
216
- raise RuntimeError(
217
- f'Invalid URL: {url}. Only HTTP/HTTPS URLs are supported.')
218
-
219
- # Generate filename based on hash of parameters
220
- hash_input = "_".join([
221
- raw_url, browser,
222
- str(height),
223
- str(width), color_scheme, context, interactions,
224
- str(full_page)
225
- ])
226
- filename = hashlib.md5(hash_input.encode()).hexdigest() + '.png'
227
- filepath = os.path.join(ss_dirpath, filename)
228
-
229
- if context:
230
- context_builder_path = self.config.screenshot_contexts[context]
231
- context_builder = resolve_python_method(context_builder_path)
232
- else:
233
- context_builder = None
234
-
235
- # Check if the file already exists. If not, take a screenshot
236
- if not os.path.exists(filepath):
237
- fut = self.pool.submit(ScreenshotDirective.take_screenshot, url, browser,
238
- width, height, filepath, screenshot_init_script,
239
- interactions, pdf, color_scheme, full_page,
240
- context_builder, request_headers)
241
- fut.result()
242
-
243
- # Create image and figure nodes
244
- docdir = os.path.dirname(self.env.doc2path(self.env.docname))
245
- rel_ss_dirpath = os.path.relpath(ss_dirpath, start=docdir)
246
- rel_filepath = os.path.join(rel_ss_dirpath, filename).replace(os.sep, '/')
247
- image_node = nodes.image(uri=rel_filepath)
248
- figure_node = nodes.figure('', image_node)
249
-
250
- if figclass:
251
- figure_node['classes'].append(figclass)
252
-
253
- if caption_text:
254
- parsed = nodes.Element()
255
- self.state.nested_parse(
256
- ViewList([caption_text], source=''), self.content_offset, parsed)
257
- figure_node += nodes.caption(parsed[0].source or '', '',
258
- *parsed[0].children)
259
-
260
- return [figure_node]
261
-
262
-
263
- app_threads = {}
264
-
265
-
266
- def resolve_python_method(import_path: str):
267
- module_path, method_name = import_path.split(":")
268
- module = importlib.import_module(module_path)
269
- method = getattr(module, method_name)
270
- return method
271
-
272
-
273
- def setup_apps(app: Sphinx, config: Config):
274
- """Start the WSGI application threads.
275
-
276
- A new replacement is created for each WSGI app."""
277
- for wsgi_app_name, wsgi_app_path in config.screenshot_apps.items():
278
- port = pick_unused_port()
279
- config.rst_prolog = (
280
- config.rst_prolog or
281
- "") + f"\n.. |{wsgi_app_name}| replace:: http://localhost:{port}\n"
282
- app_builder = resolve_python_method(wsgi_app_path)
283
- wsgi_app = app_builder(app)
284
- httpd = wsgiref.simple_server.make_server("localhost", port, wsgi_app)
285
- thread = threading.Thread(target=httpd.serve_forever)
286
- thread.start()
287
- app_threads[wsgi_app_name] = (httpd, thread)
288
-
289
-
290
- def teardown_apps(app: Sphinx, exception: typing.Optional[Exception]):
291
- """Shut down the WSGI application threads."""
292
- for httpd, thread in app_threads.values():
293
- httpd.shutdown()
294
- thread.join()
295
-
296
-
297
- def setup(app: Sphinx) -> Meta:
298
- app.add_directive('screenshot', ScreenshotDirective)
299
- app.add_config_value('screenshot_init_script', '', 'env')
300
- app.add_config_value(
301
- 'screenshot_default_width',
302
- 1280,
303
- 'env',
304
- description="The default width for screenshots")
305
- app.add_config_value(
306
- 'screenshot_default_height',
307
- 960,
308
- 'env',
309
- description="The default height for screenshots")
310
- app.add_config_value(
311
- 'screenshot_default_browser',
312
- 'chromium',
313
- 'env',
314
- description="The default browser for screenshots")
315
- app.add_config_value(
316
- 'screenshot_default_full_page',
317
- False,
318
- 'env',
319
- description="Whether to take full page screenshots")
320
- app.add_config_value(
321
- 'screenshot_default_color_scheme',
322
- 'null',
323
- 'env',
324
- description="The default color scheme for screenshots")
325
- app.add_config_value(
326
- 'screenshot_contexts', {},
327
- 'env',
328
- types=[dict[str, str]],
329
- description="A dict of paths to Playwright context build methods")
330
- app.add_config_value(
331
- 'screenshot_default_headers', {},
332
- 'env',
333
- description="The default headers to pass in requests")
334
- app.add_config_value(
335
- 'screenshot_apps', {},
336
- 'env',
337
- types=[dict[str, str]],
338
- description="A dict of WSGI apps")
339
- app.connect('config-inited', setup_apps)
340
- app.connect('build-finished', teardown_apps)
341
- return {
342
- 'version': importlib.metadata.version('sphinxcontrib-screenshot'),
343
- 'parallel_read_safe': True,
344
- 'parallel_write_safe': True,
345
- }
@@ -1,6 +0,0 @@
1
- sphinxcontrib/screenshot.py,sha256=eJEF1qfGGo1uwyTovYHXx4Ewz2fXmrxDSsQSdLJ0O-0,11863
2
- sphinxcontrib_screenshot-0.1.4.dist-info/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
3
- sphinxcontrib_screenshot-0.1.4.dist-info/METADATA,sha256=XpzmVDGTDfL-AxKUv7jIV3wQ_phEGc-OLxWU-wG7-o0,2850
4
- sphinxcontrib_screenshot-0.1.4.dist-info/WHEEL,sha256=In9FTNxeP60KnTkGw7wk6mJPYd_dQSjEZmXdBdMCI-8,91
5
- sphinxcontrib_screenshot-0.1.4.dist-info/top_level.txt,sha256=VJrV3_vaiKQVgVpR0I1iecxoO0drzGu-M0j40PVP2QQ,14
6
- sphinxcontrib_screenshot-0.1.4.dist-info/RECORD,,