webcode-tk 1.2.6__tar.gz → 1.2.8__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.
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: webcode-tk
3
- Version: 1.2.6
4
- Summary:
3
+ Version: 1.2.8
4
+ Summary: A set of tools for inspecting HTML and CSS code
5
5
  License: MIT
6
6
  License-File: LICENSE.rst
7
7
  Keywords: html,validation,css
@@ -1,7 +1,7 @@
1
1
  [tool.poetry]
2
2
  name = "webcode-tk"
3
- version = "1.2.6"
4
- description = ""
3
+ version = "1.2.8"
4
+ description = "A set of tools for inspecting HTML and CSS code"
5
5
  authors = ["hundredvisionsguy <cwinikka@gmail.com>"]
6
6
  keywords = ["html", "validation", "css", ]
7
7
  license = "MIT"
@@ -28,7 +28,7 @@ lxml = "^6.0.0"
28
28
  mechanicalsoup = "^1.4.0"
29
29
  pytest-mock = "^3.15.1"
30
30
 
31
- [tool.poetry.dev-dependencies]
31
+ [tool.poetry.group.dev.dependencies]
32
32
  pytest = "*"
33
33
  mkdocs = "*"
34
34
  mkgendocs = "*"
@@ -16,6 +16,11 @@
16
16
 
17
17
  - **`color_tools`**: processes CSS color related properties and values.
18
18
 
19
+ - **`contrast_tools`**: calculates contrast results taking into account how
20
+ the background is invisible, but still affects contrast on elements
21
+ even if those elements do not have a background color explicitly
22
+ applied. It also only checks elements with text in the innerHTML.
23
+
19
24
  - **`css_tools`**: creates Stylesheet objects that store CSS information.
20
25
 
21
26
  - **`font_tools`**: A collection of functions used to process font-related
@@ -35,4 +40,4 @@
35
40
  - **`validator_tools`**: sends HTML or CSS code to the W3C Validator to check
36
41
  for errors.
37
42
  """
38
- __version__ = "1.2.6"
43
+ __version__ = "1.2.8"
@@ -130,6 +130,7 @@ def generate_contrast_report(
130
130
 
131
131
  # Check if fails WCAG level
132
132
  result = item.get(wcag_level_key)
133
+
133
134
  if result:
134
135
  result = result.lower()
135
136
  else:
@@ -154,35 +155,42 @@ def generate_contrast_report(
154
155
  actual_ratio = item.get("contrast_ratio")
155
156
  actual_ratio = round(actual_ratio, 1)
156
157
 
157
- # append fail message to project_files
158
- msg = f"{result}: in {current_file}, the {element} failed WCAG "
159
- msg += f"{wcag_level} contrast requirements (ratio: "
160
- msg += f"{actual_ratio}, required: {target_ratio})."
161
- project_files[current_file].append(msg)
158
+ msg = f"{result}: in {current_file}, the <{element}> element"
159
+ msg += f" WCAG {wcag_level} contrast requirements with a "
160
+ msg += f"ratio of {actual_ratio} (min: {target_ratio})."
161
+
162
+ if msg not in project_files[current_file]:
163
+ project_files[current_file].append(msg)
162
164
 
163
165
  # Check for html docs with no fails.
164
166
  for file in project_files:
165
167
  errors = project_files.get(file)
168
+
169
+ # Check for global selectors (html, *, or body) and calculate contrast
170
+ global_color_result = css_tools.get_global_color_report(project_path)
171
+
172
+ if isinstance(global_color_result, list):
173
+ for result in global_color_result:
174
+ if file in result:
175
+ # Always add global color failures
176
+ if "fail" in result:
177
+ error_msg = result.split(file)[1]
178
+ msg = f"fail: in {file}, {error_msg.strip()}"
179
+ # Add to errors list if not already there
180
+ if msg not in project_files[file]:
181
+ project_files[file].append(msg)
182
+
183
+ # Now append to report
166
184
  if errors:
167
185
  if "has no CSS" in errors[0]:
168
186
  report.append(errors[0])
169
187
  else:
170
188
  report.extend(errors)
171
189
  else:
172
- # Check for global selectors (html, *, or body) and calculate
173
- # contrast
174
- global_color_result = css_tools.get_global_color_report(
175
- project_path
176
- )
177
- if isinstance(global_color_result, list):
178
- for result in global_color_result:
179
- if "fail" in result:
180
- if file in result:
181
- error_msg = result.split(file)[1]
182
- msg = f"fail: in {file}, {error_msg.strip()}"
183
- else:
184
- msg = f"pass: {file} passes color contrast."
190
+ # File has no element-level failures
191
+ msg = f"pass: {file} passes color contrast."
185
192
  report.append(msg)
193
+
186
194
  return report
187
195
 
188
196
 
@@ -402,6 +410,9 @@ def analyze_css(
402
410
  filename = html_doc["filename"]
403
411
  soup = html_doc["soup"]
404
412
 
413
+ # Check to see if background color was set globally
414
+ global_bg = detect_document_background(project_path, filename)
415
+
405
416
  # Check for CSS sources and create warnings if needed
406
417
  warnings = []
407
418
 
@@ -417,7 +428,7 @@ def analyze_css(
417
428
  )
418
429
 
419
430
  # Step 1: Apply default styles to all elements
420
- default_styles = apply_browser_defaults(soup)
431
+ default_styles = apply_browser_defaults(soup, default_bg=global_bg)
421
432
 
422
433
  # Find CSS rules specific to this HTML document
423
434
  doc_css_rules = get_css_rules_for_document(filename, css_files)
@@ -468,6 +479,47 @@ def analyze_css(
468
479
  return doc_results
469
480
 
470
481
 
482
+ def detect_document_background(project_path: str, filename: str) -> str:
483
+ """
484
+ Detect global background color from CSS rules for this document.
485
+
486
+ Args:
487
+ project_path: Path to project root
488
+ filename: Name of current HTML file
489
+
490
+ Returns:
491
+ str: Background color hex value, or DEFAULT_GLOBAL_BACKGROUND if
492
+ none found
493
+ """
494
+ # Get global colors for entire project
495
+ global_colors = css_tools.get_project_global_colors(project_path)
496
+
497
+ # Find entry for this specific HTML file
498
+ for file_path, color_data_list in global_colors.items():
499
+ # Match filename (handle both full path and just filename)
500
+ if filename in file_path:
501
+ # color_data_list is a list of dicts with global selector info
502
+ if isinstance(color_data_list, dict):
503
+ color_data_list = [
504
+ color_data_list,
505
+ ]
506
+ for color_data in color_data_list:
507
+ # Look for body selector specifically
508
+ if color_data.get("selector") == "body":
509
+ bg_color = color_data.get("background-color")
510
+ if bg_color:
511
+ return bg_color
512
+
513
+ # Also check for html or * selectors
514
+ if color_data.get("selector") in ["html", "*"]:
515
+ bg_color = color_data.get("background-color")
516
+ if bg_color:
517
+ return bg_color
518
+
519
+ # No global background found, use default white
520
+ return DEFAULT_GLOBAL_BACKGROUND
521
+
522
+
471
523
  def has_any_css_sources(soup: BeautifulSoup) -> bool:
472
524
  """
473
525
  Check if HTML document has any CSS sources (external or internal).
@@ -495,7 +547,9 @@ def has_any_css_sources(soup: BeautifulSoup) -> bool:
495
547
  return False
496
548
 
497
549
 
498
- def apply_browser_defaults(soup: BeautifulSoup) -> dict:
550
+ def apply_browser_defaults(
551
+ soup: BeautifulSoup, default_bg: str = DEFAULT_GLOBAL_BACKGROUND
552
+ ) -> dict:
499
553
  """
500
554
  Apply browser default styles to all elements with text content.
501
555
 
@@ -505,8 +559,18 @@ def apply_browser_defaults(soup: BeautifulSoup) -> dict:
505
559
  element_styles = {}
506
560
  default_specificity = css_tools.get_specificity("")
507
561
 
508
- for element in soup.find_all():
509
- if not element.is_empty_element and element.get_text(strip=True):
562
+ for element in soup.descendants:
563
+ # Skip non-tag elements
564
+ if not isinstance(element, Tag):
565
+ continue
566
+
567
+ # Always include html and body elements for inheritance chain
568
+ is_structural = element.name in ["html", "body"]
569
+ has_text = not element.is_empty_element and element.get_text(
570
+ strip=True
571
+ )
572
+
573
+ if is_structural or has_text:
510
574
  # The element object itself becomes the key
511
575
  element_styles[element] = {
512
576
  "color": {
@@ -516,7 +580,7 @@ def apply_browser_defaults(soup: BeautifulSoup) -> dict:
516
580
  "is_default": True,
517
581
  },
518
582
  "background-color": {
519
- "value": DEFAULT_GLOBAL_BACKGROUND,
583
+ "value": default_bg,
520
584
  "specificity": default_specificity,
521
585
  "source": "default",
522
586
  "is_default": True,
@@ -535,6 +599,8 @@ def apply_browser_defaults(soup: BeautifulSoup) -> dict:
535
599
  },
536
600
  }
537
601
 
602
+ if element.name in ["html", "body"]:
603
+ print(f" ✓ Added <{element.name}> to element_styles")
538
604
  # Apply element-specific defaults
539
605
  # Apply link colors
540
606
  if element.name == "a":
@@ -641,8 +707,12 @@ def compute_element_styles(
641
707
  dict: Dictionary mapping elements to their final computed styles
642
708
  after applying CSS cascade and inheritance.
643
709
  """
644
- # Step 1: Start with default styles
645
- computed_styles = copy.deepcopy(default_styles)
710
+ # ✅ NEW: Shallow copy of dict, deep copy only the style values
711
+ computed_styles = {}
712
+ for element, styles in default_styles.items():
713
+ # Keep the SAME element object (preserves parent relationship)
714
+ # but deep copy its styles dictionary
715
+ computed_styles[element] = copy.deepcopy(styles)
646
716
 
647
717
  # Step 2: Apply all CSS rules (cascade resolution)
648
718
  for rules_list in css_rules:
@@ -1074,145 +1144,144 @@ def apply_inheritance(soup: BeautifulSoup, computed_styles: dict) -> None:
1074
1144
  def apply_css_inheritance(computed_styles: dict) -> None:
1075
1145
  """
1076
1146
  Apply traditional CSS inheritance for inheritable properties.
1077
-
1078
- Args:
1079
- computed_styles (dict): Dictionary mapping elements to their
1080
- computed styles (modified in place).
1081
-
1082
- Returns:
1083
- None: Modifies computed_styles dictionary in place.
1147
+ Only inherit from the closest ancestor with an explicit rule.
1084
1148
  """
1085
1149
  inheritable_props = {"color", "font-size", "font-weight"}
1086
1150
 
1087
- for parent_element in computed_styles:
1088
- parent_styles = computed_styles[parent_element]
1151
+ for child_element in computed_styles:
1152
+ child_styles = computed_styles[child_element]
1089
1153
 
1090
- # Find all children that also have text content (in computed_styles)
1091
- children = parent_element.find_all()
1092
- children_with_styles = [
1093
- child for child in children if child in computed_styles
1094
- ]
1154
+ # find closest ancestor with explicit value
1155
+ for prop in inheritable_props:
1156
+ child_prop = child_styles.get(prop)
1095
1157
 
1096
- for child_element in children_with_styles:
1097
- child_styles = computed_styles[child_element]
1098
-
1099
- # Apply inheritance for each inheritable property
1100
- for prop in inheritable_props:
1101
- parent_prop = parent_styles.get(prop)
1102
-
1103
- if parent_prop: # Parent has this property
1104
- child_prop = child_styles.get(prop)
1105
- should_inherit = False
1106
-
1107
- if child_prop is None:
1108
- # Child has no property at all - inherit
1109
- should_inherit = True
1110
- elif child_prop.get("source") == "default":
1111
- # Child has default style - inheritance wins
1112
- should_inherit = True
1113
- else:
1114
- # Child has explicit CSS rule - don't inherit
1115
- should_inherit = False
1116
-
1117
- if should_inherit:
1118
- child_styles[prop] = {
1119
- "value": parent_prop["value"],
1120
- "specificity": parent_prop["specificity"],
1121
- "source": "inheritance",
1122
- "selector": parent_prop.get(
1123
- "selector", "inherited"
1124
- ),
1125
- "css_file": parent_prop.get("css_file"),
1126
- "css_source_type": parent_prop.get(
1127
- "css_source_type"
1128
- ),
1129
- "inherited_from": parent_element,
1130
- }
1158
+ # Skip if child has explicit CSS rule (not default)
1159
+ if child_prop and child_prop.get("source") not in [
1160
+ "default",
1161
+ None,
1162
+ ]:
1163
+ continue
1164
+
1165
+ # Walk up tree to find first ancestor with explicit value
1166
+ current = child_element.parent
1167
+ inherited_value = None
1168
+
1169
+ while current and not inherited_value:
1170
+ if current in computed_styles:
1171
+ parent_styles = computed_styles[current]
1172
+ parent_prop = parent_styles.get(prop)
1173
+
1174
+ # Found a parent with this property
1175
+ if parent_prop:
1176
+ # Only inherit if parent has explicit rule
1177
+ if parent_prop.get("source") == "rule":
1178
+ inherited_value = parent_prop
1179
+ inherited_from = current
1180
+ break # ← Stop at first explicit ancestor
1181
+
1182
+ current = current.parent
1183
+
1184
+ # Apply inherited value if found
1185
+ if inherited_value:
1186
+ child_styles[prop] = {
1187
+ "value": inherited_value["value"],
1188
+ "specificity": inherited_value["specificity"],
1189
+ "source": "inheritance",
1190
+ "selector": inherited_value.get("selector", "inherited"),
1191
+ "css_file": inherited_value.get("css_file"),
1192
+ "css_source_type": inherited_value.get("css_source_type"),
1193
+ "inherited_from": inherited_from,
1194
+ }
1131
1195
 
1132
1196
 
1133
1197
  def apply_visual_background_inheritance(computed_styles: dict) -> None:
1134
1198
  """
1135
- Apply visual background colors to elements without explicit
1136
- backgrounds.
1199
+ Apply visual background colors to elements without explicit backgrounds.
1137
1200
 
1138
1201
  Args:
1139
1202
  computed_styles: a dictionary of elements with computed styles.
1140
1203
  Returns:
1141
1204
  None
1142
1205
  """
1143
- # Track which elements got new backgrounds this iteration
1144
- changes_made = True
1145
- max_iterations = 10 # Prevent infinite loops
1146
- iteration = 0
1147
-
1148
- while changes_made and iteration < max_iterations:
1149
- changes_made = False
1150
- iteration += 1
1151
1206
 
1152
- for element in computed_styles:
1153
- element_styles = computed_styles[element]
1154
-
1155
- # Skip if element already has background-color
1156
- if "background-color" in element_styles:
1157
- continue
1207
+ for element in computed_styles:
1208
+ element_styles = computed_styles[element]
1158
1209
 
1159
- # Check for explicit backgrounds
1160
- if "background" in element_styles:
1161
- bg_value = element_styles["background"]["value"]
1210
+ # Check for BOTH background-color AND background
1211
+ current_bg = element_styles.get("background-color")
1212
+ has_background_shorthand = "background" in element_styles
1162
1213
 
1163
- # Check if background contains a raster image
1164
- if contains_raster_image(bg_value):
1165
- # Mark as contrast-indeterminate - cannot analyze
1166
- element_styles["background-color"] = {
1167
- "value": None, # No usable color
1168
- "specificity": "000",
1169
- "contrast_analysis": "indeterminate",
1170
- "reason": "background_image_blocks_color_analysis",
1171
- "original_background": bg_value,
1172
- "visual_inheritance": False,
1173
- }
1174
- changes_made = True
1175
- continue
1176
- else:
1177
- # Has usable background shorthand - skip inheritance
1178
- continue
1214
+ # If element has background shorthand, process it for images
1215
+ if has_background_shorthand:
1216
+ bg_value = element_styles["background"]["value"]
1179
1217
 
1180
- # Element needs background inheritance
1181
- ancestor_bg = find_ancestor_background(element, computed_styles)
1182
-
1183
- if ancestor_bg.get("contrast_analysis") == "indeterminate":
1184
- # Ancestor is indeterminate - propagate that status
1218
+ # Check if background contains a raster image
1219
+ if contains_raster_image(bg_value):
1220
+ # Mark as contrast-indeterminate - cannot analyze
1185
1221
  element_styles["background-color"] = {
1186
1222
  "value": None,
1187
1223
  "specificity": "000",
1188
1224
  "contrast_analysis": "indeterminate",
1189
- "reason": ancestor_bg["reason"],
1190
- "inherited_from": ancestor_bg["source_element"],
1191
- "source": "visual_inheritance",
1192
- "original_background": ancestor_bg.get(
1193
- "original_background"
1194
- ),
1225
+ "reason": "background_image_blocks_color_analysis",
1226
+ "original_background": bg_value,
1227
+ "visual_inheritance": False,
1195
1228
  }
1196
- changes_made = True
1229
+ continue
1197
1230
  else:
1198
- # Normal inheritance - extract usable color
1199
- contrast_color = extract_contrast_color(ancestor_bg["value"])
1200
- effective_color = (
1201
- contrast_color if contrast_color else ancestor_bg["value"]
1202
- )
1203
- element_styles["background-color"] = {
1204
- "value": effective_color,
1205
- "specificity": "000",
1206
- "source": "visual_inheritance",
1207
- "inherited_from": ancestor_bg["source_element"],
1208
- "contrast_analysis": "determinable",
1209
- "original_background": (
1210
- ancestor_bg["value"]
1211
- if ancestor_bg["value"] != DEFAULT_GLOBAL_BACKGROUND
1212
- else None
1213
- ),
1214
- }
1215
- changes_made = True
1231
+ # Has usable background shorthand - skip inheritance
1232
+ continue
1233
+ if not current_bg:
1234
+ continue
1235
+
1236
+ bg_source = current_bg.get("source")
1237
+ bg_value = current_bg.get("value")
1238
+
1239
+ # Skip elements with explicit CSS rules
1240
+ if bg_source == "rule":
1241
+ continue
1242
+
1243
+ # Only update elements that still have the default white background
1244
+ # This prevents overwriting elements with existing proper backgrounds
1245
+ if bg_source != "default":
1246
+ continue
1247
+
1248
+ # Element needs background inheritance (lines 1120-1141)
1249
+ ancestor_bg = find_ancestor_background(element, computed_styles)
1250
+
1251
+ if not ancestor_bg or ancestor_bg.get("source") == "default":
1252
+ # No explicit background to inherit - keep browser default
1253
+ continue
1254
+
1255
+ if ancestor_bg.get("contrast_analysis") == "indeterminate":
1256
+ # Ancestor is indeterminate - propagate that status
1257
+ element_styles["background-color"] = {
1258
+ "value": None,
1259
+ "specificity": "000",
1260
+ "contrast_analysis": "indeterminate",
1261
+ "reason": ancestor_bg["reason"],
1262
+ "inherited_from": ancestor_bg["source_element"],
1263
+ "source": "visual_inheritance",
1264
+ "original_background": ancestor_bg.get("original_background"),
1265
+ }
1266
+
1267
+ else:
1268
+ # Normal inheritance - extract usable color
1269
+ contrast_color = extract_contrast_color(ancestor_bg["value"])
1270
+ effective_color = (
1271
+ contrast_color if contrast_color else ancestor_bg["value"]
1272
+ )
1273
+ element_styles["background-color"] = {
1274
+ "value": effective_color,
1275
+ "specificity": "000",
1276
+ "source": "visual_inheritance",
1277
+ "inherited_from": ancestor_bg["source_element"],
1278
+ "contrast_analysis": "determinable",
1279
+ "original_background": (
1280
+ ancestor_bg["value"]
1281
+ if ancestor_bg["value"] != DEFAULT_GLOBAL_BACKGROUND
1282
+ else None
1283
+ ),
1284
+ }
1216
1285
 
1217
1286
 
1218
1287
  def has_usable_background_color(background_prop: dict) -> bool:
@@ -1367,6 +1436,10 @@ def find_ancestor_background(element: Tag, computed_styles: dict) -> dict:
1367
1436
  # Accept both "rule" and "default" sources
1368
1437
  if bg_source in ["rule", "default"]:
1369
1438
  bg_value = current_styles[bg_prop]["value"]
1439
+ if bg_source == "default":
1440
+ # Skip ancestors with only default backgrounds
1441
+ skip_ancestor = True
1442
+ break
1370
1443
 
1371
1444
  # Does this ancestor already is indeterminate status
1372
1445
  if (
@@ -1483,6 +1556,26 @@ def analyze_elements_for_contrast(
1483
1556
  contrast_analysis = element_data["background-color"].get(
1484
1557
  "contrast_analysis"
1485
1558
  )
1559
+
1560
+ # Check if this element inherited both text and background colors
1561
+ text_color_source_type = extract_property_source(
1562
+ element_data["color"]
1563
+ ).get("source_type")
1564
+ bg_color_source_type = extract_property_source(
1565
+ element_data["background-color"]
1566
+ ).get("source_type")
1567
+
1568
+ # Determine if this is a global color failure
1569
+ inherited_text = text_color_source_type in [
1570
+ "inherited",
1571
+ "browser_default",
1572
+ ]
1573
+ inherited_bg = bg_color_source_type in [
1574
+ "visual_inheritance",
1575
+ "browser_default",
1576
+ ]
1577
+ is_global_failure = inherited_text and inherited_bg
1578
+
1486
1579
  if is_large:
1487
1580
  aaa_results = contrast_report.get("Large AAA")
1488
1581
  aa_results = contrast_report.get("Large AA")
@@ -1514,6 +1607,7 @@ def analyze_elements_for_contrast(
1514
1607
  "wcag_aa_pass": aa_results,
1515
1608
  "wcag_aaa_pass": aaa_results,
1516
1609
  "contrast_analysis": contrast_analysis,
1610
+ "global_color_failure": is_global_failure,
1517
1611
  }
1518
1612
 
1519
1613
  # add to results
@@ -1627,4 +1721,7 @@ if __name__ == "__main__":
1627
1721
  project_path = "tests/test_files/large_project/"
1628
1722
  project_path = "tests/test_files/contrast_tool_test/"
1629
1723
  contrast_results = generate_contrast_report(project_path, "AAA")
1630
- print(contrast_results)
1724
+ project_path = "tests/test_files/contrast_tool_test/"
1725
+ global_colors = css_tools.get_project_global_colors(project_path)
1726
+ print()
1727
+ print()
@@ -2458,7 +2458,10 @@ def get_global_color_report(project_dir: str, level="aaa") -> list:
2458
2458
  if global_color_data:
2459
2459
  for item in global_color_data:
2460
2460
  file, result = get_color_data(filename, item, level)
2461
- passes.append(f"pass: {file} {result}")
2461
+ if "fail" in result:
2462
+ passes.append(f"fail: {file} {result}")
2463
+ else:
2464
+ passes.append(f"pass: {file} {result}")
2462
2465
  if passes:
2463
2466
  details = ""
2464
2467
  for detail in passes:
@@ -2485,8 +2488,6 @@ def get_color_data(file: str, color_details: dict, level="aaa") -> tuple:
2485
2488
  Returns:
2486
2489
  color_data: a tuple with filename and results as a string"""
2487
2490
  selector = color_details.get("selector")
2488
- bg_color = color_details.get("background-color")
2489
- color = color_details.get("color")
2490
2491
  contrast_ratio = color_details.get("contrast_ratio")
2491
2492
  if level == "aaa":
2492
2493
  passes = color_details.get("passes_normal_aaa")
@@ -2495,8 +2496,8 @@ def get_color_data(file: str, color_details: dict, level="aaa") -> tuple:
2495
2496
  if passes:
2496
2497
  results = "passes global colors"
2497
2498
  else:
2498
- results = f"{selector} {bg_color} and {color} fail with a contrast"
2499
- results += f" ratio of {contrast_ratio}."
2499
+ results = f"the <{selector}> element fails WCAG {level.upper()}"
2500
+ results += f"contrast with a ratio of {contrast_ratio}."
2500
2501
  color_data = (file, results)
2501
2502
  return color_data
2502
2503
 
@@ -2877,3 +2878,7 @@ if __name__ == "__main__":
2877
2878
  rulesets = get_element_rulesets(
2878
2879
  "tests/test_files/cascade_complexities", "figure"
2879
2880
  )
2881
+ passing_global_colors = get_global_color_report(project_folder)
2882
+ project_path = "tests/test_files/contrast_tool_test/"
2883
+ global_colors = get_global_color_report(project_path)
2884
+ print(global_colors)
File without changes
File without changes