cs-bs4utils 20260912__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.
@@ -0,0 +1,366 @@
1
+ Metadata-Version: 2.4
2
+ Name: cs-bs4utils
3
+ Version: 20260912
4
+ Summary: Various utility functions and classes for working with the HTML soup from `beautifulsoup4`.
5
+ Keywords: python3
6
+ Author-email: Cameron Simpson <cs@cskk.id.au>
7
+ Description-Content-Type: text/markdown
8
+ Classifier: Programming Language :: Python
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Topic :: Text Processing
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)
15
+ Requires-Dist: beautifulsoup4
16
+ Requires-Dist: cs.gimmicks>=20260311
17
+ Requires-Dist: cs.lex>=20260912
18
+ Requires-Dist: cs.pfx>=20260912
19
+ Requires-Dist: icontract
20
+ Requires-Dist: lxml
21
+ Requires-Dist: typguard
22
+ Project-URL: MonoRepo Commits, https://bitbucket.org/cameron_simpson/css/commits/branch/main
23
+ Project-URL: Monorepo Git Mirror, https://github.com/cameron-simpson/css
24
+ Project-URL: Monorepo Hg/Mercurial Mirror, https://hg.sr.ht/~cameron-simpson/css
25
+ Project-URL: Source, https://github.com/cameron-simpson/css/blob/main/lib/python/cs/bs4utils.py
26
+
27
+ Various utility functions and classes for working with the HTML soup from `beautifulsoup4`.
28
+
29
+ *Latest release 20260912*:
30
+ Initial PyPI release.
31
+
32
+
33
+
34
+ Short summary:
35
+
36
+
37
+ * `as_xml`: Transform `tag` into an `lxml` XML element.
38
+
39
+
40
+ * `child_tags`: A generator yielding the immediate child tags of `child` whose tag name is `child_name`. If `child_name` is `None`, yield all the immediate child tags, skipping things like strings and comments.
41
+
42
+
43
+ * `find_heading`: Find the nearest heading satisfying the test `filter(tag)`. Return the tag or `None` is one is not found. The default `filter` tests that the heading is not empty. This uses `find_up` to locate the tag.
44
+
45
+
46
+ * `find_up`: A generator yielding `(found,ref)` 2-tuples obtained by search the tag tree left and up from `tag` using `.previous_sibling` and `.parent`, matching tags where `test(found)` is true.
47
+
48
+
49
+ * `printt_soup`: Print the contents of the soup via `cs.lex.printt` using `tabulate_soup` to make the table.
50
+
51
+
52
+ * `Table`: A `Widget` subclass representing an HTML TABLE tag.
53
+
54
+
55
+ * `tabulate_soup`: Return a table describing `soup` for use with `cs.lex.printt`. Connect tags with their child tags using Unicode box characters.
56
+
57
+
58
+ * `Widget`: Base class for various "widget" HTML constructs, such as a TABLE, or in principle anything else regular on a page.
59
+
60
+ # Functions
61
+
62
+ ## as_xml(tag: bs4.element.Tag, *, E=None)
63
+
64
+ Transform `tag` into an `lxml` XML element.
65
+
66
+ ## child_tags(tag, child_name: str | None = None) -> Iterable[bs4.element.Tag]
67
+
68
+ A generator yielding the immediate child tags of `child`
69
+ whose tag name is `child_name`.
70
+ If `child_name` is `None`, yield all the immediate child tags,
71
+ skipping things like strings and comments.
72
+
73
+ ## find_heading(tag, filter: Callable[[bs4.element.Tag], bool] = <function <lambda> at 0x110363ec0>) -> bs4.element.Tag | None
74
+
75
+ Find the nearest heading satisfying the test `filter(tag)`.
76
+ Return the tag or `None` is one is not found.
77
+ The default `filter` tests that the heading is not empty.
78
+ This uses `find_up` to locate the tag.
79
+
80
+ ## find_up(tag, test: Union[str, Callable[[bs4.element.Tag], bool]], *, first=False) -> Generator[tuple[bs4.element.Tag, bs4.element.Tag], tuple[None, None], NoneType]
81
+
82
+ A generator yielding `(found,ref)` 2-tuples obtained by
83
+ search the tag tree left and up from `tag` using `.previous_sibling`
84
+ and `.parent`, matching tags where `test(found)` is true.
85
+
86
+ The `test` may be a tag `.name` value (a string) or a callable
87
+ to evaluate a ound tag.
88
+
89
+ If `first` is true (default `False`) then the search stops
90
+ after the first match. If there are no matches the tuple
91
+ `(None,None)` is returned (this does not happen if `first`
92
+ is false).
93
+
94
+ A primary use case for this is to find the heading tag for `tag`.
95
+
96
+ In the tuple, `found` is the matched tag. `ref` is the reference
97
+ tag, the later sibling of `found` where the search started
98
+ for that level; if `found` is at the same level as `tag` then
99
+ `ref` will be `tag`.
100
+
101
+ For example, to locate the level 2 heading governing a tag:
102
+
103
+ (h2,_), *_ = find_up(tag,lambda found: found.name == 'h2')
104
+
105
+ or more concisely:
106
+
107
+ (h2,_), *_ = find_up(tag, 'h2')
108
+
109
+ or even:
110
+
111
+ (h2,_), = find_up(tag, 'h2',first=True)
112
+
113
+ Note that the first two will cause Python to raise an exception
114
+ if there are no matches, while the third will provide `h2`
115
+ as `None`.
116
+
117
+ ## printt_soup(tag: bs4.element.Tag, **printt_kw)
118
+
119
+ Print the contents of the soup via `cs.lex.printt`
120
+ using `tabulate_soup` to make the table.
121
+
122
+ ## tabulate_soup(tag: bs4.element.Tag | bs4.element.NavigableString) -> list[list[str, str] | tuple]
123
+
124
+ Return a table describing `soup` for use with `cs.lex.printt`.
125
+ Connect tags with their child tags using Unicode box characters.
126
+
127
+ # Classes
128
+
129
+ ## class Table(Widget)
130
+
131
+ A `Widget` subclass representing an HTML TABLE tag.
132
+
133
+ ### Table.__init__(self, tag)
134
+
135
+ Scan the TABLE for the basic structures, used for the other properties etc later.
136
+
137
+ Note that if there was no TBODY, the immediate rows of the
138
+ TABLE are presented as though they were in a single TBODY.
139
+
140
+ ### Table.IndexedCellValueType
141
+
142
+ Built-in immutable sequence.
143
+
144
+ If no argument is given, the constructor returns an empty tuple.
145
+ If iterable is specified the tuple is initialized from iterable's items.
146
+
147
+ If the argument is a tuple, the return value is the same object.
148
+
149
+ ### Table.__firstlineno__
150
+
151
+ int([x]) -> integer
152
+ int(x, base=10) -> integer
153
+
154
+ Convert a number or string to an integer, or return 0 if no arguments
155
+ are given. If x is a number, return x.__int__(). For floating-point
156
+ numbers, this truncates towards zero.
157
+
158
+ If x is not a number or if base is given, then x must be a string,
159
+ bytes, or bytearray instance representing an integer literal in the
160
+ given base. The literal can be preceded by '+' or '-' and be surrounded
161
+ by whitespace. The base defaults to 10. Valid bases are 0 and 2-36.
162
+ Base 0 means to interpret the base from the string as an integer literal.
163
+ >>> int('0b100', base=0)
164
+ 4
165
+
166
+ ### Table.__static_attributes__
167
+
168
+ Built-in immutable sequence.
169
+
170
+ If no argument is given, the constructor returns an empty tuple.
171
+ If iterable is specified the tuple is initialized from iterable's items.
172
+
173
+ If the argument is a tuple, the return value is the same object.
174
+
175
+ ### Table.all_rows
176
+
177
+ Return all the rows from the header, bodies, and footer.
178
+
179
+ ### Table.as_indexed_values(self, *, convert: Optional[Callable[[str, int, int, int, bs4.element.Tag], Any]] = None, convert_head_cell: Optional[Callable[[str, int, int, int, bs4.element.Tag], Any]] = None, convert_body_cell: Optional[Callable[[str, int, int, int, bs4.element.Tag], Any]] = None, convert_foot_cell: Optional[Callable[[str, int, int, int, bs4.element.Tag], Any]] = None, omit_header=False, omit_footer=False) -> list[list[tuple[str, int, int, int, bs4.element.Tag, typing.Any]]]
180
+
181
+ Return the table contents as a list-of-lists of indexed cell values.
182
+ Each inner list contains the cell value records from a row.
183
+
184
+ this is an elaborate counterpart to the `as_lists` method.
185
+
186
+ Parameters:
187
+ * `convert`: the default cell conversion function
188
+ * `convert_head`: the header cell conversion function, default from `convert`
189
+ * `convert_body`: the body cell conversion function, default from `convert`
190
+ * `convert_foot`: the footer cell conversion function, default from `convert`
191
+ * `omit_header`: do not include rows from the `THEAD` section
192
+ * `omit_footer`: do not include rows from the `TFOOT` section
193
+
194
+ The conversion functions accept the following positional parameters:
195
+ * `section_type`: one of `"THEAD"`, `"TBODY"` or `"TFOOT"`
196
+ * `section_index`: the index of the section, 0 for the
197
+ header or footer but there may be multiple `TBODY` sections
198
+ * `row_index`: the index of the row within the section
199
+ * `col_index`: the index of the column within the row
200
+ * `cell`: the `TD` or `TH` tag for the cell
201
+ The function should return the converted value of `cell`.
202
+ The default conversion function returns `cell`.
203
+
204
+ The row and column indices supplied to the conversion unction
205
+ are of the _resolved_ cells, after expansion via the `colspan`
206
+ or `rowspan` values.
207
+ For example, a row with 3 cells whose second cell had a
208
+ `colspan=2` would be a list of 4 cells, with the second
209
+ original cell referenced in the second and third items of
210
+ the list; it _will_ be the same tag instance.
211
+
212
+ Each cell instance is converted only once; the same cell
213
+ spanning multiple columns or rows will have the same value
214
+ instance in the result record.
215
+
216
+ The resulting list-of-lists contains value records, a 6-tuple
217
+ of `(section_type,section_index,row_index,col_index,cell,value)`.
218
+ Note that the `row_index` and `col_index` are those of the
219
+ top left index where the `cell` was first encountered for
220
+ cells spanning multiple columns or rows.
221
+
222
+ Examples:
223
+
224
+ Convert every numeric cell to its `float` value, leave other cells as their text.
225
+
226
+ def as_float(section_type, section_index, row_index, column_index, cell):
227
+ text = cell.get_text.strip()
228
+ try:
229
+ value = float(text)
230
+ except ValueError:
231
+ value = text
232
+ return value
233
+
234
+ values = T.as_indexed_values(convert=as_float)
235
+
236
+ Convert only the body cells, keep the headers as tags, omit the footer:
237
+
238
+ values = T.as_indexed_values(convert_body_cell=as_float, omit_footer=True)
239
+
240
+ ### Table.as_lists(self, *, omit_header=False, omit_footer=False) -> list[list[bs4.element.Tag]]
241
+
242
+ Return the table contents as a list-of-lists-of-tags;
243
+ each inner list is a row of tags.
244
+ The innermost elements are the TH or TD tags.
245
+ Note that cells spanning multiple columns or rows via their
246
+ `colspan` or `rowspan` are the same reference.
247
+
248
+ Parameters:
249
+ * `omit_header`: do not include rows from the `THEAD` section
250
+ * `omit_footer`: do not include rows from the `TFOOT` section
251
+
252
+ ### Table.body_rows
253
+
254
+ The rows from the table TBODY tags, if any.
255
+ Note that if there was no TBODY, the immediate rows of the
256
+ TABLE are presented as though they were in a single TBODY.
257
+
258
+ ### Table.cell_colspan(cell: bs4.element.Tag) -> int
259
+
260
+ Compute the `colspan` value for a table cell.
261
+
262
+ ### Table.cell_rowspan(cell: bs4.element.Tag) -> int
263
+
264
+ Compute the `rowspan` value for a table cell.
265
+
266
+ ### Table.foot_rows
267
+
268
+ The rows from the table TFOOT, if any.
269
+
270
+ ### Table.head_rows
271
+
272
+ The rows from the table THEAD, if any.
273
+
274
+ ### Table.printt(self)
275
+
276
+ Print the table text.
277
+
278
+ ### Table.row_cells(tr: bs4.element.Tag) -> list[bs4.element.Tag]
279
+
280
+ Return a list of the cells (`TD` or `TH`) from a `TR` tag.
281
+ `colspan` is supported by referencing the same cell multiple times.
282
+ Only `TD` and `TH` tags which are immediate children of the `TR` are recognised.
283
+
284
+ ### Table.section_rows(section: bs4.element.Tag | None) -> list[list[bs4.element.Tag]]
285
+
286
+ Return the rows from a table section such as `THEAD`, `TBODY`, or `TFOOT`.
287
+ `rowspan` is supported by referencing the same cell in lower rows.
288
+
289
+ ### Table.title
290
+
291
+ The title of the table, from the caption or the nearest heading.
292
+
293
+ ## class Widget
294
+
295
+ Base class for various "widget" HTML constructs, such as a
296
+ TABLE, or in principle anything else regular on a page.
297
+
298
+ A `Widgwt` supplies:
299
+ - `__init__(tag)` to record the target BS4 tag, typically the
300
+ top level tag encompassing the wudget
301
+ - `find_all(soup)`: returning a list of the top level tags
302
+ within the BS4 tag `soup`; the default method calls
303
+ `soup.find_all()` with the lower case version of the class
304
+ name via `soup.find_all()`
305
+ - `scan(soup)`: a factory method calling `cls(tag)` for every
306
+ tag found by `find_all(soup)`
307
+
308
+ Everything else in a subclass supports whatever needs doing
309
+ with the widget; the `Table` class is an exemplar:
310
+ - its `__init__` method passes the tag to `super().__init__()`
311
+ as normal, then find s a few top level things about the table
312
+ - the caption, header, bodies, footer
313
+ - the default `find_all` is used because the lass name matches
314
+ the HTML tag name
315
+ - everything else more complex is provided as methods or
316
+ `@cached_property` properties, computed on demand
317
+
318
+ ### Widget.__init__(self, tag: bs4.element.Tag)
319
+
320
+ Initialise this `Widget` by saving `tag` as `self.tag` and
321
+ then calling `self.scan()`.
322
+
323
+ ### Widget.__dict__
324
+
325
+ Read-only proxy of a mapping.
326
+
327
+ ### Widget.__firstlineno__
328
+
329
+ int([x]) -> integer
330
+ int(x, base=10) -> integer
331
+
332
+ Convert a number or string to an integer, or return 0 if no arguments
333
+ are given. If x is a number, return x.__int__(). For floating-point
334
+ numbers, this truncates towards zero.
335
+
336
+ If x is not a number or if base is given, then x must be a string,
337
+ bytes, or bytearray instance representing an integer literal in the
338
+ given base. The literal can be preceded by '+' or '-' and be surrounded
339
+ by whitespace. The base defaults to 10. Valid bases are 0 and 2-36.
340
+ Base 0 means to interpret the base from the string as an integer literal.
341
+ >>> int('0b100', base=0)
342
+ 4
343
+
344
+ ### Widget.__static_attributes__
345
+
346
+ Built-in immutable sequence.
347
+
348
+ If no argument is given, the constructor returns an empty tuple.
349
+ If iterable is specified the tuple is initialized from iterable's items.
350
+
351
+ If the argument is a tuple, the return value is the same object.
352
+
353
+ ### Widget.find_all(soup) -> list[bs4.element.Tag]
354
+
355
+ The default `find_all` finds tags from `soup` whose name matches the class name.
356
+
357
+ ### Widget.scan(soup) -> list[typing.Self]
358
+
359
+ Return a list of all `Widget`s of this type found in `soup`.
360
+
361
+ # Release Log
362
+
363
+
364
+
365
+ *Release 20260912*:
366
+ Initial PyPI release.
@@ -0,0 +1,390 @@
1
+ [project]
2
+ name = "cs-bs4utils"
3
+ description = "Various utility functions and classes for working with the HTML soup from `beautifulsoup4`."
4
+ authors = [
5
+ { name = "Cameron Simpson", email = "cs@cskk.id.au" },
6
+ ]
7
+ keywords = [
8
+ "python3",
9
+ ]
10
+ dependencies = [
11
+ "beautifulsoup4",
12
+ "cs.gimmicks>=20260311",
13
+ "cs.lex>=20260912",
14
+ "cs.pfx>=20260912",
15
+ "icontract",
16
+ "lxml",
17
+ "typguard",
18
+ ]
19
+ classifiers = [
20
+ "Programming Language :: Python",
21
+ "Programming Language :: Python :: 3",
22
+ "Topic :: Text Processing",
23
+ "Development Status :: 4 - Beta",
24
+ "Intended Audience :: Developers",
25
+ "Operating System :: OS Independent",
26
+ "License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)",
27
+ ]
28
+ version = "20260912"
29
+
30
+ [project.license]
31
+ text = "GNU General Public License v3 or later (GPLv3+)"
32
+
33
+ [project.urls]
34
+ "Monorepo Hg/Mercurial Mirror" = "https://hg.sr.ht/~cameron-simpson/css"
35
+ "Monorepo Git Mirror" = "https://github.com/cameron-simpson/css"
36
+ "MonoRepo Commits" = "https://bitbucket.org/cameron_simpson/css/commits/branch/main"
37
+ Source = "https://github.com/cameron-simpson/css/blob/main/lib/python/cs/bs4utils.py"
38
+
39
+ [project.readme]
40
+ text = """
41
+ Various utility functions and classes for working with the HTML soup from `beautifulsoup4`.
42
+
43
+ *Latest release 20260912*:
44
+ Initial PyPI release.
45
+
46
+
47
+
48
+ Short summary:
49
+
50
+
51
+ * `as_xml`: Transform `tag` into an `lxml` XML element.
52
+
53
+
54
+ * `child_tags`: A generator yielding the immediate child tags of `child` whose tag name is `child_name`. If `child_name` is `None`, yield all the immediate child tags, skipping things like strings and comments.
55
+
56
+
57
+ * `find_heading`: Find the nearest heading satisfying the test `filter(tag)`. Return the tag or `None` is one is not found. The default `filter` tests that the heading is not empty. This uses `find_up` to locate the tag.
58
+
59
+
60
+ * `find_up`: A generator yielding `(found,ref)` 2-tuples obtained by search the tag tree left and up from `tag` using `.previous_sibling` and `.parent`, matching tags where `test(found)` is true.
61
+
62
+
63
+ * `printt_soup`: Print the contents of the soup via `cs.lex.printt` using `tabulate_soup` to make the table.
64
+
65
+
66
+ * `Table`: A `Widget` subclass representing an HTML TABLE tag.
67
+
68
+
69
+ * `tabulate_soup`: Return a table describing `soup` for use with `cs.lex.printt`. Connect tags with their child tags using Unicode box characters.
70
+
71
+
72
+ * `Widget`: Base class for various \"widget\" HTML constructs, such as a TABLE, or in principle anything else regular on a page.
73
+
74
+ # Functions
75
+
76
+ ## as_xml(tag: bs4.element.Tag, *, E=None)
77
+
78
+ Transform `tag` into an `lxml` XML element.
79
+
80
+ ## child_tags(tag, child_name: str | None = None) -> Iterable[bs4.element.Tag]
81
+
82
+ A generator yielding the immediate child tags of `child`
83
+ whose tag name is `child_name`.
84
+ If `child_name` is `None`, yield all the immediate child tags,
85
+ skipping things like strings and comments.
86
+
87
+ ## find_heading(tag, filter: Callable[[bs4.element.Tag], bool] = <function <lambda> at 0x110363ec0>) -> bs4.element.Tag | None
88
+
89
+ Find the nearest heading satisfying the test `filter(tag)`.
90
+ Return the tag or `None` is one is not found.
91
+ The default `filter` tests that the heading is not empty.
92
+ This uses `find_up` to locate the tag.
93
+
94
+ ## find_up(tag, test: Union[str, Callable[[bs4.element.Tag], bool]], *, first=False) -> Generator[tuple[bs4.element.Tag, bs4.element.Tag], tuple[None, None], NoneType]
95
+
96
+ A generator yielding `(found,ref)` 2-tuples obtained by
97
+ search the tag tree left and up from `tag` using `.previous_sibling`
98
+ and `.parent`, matching tags where `test(found)` is true.
99
+
100
+ The `test` may be a tag `.name` value (a string) or a callable
101
+ to evaluate a ound tag.
102
+
103
+ If `first` is true (default `False`) then the search stops
104
+ after the first match. If there are no matches the tuple
105
+ `(None,None)` is returned (this does not happen if `first`
106
+ is false).
107
+
108
+ A primary use case for this is to find the heading tag for `tag`.
109
+
110
+ In the tuple, `found` is the matched tag. `ref` is the reference
111
+ tag, the later sibling of `found` where the search started
112
+ for that level; if `found` is at the same level as `tag` then
113
+ `ref` will be `tag`.
114
+
115
+ For example, to locate the level 2 heading governing a tag:
116
+
117
+ (h2,_), *_ = find_up(tag,lambda found: found.name == 'h2')
118
+
119
+ or more concisely:
120
+
121
+ (h2,_), *_ = find_up(tag, 'h2')
122
+
123
+ or even:
124
+
125
+ (h2,_), = find_up(tag, 'h2',first=True)
126
+
127
+ Note that the first two will cause Python to raise an exception
128
+ if there are no matches, while the third will provide `h2`
129
+ as `None`.
130
+
131
+ ## printt_soup(tag: bs4.element.Tag, **printt_kw)
132
+
133
+ Print the contents of the soup via `cs.lex.printt`
134
+ using `tabulate_soup` to make the table.
135
+
136
+ ## tabulate_soup(tag: bs4.element.Tag | bs4.element.NavigableString) -> list[list[str, str] | tuple]
137
+
138
+ Return a table describing `soup` for use with `cs.lex.printt`.
139
+ Connect tags with their child tags using Unicode box characters.
140
+
141
+ # Classes
142
+
143
+ ## class Table(Widget)
144
+
145
+ A `Widget` subclass representing an HTML TABLE tag.
146
+
147
+ ### Table.__init__(self, tag)
148
+
149
+ Scan the TABLE for the basic structures, used for the other properties etc later.
150
+
151
+ Note that if there was no TBODY, the immediate rows of the
152
+ TABLE are presented as though they were in a single TBODY.
153
+
154
+ ### Table.IndexedCellValueType
155
+
156
+ Built-in immutable sequence.
157
+
158
+ If no argument is given, the constructor returns an empty tuple.
159
+ If iterable is specified the tuple is initialized from iterable's items.
160
+
161
+ If the argument is a tuple, the return value is the same object.
162
+
163
+ ### Table.__firstlineno__
164
+
165
+ int([x]) -> integer
166
+ int(x, base=10) -> integer
167
+
168
+ Convert a number or string to an integer, or return 0 if no arguments
169
+ are given. If x is a number, return x.__int__(). For floating-point
170
+ numbers, this truncates towards zero.
171
+
172
+ If x is not a number or if base is given, then x must be a string,
173
+ bytes, or bytearray instance representing an integer literal in the
174
+ given base. The literal can be preceded by '+' or '-' and be surrounded
175
+ by whitespace. The base defaults to 10. Valid bases are 0 and 2-36.
176
+ Base 0 means to interpret the base from the string as an integer literal.
177
+ >>> int('0b100', base=0)
178
+ 4
179
+
180
+ ### Table.__static_attributes__
181
+
182
+ Built-in immutable sequence.
183
+
184
+ If no argument is given, the constructor returns an empty tuple.
185
+ If iterable is specified the tuple is initialized from iterable's items.
186
+
187
+ If the argument is a tuple, the return value is the same object.
188
+
189
+ ### Table.all_rows
190
+
191
+ Return all the rows from the header, bodies, and footer.
192
+
193
+ ### Table.as_indexed_values(self, *, convert: Optional[Callable[[str, int, int, int, bs4.element.Tag], Any]] = None, convert_head_cell: Optional[Callable[[str, int, int, int, bs4.element.Tag], Any]] = None, convert_body_cell: Optional[Callable[[str, int, int, int, bs4.element.Tag], Any]] = None, convert_foot_cell: Optional[Callable[[str, int, int, int, bs4.element.Tag], Any]] = None, omit_header=False, omit_footer=False) -> list[list[tuple[str, int, int, int, bs4.element.Tag, typing.Any]]]
194
+
195
+ Return the table contents as a list-of-lists of indexed cell values.
196
+ Each inner list contains the cell value records from a row.
197
+
198
+ this is an elaborate counterpart to the `as_lists` method.
199
+
200
+ Parameters:
201
+ * `convert`: the default cell conversion function
202
+ * `convert_head`: the header cell conversion function, default from `convert`
203
+ * `convert_body`: the body cell conversion function, default from `convert`
204
+ * `convert_foot`: the footer cell conversion function, default from `convert`
205
+ * `omit_header`: do not include rows from the `THEAD` section
206
+ * `omit_footer`: do not include rows from the `TFOOT` section
207
+
208
+ The conversion functions accept the following positional parameters:
209
+ * `section_type`: one of `\"THEAD\"`, `\"TBODY\"` or `\"TFOOT\"`
210
+ * `section_index`: the index of the section, 0 for the
211
+ header or footer but there may be multiple `TBODY` sections
212
+ * `row_index`: the index of the row within the section
213
+ * `col_index`: the index of the column within the row
214
+ * `cell`: the `TD` or `TH` tag for the cell
215
+ The function should return the converted value of `cell`.
216
+ The default conversion function returns `cell`.
217
+
218
+ The row and column indices supplied to the conversion unction
219
+ are of the _resolved_ cells, after expansion via the `colspan`
220
+ or `rowspan` values.
221
+ For example, a row with 3 cells whose second cell had a
222
+ `colspan=2` would be a list of 4 cells, with the second
223
+ original cell referenced in the second and third items of
224
+ the list; it _will_ be the same tag instance.
225
+
226
+ Each cell instance is converted only once; the same cell
227
+ spanning multiple columns or rows will have the same value
228
+ instance in the result record.
229
+
230
+ The resulting list-of-lists contains value records, a 6-tuple
231
+ of `(section_type,section_index,row_index,col_index,cell,value)`.
232
+ Note that the `row_index` and `col_index` are those of the
233
+ top left index where the `cell` was first encountered for
234
+ cells spanning multiple columns or rows.
235
+
236
+ Examples:
237
+
238
+ Convert every numeric cell to its `float` value, leave other cells as their text.
239
+
240
+ def as_float(section_type, section_index, row_index, column_index, cell):
241
+ text = cell.get_text.strip()
242
+ try:
243
+ value = float(text)
244
+ except ValueError:
245
+ value = text
246
+ return value
247
+
248
+ values = T.as_indexed_values(convert=as_float)
249
+
250
+ Convert only the body cells, keep the headers as tags, omit the footer:
251
+
252
+ values = T.as_indexed_values(convert_body_cell=as_float, omit_footer=True)
253
+
254
+ ### Table.as_lists(self, *, omit_header=False, omit_footer=False) -> list[list[bs4.element.Tag]]
255
+
256
+ Return the table contents as a list-of-lists-of-tags;
257
+ each inner list is a row of tags.
258
+ The innermost elements are the TH or TD tags.
259
+ Note that cells spanning multiple columns or rows via their
260
+ `colspan` or `rowspan` are the same reference.
261
+
262
+ Parameters:
263
+ * `omit_header`: do not include rows from the `THEAD` section
264
+ * `omit_footer`: do not include rows from the `TFOOT` section
265
+
266
+ ### Table.body_rows
267
+
268
+ The rows from the table TBODY tags, if any.
269
+ Note that if there was no TBODY, the immediate rows of the
270
+ TABLE are presented as though they were in a single TBODY.
271
+
272
+ ### Table.cell_colspan(cell: bs4.element.Tag) -> int
273
+
274
+ Compute the `colspan` value for a table cell.
275
+
276
+ ### Table.cell_rowspan(cell: bs4.element.Tag) -> int
277
+
278
+ Compute the `rowspan` value for a table cell.
279
+
280
+ ### Table.foot_rows
281
+
282
+ The rows from the table TFOOT, if any.
283
+
284
+ ### Table.head_rows
285
+
286
+ The rows from the table THEAD, if any.
287
+
288
+ ### Table.printt(self)
289
+
290
+ Print the table text.
291
+
292
+ ### Table.row_cells(tr: bs4.element.Tag) -> list[bs4.element.Tag]
293
+
294
+ Return a list of the cells (`TD` or `TH`) from a `TR` tag.
295
+ `colspan` is supported by referencing the same cell multiple times.
296
+ Only `TD` and `TH` tags which are immediate children of the `TR` are recognised.
297
+
298
+ ### Table.section_rows(section: bs4.element.Tag | None) -> list[list[bs4.element.Tag]]
299
+
300
+ Return the rows from a table section such as `THEAD`, `TBODY`, or `TFOOT`.
301
+ `rowspan` is supported by referencing the same cell in lower rows.
302
+
303
+ ### Table.title
304
+
305
+ The title of the table, from the caption or the nearest heading.
306
+
307
+ ## class Widget
308
+
309
+ Base class for various \"widget\" HTML constructs, such as a
310
+ TABLE, or in principle anything else regular on a page.
311
+
312
+ A `Widgwt` supplies:
313
+ - `__init__(tag)` to record the target BS4 tag, typically the
314
+ top level tag encompassing the wudget
315
+ - `find_all(soup)`: returning a list of the top level tags
316
+ within the BS4 tag `soup`; the default method calls
317
+ `soup.find_all()` with the lower case version of the class
318
+ name via `soup.find_all()`
319
+ - `scan(soup)`: a factory method calling `cls(tag)` for every
320
+ tag found by `find_all(soup)`
321
+
322
+ Everything else in a subclass supports whatever needs doing
323
+ with the widget; the `Table` class is an exemplar:
324
+ - its `__init__` method passes the tag to `super().__init__()`
325
+ as normal, then find s a few top level things about the table
326
+ - the caption, header, bodies, footer
327
+ - the default `find_all` is used because the lass name matches
328
+ the HTML tag name
329
+ - everything else more complex is provided as methods or
330
+ `@cached_property` properties, computed on demand
331
+
332
+ ### Widget.__init__(self, tag: bs4.element.Tag)
333
+
334
+ Initialise this `Widget` by saving `tag` as `self.tag` and
335
+ then calling `self.scan()`.
336
+
337
+ ### Widget.__dict__
338
+
339
+ Read-only proxy of a mapping.
340
+
341
+ ### Widget.__firstlineno__
342
+
343
+ int([x]) -> integer
344
+ int(x, base=10) -> integer
345
+
346
+ Convert a number or string to an integer, or return 0 if no arguments
347
+ are given. If x is a number, return x.__int__(). For floating-point
348
+ numbers, this truncates towards zero.
349
+
350
+ If x is not a number or if base is given, then x must be a string,
351
+ bytes, or bytearray instance representing an integer literal in the
352
+ given base. The literal can be preceded by '+' or '-' and be surrounded
353
+ by whitespace. The base defaults to 10. Valid bases are 0 and 2-36.
354
+ Base 0 means to interpret the base from the string as an integer literal.
355
+ >>> int('0b100', base=0)
356
+ 4
357
+
358
+ ### Widget.__static_attributes__
359
+
360
+ Built-in immutable sequence.
361
+
362
+ If no argument is given, the constructor returns an empty tuple.
363
+ If iterable is specified the tuple is initialized from iterable's items.
364
+
365
+ If the argument is a tuple, the return value is the same object.
366
+
367
+ ### Widget.find_all(soup) -> list[bs4.element.Tag]
368
+
369
+ The default `find_all` finds tags from `soup` whose name matches the class name.
370
+
371
+ ### Widget.scan(soup) -> list[typing.Self]
372
+
373
+ Return a list of all `Widget`s of this type found in `soup`.
374
+
375
+ # Release Log
376
+
377
+
378
+
379
+ *Release 20260912*:
380
+ Initial PyPI release."""
381
+ content-type = "text/markdown"
382
+
383
+ [build-system]
384
+ build-backend = "flit_core.buildapi"
385
+ requires = [
386
+ "flit_core >=3.2,<4",
387
+ ]
388
+
389
+ [tool.flit.module]
390
+ name = "cs.bs4utils"
@@ -0,0 +1,652 @@
1
+ #!/usr/bin/env python3
2
+
3
+ ''' Various utility functions and classes for working with the HTML soup from `beautifulsoup4`.
4
+ '''
5
+
6
+ from functools import cached_property
7
+ from typing import Any, Callable, Generator, Iterable, Self
8
+
9
+ from bs4 import BeautifulSoup, Tag as BS4Tag, NavigableString
10
+ from icontract import require
11
+ from lxml.builder import ElementMaker
12
+ from typeguard import typechecked
13
+
14
+ from cs.lex import cropped_repr, printt
15
+ from cs.pfx import pfx
16
+ from cs.gimmicks import warning
17
+
18
+ __version__ = '20260912'
19
+
20
+ DISTINFO = {
21
+ 'keywords': ["python3"],
22
+ 'classifiers': [
23
+ "Programming Language :: Python",
24
+ "Programming Language :: Python :: 3",
25
+ "Topic :: Text Processing",
26
+ ],
27
+ 'install_requires': [
28
+ 'cs.lex',
29
+ 'cs.pfx',
30
+ 'cs.gimmicks',
31
+ 'beautifulsoup4',
32
+ 'icontract',
33
+ 'lxml',
34
+ 'typguard',
35
+ ],
36
+ }
37
+
38
+ # TODO: find_all(...,recursive=False) does this? apparently not?
39
+ def child_tags(tag, child_name: str | None = None) -> Iterable[BS4Tag]:
40
+ ''' A generator yielding the immediate child tags of `child`
41
+ whose tag name is `child_name`.
42
+ If `child_name` is `None`, yield all the immediate child tags,
43
+ skipping things like strings and comments.
44
+ '''
45
+ for child in tag.children:
46
+ if isinstance(child, BS4Tag) and (child_name is None
47
+ or child.name == child_name):
48
+ yield child
49
+
50
+ def find_up(
51
+ tag,
52
+ test: str | Callable[[BS4Tag], bool],
53
+ *,
54
+ first=False
55
+ ) -> Generator[tuple[BS4Tag, BS4Tag], tuple[None, None]]:
56
+ ''' A generator yielding `(found,ref)` 2-tuples obtained by
57
+ search the tag tree left and up from `tag` using `.previous_sibling`
58
+ and `.parent`, matching tags where `test(found)` is true.
59
+
60
+ The `test` may be a tag `.name` value (a string) or a callable
61
+ to evaluate a ound tag.
62
+
63
+ If `first` is true (default `False`) then the search stops
64
+ after the first match. If there are no matches the tuple
65
+ `(None,None)` is returned (this does not happen if `first`
66
+ is false).
67
+
68
+ A primary use case for this is to find the heading tag for `tag`.
69
+
70
+ In the tuple, `found` is the matched tag. `ref` is the reference
71
+ tag, the later sibling of `found` where the search started
72
+ for that level; if `found` is at the same level as `tag` then
73
+ `ref` will be `tag`.
74
+
75
+ For example, to locate the level 2 heading governing a tag:
76
+
77
+ (h2,_), *_ = find_up(tag,lambda found: found.name == 'h2')
78
+
79
+ or more concisely:
80
+
81
+ (h2,_), *_ = find_up(tag, 'h2')
82
+
83
+ or even:
84
+
85
+ (h2,_), = find_up(tag, 'h2',first=True)
86
+
87
+ Note that the first two will cause Python to raise an exception
88
+ if there are no matches, while the third will provide `h2`
89
+ as `None`.
90
+ '''
91
+ if isinstance(test, str):
92
+ tag_name = test
93
+ test = lambda tag: tag.name is not None and tag.name == tag_name
94
+ reftag = tag
95
+ while reftag is not None:
96
+ prev = reftag.previous_sibling
97
+ while prev is not None:
98
+ if tag.name is not None and test(prev):
99
+ yield prev, reftag
100
+ if first:
101
+ return
102
+ prev = prev.previous_sibling
103
+ reftag = reftag.parent
104
+ if first:
105
+ yield None, None
106
+
107
+ def find_heading(
108
+ tag,
109
+ filter: Callable[[BS4Tag],
110
+ bool] = (lambda tag: len(tag.get_text().strip()) > 0),
111
+ ) -> BS4Tag | None:
112
+ ''' Find the nearest heading satisfying the test `filter(tag)`.
113
+ Return the tag or `None` is one is not found.
114
+ The default `filter` tests that the heading is not empty.
115
+ This uses `find_up` to locate the tag.
116
+ '''
117
+ (h, _), = find_up(
118
+ tag,
119
+ lambda tag: (
120
+ tag.name and tag.name.startswith('h') and tag.name[1:].isdigit() and
121
+ filter(tag)
122
+ ),
123
+ first=True,
124
+ )
125
+ return h
126
+
127
+ @typechecked
128
+ def tabulate_soup(
129
+ tag: BS4Tag | NavigableString
130
+ ) -> list[list[str, str] | tuple]:
131
+ ''' Return a table describing `soup` for use with `cs.lex.printt`.
132
+ Connect tags with their child tags using Unicode box characters.
133
+ '''
134
+ table = []
135
+ if isinstance(tag, NavigableString):
136
+ text = str(tag).strip()
137
+ if text:
138
+ table.append(['', text])
139
+ else:
140
+ # A tag with interior content.
141
+ attrs = dict(tag.attrs)
142
+ label = tag.name
143
+ # pop off the id attribute if present, include in the label
144
+ try:
145
+ id_attr = attrs.pop('id')
146
+ except KeyError:
147
+ pass
148
+ else:
149
+ label += f' #{id_attr}'
150
+ # pop off the name attribute if present, include in the label
151
+ try:
152
+ name_attr = attrs.pop('name')
153
+ except KeyError:
154
+ pass
155
+ else:
156
+ # I saw an amazon page embed an obscene amount of JSON in a
157
+ # name attribute :-(
158
+ label += f' name={cropped_repr(name_attr)}'
159
+ children = list(
160
+ child for child in tag.children if isinstance(child, NavigableString)
161
+ or child.name not in ('script', 'style')
162
+ )
163
+ # count the subtags which aren't strings
164
+ nsubtags = sum(
165
+ not isinstance(child, NavigableString) for child in children
166
+ )
167
+ if not attrs and len(children) == 1 and isinstance(children[0],
168
+ NavigableString):
169
+ # The super compact form:
170
+ # a tag with no attrs and some text puts the text beside the tag name.
171
+ assert nsubtags == 0
172
+ text = f'{str(children[0]).strip()}'
173
+ table.append([label, text])
174
+ else:
175
+ attr_text = "\n".join(
176
+ f'{attr}={value!r}' for attr, value in sorted(attrs.items())
177
+ )
178
+ table.append([label, attr_text])
179
+ if children:
180
+ subtable = []
181
+ for child in children:
182
+ subtable.extend(tabulate_soup(child))
183
+ table.append(tuple(subtable))
184
+ return table
185
+
186
+ def printt_soup(tag: BS4Tag, **printt_kw):
187
+ ''' Print the contents of the soup via `cs.lex.printt`
188
+ using `tabulate_soup` to make the table.
189
+ '''
190
+ if isinstance(tag, BS4Tag) and tag.name == 'html':
191
+ table = []
192
+ if tag.head:
193
+ table.extend(tabulate_soup(tag.head))
194
+ table.extend(tabulate_soup(tag.body))
195
+ else:
196
+ table = tabulate_soup(tag)
197
+ printt(*table, **printt_kw)
198
+
199
+ def as_xml(tag: BS4Tag, *, E=None):
200
+ ''' Transform `tag` into an `lxml` XML element.
201
+ '''
202
+ if E is None:
203
+ E = ElementMaker()
204
+ return E(tag.name, *map(as_xml, tag.children), **tag.attrs)
205
+
206
+ class Widget:
207
+ ''' Base class for various "widget" HTML constructs, such as a
208
+ TABLE, or in principle anything else regular on a page.
209
+
210
+ A `Widgwt` supplies:
211
+ - `__init__(tag)` to record the target BS4 tag, typically the
212
+ top level tag encompassing the wudget
213
+ - `find_all(soup)`: returning a list of the top level tags
214
+ within the BS4 tag `soup`; the default method calls
215
+ `soup.find_all()` with the lower case version of the class
216
+ name via `soup.find_all()`
217
+ - `scan(soup)`: a factory method calling `cls(tag)` for every
218
+ tag found by `find_all(soup)`
219
+
220
+ Everything else in a subclass supports whatever needs doing
221
+ with the widget; the `Table` class is an exemplar:
222
+ - its `__init__` method passes the tag to `super().__init__()`
223
+ as normal, then find s a few top level things about the table
224
+ - the caption, header, bodies, footer
225
+ - the default `find_all` is used because the lass name matches
226
+ the HTML tag name
227
+ - everything else more complex is provided as methods or
228
+ `@cached_property` properties, computed on demand
229
+ '''
230
+
231
+ def __init__(self, tag: BS4Tag):
232
+ ''' Initialise this `Widget` by saving `tag` as `self.tag` and
233
+ then calling `self.scan()`.
234
+ '''
235
+ self.tag = tag
236
+
237
+ @classmethod
238
+ def find_all(cls, soup) -> list[BS4Tag]:
239
+ ''' The default `find_all` finds tags from `soup` whose name matches the class name.
240
+ '''
241
+ return soup.find_all(cls.__name__.lower())
242
+
243
+ @classmethod
244
+ def scan(cls, soup) -> list[Self]:
245
+ ''' Return a list of all `Widget`s of this type found in `soup`.
246
+ '''
247
+ return [cls(tag) for tag in cls.find_all(soup)]
248
+
249
+ class Table(Widget):
250
+ ''' A `Widget` subclass representing an HTML TABLE tag.
251
+ '''
252
+
253
+ def __init__(self, tag):
254
+ ''' Scan the TABLE for the basic structures, used for the other properties etc later.
255
+
256
+ Note that if there was no TBODY, the immediate rows of the
257
+ TABLE are presented as though they were in a single TBODY.
258
+ '''
259
+ super().__init__(tag)
260
+ self.caption = tag.find('caption')
261
+ self.colgroups = tag.find_all('colgroup', recursive=False)
262
+ self.thead = tag.find('thead')
263
+ self.tbodies = tag.find_all('tbody')
264
+ if not self.tbodies:
265
+ # fake up a single TBODY if there are none
266
+ tbody = BS4Tag(name='tbody')
267
+ for tr in tag.find_all('tr', recursive=False):
268
+ tbody.append(tr)
269
+ self.tbodies = [tbody]
270
+ self.tfoot = tag.find('tfoot')
271
+
272
+ @staticmethod
273
+ def cell_colspan(cell: BS4Tag) -> int:
274
+ ''' Compute the `colspan` value for a table cell.
275
+ '''
276
+ colspan = cell.attrs.get("colspan", 1)
277
+ try:
278
+ colspan = int(colspan)
279
+ except ValueError as e:
280
+ warning(f'invalid {colspan=} ({e}), using 1: {cell}')
281
+ colspan = 1
282
+ return colspan
283
+
284
+ @staticmethod
285
+ def cell_rowspan(cell: BS4Tag) -> int:
286
+ ''' Compute the `rowspan` value for a table cell.
287
+ '''
288
+ rowspan = cell.attrs.get("rowspan", 1)
289
+ try:
290
+ rowspan = int(rowspan)
291
+ except ValueError as e:
292
+ warning(f'invalid {rowspan=} ({e}), using 1: {cell}')
293
+ rowspan = 1
294
+ return rowspan
295
+
296
+ @classmethod
297
+ def row_cells(cls, tr: BS4Tag) -> list[BS4Tag]:
298
+ ''' Return a list of the cells (`TD` or `TH`) from a `TR` tag.
299
+ `colspan` is supported by referencing the same cell multiple times.
300
+ Only `TD` and `TH` tags which are immediate children of the `TR` are recognised.
301
+ '''
302
+ cells = []
303
+ for cell in tr.find_all(lambda tag: tag.name in ('th', 'td'),
304
+ recursive=False):
305
+ colspan = cls.cell_colspan(cell)
306
+ for _ in range(colspan):
307
+ cells.append(cell)
308
+ return cells
309
+
310
+ @classmethod
311
+ @require(lambda section: section.name in ('thead', 'tbody', 'tfoot'))
312
+ def section_rows(cls, section: BS4Tag | None) -> list[list[BS4Tag]]:
313
+ ''' Return the rows from a table section such as `THEAD`, `TBODY`, or `TFOOT`.
314
+ `rowspan` is supported by referencing the same cell in lower rows.
315
+ '''
316
+ if section is None:
317
+ return []
318
+ trs = section.find_all('tr', recursive=False)
319
+ rows = [[] for _ in trs]
320
+ for row_index, row_cells in enumerate(cls.row_cells(tr) for tr in trs):
321
+ row = rows[row_index]
322
+ assert rows[row_index] is row
323
+ cell_pos = 0
324
+ for cell in row_cells:
325
+ # advance past any cells presupplied by a rowspan
326
+ while cell_pos < len(row) and row[cell_pos] is not None:
327
+ cell_pos += 1
328
+ if cell_pos < len(row):
329
+ assert row[cell_pos] is None
330
+ row[cell_pos] = cell
331
+ else:
332
+ assert cell_pos == len(row)
333
+ row.append(cell)
334
+ # propagate this cell to further rows for its rowspan
335
+ for offset in range(1, cls.cell_rowspan(cell)):
336
+ subindex = row_index + offset
337
+ if subindex == len(rows):
338
+ subrow = []
339
+ rows.append(subrow)
340
+ else:
341
+ assert subindex < len(rows)
342
+ subrow = rows[subindex]
343
+ while len(subrow) < cell_pos:
344
+ subrow.append(None)
345
+ if cell_pos < len(subrow):
346
+ subrow[cell_pos] = cell
347
+ else:
348
+ assert len(subrow) == cell_pos
349
+ subrow.append(cell)
350
+ cell_pos += 1
351
+ # infill None cells with empty TD tags
352
+ for row in rows[1:]:
353
+ for i, cell in enumerate(row):
354
+ if cell is None:
355
+ row[i] = BS4Tag(name='td')
356
+ return rows
357
+
358
+ @cached_property
359
+ def head_rows(self) -> list[list[BS4Tag]]:
360
+ ''' The rows from the table THEAD, if any.
361
+ '''
362
+ return self.section_rows(self.thead)
363
+
364
+ @cached_property
365
+ def body_rows(self) -> list[list[BS4Tag]]:
366
+ ''' The rows from the table TBODY tags, if any.
367
+ Note that if there was no TBODY, the immediate rows of the
368
+ TABLE are presented as though they were in a single TBODY.
369
+ '''
370
+ rows = []
371
+ for body in self.tbodies:
372
+ rows.extend(self.section_rows(body))
373
+ return rows
374
+
375
+ @cached_property
376
+ def foot_rows(self) -> list[list[BS4Tag]]:
377
+ ''' The rows from the table TFOOT, if any.
378
+ '''
379
+ return self.section_rows(self.tfoot)
380
+
381
+ def as_lists(self,
382
+ *,
383
+ omit_header=False,
384
+ omit_footer=False) -> list[list[BS4Tag]]:
385
+ ''' Return the table contents as a list-of-lists-of-tags;
386
+ each inner list is a row of tags.
387
+ The innermost elements are the TH or TD tags.
388
+ Note that cells spanning multiple columns or rows via their
389
+ `colspan` or `rowspan` are the same reference.
390
+
391
+ Parameters:
392
+ * `omit_header`: do not include rows from the `THEAD` section
393
+ * `omit_footer`: do not include rows from the `TFOOT` section
394
+ '''
395
+ rows = self.all_rows = []
396
+ if not omit_header:
397
+ rows.extend(self.head_rows)
398
+ rows.extend(self.body_rows)
399
+ if not omit_footer:
400
+ rows.extend(self.foot_rows)
401
+ return rows
402
+
403
+ # the type of a cell value entry
404
+ IndexedCellValueType = tuple[str, int, int, int, BS4Tag, Any]
405
+
406
+ # the type of a cell conversion function
407
+ IndexedCellConversionFunction = Callable[[str, int, int, int, BS4Tag], Any]
408
+
409
+ def as_indexed_values(
410
+ self,
411
+ *,
412
+ convert: IndexedCellConversionFunction | None = None,
413
+ convert_head_cell: IndexedCellConversionFunction | None = None,
414
+ convert_body_cell: IndexedCellConversionFunction | None = None,
415
+ convert_foot_cell: IndexedCellConversionFunction | None = None,
416
+ omit_header=False,
417
+ omit_footer=False,
418
+ ) -> list[list[IndexedCellValueType]]:
419
+ ''' Return the table contents as a list-of-lists of indexed cell values.
420
+ Each inner list contains the cell value records from a row.
421
+
422
+ this is an elaborate counterpart to the `as_lists` method.
423
+
424
+ Parameters:
425
+ * `convert`: the default cell conversion function
426
+ * `convert_head`: the header cell conversion function, default from `convert`
427
+ * `convert_body`: the body cell conversion function, default from `convert`
428
+ * `convert_foot`: the footer cell conversion function, default from `convert`
429
+ * `omit_header`: do not include rows from the `THEAD` section
430
+ * `omit_footer`: do not include rows from the `TFOOT` section
431
+
432
+ The conversion functions accept the following positional parameters:
433
+ * `section_type`: one of `"THEAD"`, `"TBODY"` or `"TFOOT"`
434
+ * `section_index`: the index of the section, 0 for the
435
+ header or footer but there may be multiple `TBODY` sections
436
+ * `row_index`: the index of the row within the section
437
+ * `col_index`: the index of the column within the row
438
+ * `cell`: the `TD` or `TH` tag for the cell
439
+ The function should return the converted value of `cell`.
440
+ The default conversion function returns `cell`.
441
+
442
+ The row and column indices supplied to the conversion unction
443
+ are of the _resolved_ cells, after expansion via the `colspan`
444
+ or `rowspan` values.
445
+ For example, a row with 3 cells whose second cell had a
446
+ `colspan=2` would be a list of 4 cells, with the second
447
+ original cell referenced in the second and third items of
448
+ the list; it _will_ be the same tag instance.
449
+
450
+ Each cell instance is converted only once; the same cell
451
+ spanning multiple columns or rows will have the same value
452
+ instance in the result record.
453
+
454
+ The resulting list-of-lists contains value records, a 6-tuple
455
+ of `(section_type,section_index,row_index,col_index,cell,value)`.
456
+ Note that the `row_index` and `col_index` are those of the
457
+ top left index where the `cell` was first encountered for
458
+ cells spanning multiple columns or rows.
459
+
460
+ Examples:
461
+
462
+ Convert every numeric cell to its `float` value, leave other cells as their text.
463
+
464
+ def as_float(section_type, section_index, row_index, column_index, cell):
465
+ text = cell.get_text.strip()
466
+ try:
467
+ value = float(text)
468
+ except ValueError:
469
+ value = text
470
+ return value
471
+
472
+ values = T.as_indexed_values(convert=as_float)
473
+
474
+ Convert only the body cells, keep the headers as tags, omit the footer:
475
+
476
+ values = T.as_indexed_values(convert_body_cell=as_float, omit_footer=True)
477
+ '''
478
+ if convert is None:
479
+ convert = (
480
+ lambda section_type, section_index, row_index, column_index, cell:
481
+ cell
482
+ )
483
+ if convert_head_cell is None:
484
+ convert_head_cell = convert
485
+ if convert_body_cell is None:
486
+ convert_body_cell = convert
487
+ if convert_foot_cell is None:
488
+ convert_foot_cell = convert
489
+ # cell_indicies={}
490
+ # mapping of id(tag) to (row_index,colum_index,converteed_value)
491
+ converted = {}
492
+
493
+ @pfx
494
+ def conv(
495
+ section_type, section_index, row_index, col_index, cell
496
+ ) -> self.IndexedCellValueType:
497
+ cell_id = id(cell)
498
+ try:
499
+ value_record = converted[cell_id]
500
+ except KeyError:
501
+ if section_type == 'THEAD':
502
+ value = convert_head_cell(
503
+ section_type, section_index, row_index, col_index, cell
504
+ )
505
+ elif section_type == 'TBODY':
506
+ value = convert_body_cell(
507
+ section_type, section_index, row_index, col_index, cell
508
+ )
509
+ elif section_type == 'TFOOT':
510
+ value = convert_foot_cell(
511
+ section_type, section_index, row_index, col_index, cell
512
+ )
513
+ else:
514
+ raise RuntimeError(f'unhandled {section_type=}')
515
+ value_record = converted[cell_id] = (
516
+ section_type, section_index, row_index, col_index, cell, value
517
+ )
518
+ return value_record
519
+
520
+ rows = self.all_rows = []
521
+ if not omit_header:
522
+ for row_index, row in enumerate(self.head_rows):
523
+ rows.append(
524
+ [
525
+ conv('THEAD', 0, row_index, col_index, cell)
526
+ for col_index, cell in enumerate(row)
527
+ ]
528
+ )
529
+ for body_index, body in enumerate(self.tbodies):
530
+ for row_index, row in enumerate(self.section_rows(body)):
531
+ rows.append(
532
+ [
533
+ conv('TBODY', body_index, row_index, col_index, cell)
534
+ for col_index, cell in enumerate(row)
535
+ ]
536
+ )
537
+ if not omit_footer:
538
+ for row_index, row in enumerate(self.foot_rows):
539
+ rows.append(
540
+ [
541
+ conv('TFOOT', 0, row_index, col_index, cell)
542
+ for col_index, cell in enumerate(row)
543
+ ]
544
+ )
545
+ return rows
546
+
547
+ @cached_property
548
+ def all_rows(self) -> list[list[BS4Tag]]:
549
+ ''' Return all the rows from the header, bodies, and footer.
550
+ '''
551
+ return self.as_lists()
552
+
553
+ @cached_property
554
+ def title(self):
555
+ ''' The title of the table, from the caption or the nearest heading.
556
+ '''
557
+ if self.caption:
558
+ title = self.caption.get_text()
559
+ else:
560
+ h = find_heading(self.tag)
561
+ if h:
562
+ title = h.get_text().strip()
563
+ else:
564
+ title = None
565
+ return title
566
+
567
+ def printt(self):
568
+ ''' Print the table text.
569
+ '''
570
+ seen_ids = set()
571
+
572
+ def row_trow(row):
573
+ ''' Render a row of cells for the table.
574
+ The row should have come from `section_rows` i.e. the
575
+ `colspan` is already applied.
576
+ '''
577
+ trow = []
578
+ for i, cell in enumerate(row):
579
+ if id(cell) in seen_ids:
580
+ trow.append("")
581
+ else:
582
+ seen_ids.add(id(cell))
583
+ trow.append(cell.get_text())
584
+ return trow
585
+
586
+ def section_trows(rows):
587
+ ''' Render the rows of a section, each of whose rows should
588
+ have come from `section_rows` i.e. the `colspan` is already applied.
589
+ '''
590
+
591
+ table = []
592
+ heading = self.title or self.tag.name.upper()
593
+ table.append([heading])
594
+ if self.thead:
595
+ table.extend(((*map(row_trow, self.head_rows),),))
596
+ for tbody in self.tbodies:
597
+ table.extend(((*map(row_trow, self.section_rows(tbody)),),))
598
+ if self.tfoot:
599
+ table.extend(((*map(row_trow, self.foot_rows),),))
600
+ ##print(self.tag.prettify())
601
+ ##pprint(table)
602
+ printt(*table)
603
+
604
+ if __name__ == '__main__':
605
+ for html in ('foo', '<h1>foo</h1>', '''
606
+ <html>
607
+ <head>
608
+ <title>title here</title>
609
+ </head>
610
+ <body>
611
+ <h1 id="3" attr="zot" attr2="2">heading 1</h1>
612
+ body here
613
+ <h1>second heading</h1>
614
+ second
615
+ third
616
+ </body>
617
+ </html>
618
+ ''', '''
619
+ <H1>H1 HEADING</H1>
620
+ <TABLE>
621
+ <THEAD><TR><TD>heaing 1<TD>heading 2
622
+ <TBODY><TR><TD>Label<TD ROWSPAN="2">9.5
623
+ <TR>
624
+ <TR><TD>3<TD>4
625
+ <TFOOT><TR><TD>foot1<TD>5
626
+ </TABLE>
627
+ '''):
628
+ print("======================================")
629
+ print(html)
630
+ print("--------------------------------------")
631
+ soup = BeautifulSoup(html, features="lxml")
632
+ printt_soup(soup)
633
+ for table in Table.scan(soup):
634
+ print()
635
+ table.printt()
636
+
637
+ for row in table.as_lists():
638
+ print(*map(type, row))
639
+
640
+ def as_float(section_type, section_index, row_index, col_index, cell):
641
+ text = cell.get_text().strip()
642
+ try:
643
+ return float(text)
644
+ except (TypeError, ValueError):
645
+ return text
646
+
647
+ for row_index, row in enumerate(table.as_indexed_values(
648
+ convert_body_cell=as_float, omit_footer=True)):
649
+ for col_index, record in enumerate(row):
650
+ section_type = record[0]
651
+ value = record[-1]
652
+ print(row_index, col_index, section_type, type(value), value)