Skip to content

Commit 79b0798

Browse files
authored
Merge pull request #278 from ma10/refactor-yaml2x-20250214
yaml2rst.pyとa11y-guidelinesのリファクタリング
2 parents bc01d0b + 88cc787 commit 79b0798

File tree

21 files changed

+2440
-217
lines changed

21 files changed

+2440
-217
lines changed
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
"""Models package for a11y-guidelines.
2+
3+
This package contains models for:
4+
- Base functionality (BaseModel, RelationshipManager)
5+
- Content (Category, Guideline)
6+
- Checks (Check, CheckTool, etc.)
7+
- FAQs (Faq, FaqTag)
8+
- References (WcagSc, InfoRef)
9+
- axe-core integration (AxeRule)
10+
"""
11+
from .base import BaseModel, RelationshipManager
12+
from .content import Category, Guideline
13+
from .check import Check, CheckTool, Condition, Procedure, Implementation, Method
14+
from .faq import Faq, FaqTag
15+
from .reference import WcagSc, InfoRef
16+
from .axe import AxeRule
17+
18+
__all__ = [
19+
# Base
20+
'BaseModel',
21+
'RelationshipManager',
22+
23+
# Content
24+
'Category',
25+
'Guideline',
26+
27+
# Checks
28+
'Check',
29+
'CheckTool',
30+
'Condition',
31+
'Procedure',
32+
'Implementation',
33+
'Method',
34+
35+
# FAQs
36+
'Faq',
37+
'FaqTag',
38+
39+
# References
40+
'WcagSc',
41+
'InfoRef',
42+
43+
# axe-core
44+
'AxeRule',
45+
]
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
"""Models for axe-core accessibility testing tool."""
2+
import re
3+
from typing import Dict, List, Optional, Any, ClassVar
4+
from dataclasses import dataclass
5+
from .base import BaseModel, RelationshipManager
6+
7+
@dataclass
8+
class AxeMessage:
9+
"""Container for axe-core message translations."""
10+
help: Dict[str, str]
11+
description: Dict[str, str]
12+
13+
class AxeRule(BaseModel):
14+
"""axe-core rule model."""
15+
16+
object_type = "axe_rule"
17+
_instances: Dict[str, 'AxeRule'] = {}
18+
19+
# Class-level metadata
20+
timestamp: Optional[str] = None
21+
version: Optional[str] = None
22+
major_version: Optional[str] = None
23+
deque_url: Optional[str] = None
24+
25+
def __init__(self, rule: Dict[str, Any], messages_ja: Dict[str, Any]):
26+
"""Initialize axe rule.
27+
28+
Args:
29+
rule: Dictionary containing rule data
30+
messages_ja: Dictionary containing Japanese translations
31+
"""
32+
super().__init__(rule['id'])
33+
34+
if self.id in self._instances:
35+
raise ValueError(f'Duplicate rule ID: {self.id}')
36+
37+
# Set message data
38+
if self.id not in messages_ja['rules']:
39+
msg_ja = {
40+
'help': rule['metadata']['help'],
41+
'description': rule['metadata']['description']
42+
}
43+
self.translated = False
44+
else:
45+
msg_ja = messages_ja['rules'][self.id]
46+
self.translated = True
47+
48+
self.message = AxeMessage(
49+
help={
50+
'en': rule['metadata']['help'],
51+
'ja': msg_ja['help']
52+
},
53+
description={
54+
'en': rule['metadata']['description'],
55+
'ja': msg_ja['description']
56+
}
57+
)
58+
59+
# Set relationships
60+
self.has_wcag_sc = False
61+
self.has_guideline = False
62+
rel = RelationshipManager()
63+
64+
# Find and associate WCAG success criteria
65+
wcag_scs = [
66+
tag2sc(tag)
67+
for tag in rule['tags']
68+
if re.match(r'wcag\d{3,}', tag)
69+
]
70+
71+
for sc in wcag_scs:
72+
from .reference import WcagSc
73+
if sc not in WcagSc._instances:
74+
continue
75+
rel.associate_objects(self, WcagSc.get_by_id(sc))
76+
self.has_wcag_sc = True
77+
for guideline in rel.get_related_objects(WcagSc.get_by_id(sc), 'guideline'):
78+
rel.associate_objects(self, guideline)
79+
self.has_guideline = True
80+
81+
AxeRule._instances[self.id] = self
82+
83+
def template_data(self, lang: str) -> Dict[str, Any]:
84+
"""Get template data for axe rule.
85+
86+
Args:
87+
lang: Language code
88+
89+
Returns:
90+
Dictionary with template data
91+
"""
92+
rel = RelationshipManager()
93+
data = {
94+
'id': self.id,
95+
'help': self.message.help,
96+
'description': self.message.description
97+
}
98+
99+
if self.translated:
100+
data['translated'] = True
101+
102+
if self.has_wcag_sc:
103+
from .reference import WcagSc
104+
scs = sorted(
105+
rel.get_related_objects(self, 'wcag_sc'),
106+
key=lambda x: x.sort_key
107+
)
108+
data['scs'] = [sc.template_data() for sc in scs]
109+
110+
if self.has_guideline:
111+
guidelines = sorted(
112+
rel.get_related_objects(self, 'guideline'),
113+
key=lambda x: x.sort_key
114+
)
115+
data['guidelines'] = [
116+
gl.get_category_and_id(lang)
117+
for gl in guidelines
118+
]
119+
120+
return data
121+
122+
@classmethod
123+
def list_all(cls) -> List['AxeRule']:
124+
"""Get all axe rules sorted by relevance and ID."""
125+
sorted_all_rules = sorted(cls._instances, key=lambda x: cls._instances[x].id)
126+
with_guidelines = []
127+
with_sc = []
128+
without_sc = []
129+
130+
for rule_id in sorted_all_rules:
131+
rule = cls._instances[rule_id]
132+
if rule.has_guideline:
133+
with_guidelines.append(rule)
134+
elif rule.has_wcag_sc:
135+
with_sc.append(rule)
136+
else:
137+
without_sc.append(rule)
138+
139+
return with_guidelines + with_sc + without_sc
140+
141+
def tag2sc(tag: str) -> str:
142+
"""Convert axe-core tag to WCAG SC identifier.
143+
144+
Args:
145+
tag: axe-core tag (e.g., 'wcag111')
146+
147+
Returns:
148+
WCAG SC identifier (e.g., '1.1.1')
149+
"""
150+
return re.sub(r'wcag(\d)(\d)(\d+)', r'\1.\2.\3', tag)
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
"""Base model classes and relationship management."""
2+
from typing import Any, Dict, List, Optional, Type, TypeVar
3+
4+
T = TypeVar('T', bound='BaseModel')
5+
6+
class BaseModel:
7+
"""Base class for all models."""
8+
9+
object_type: str = ""
10+
_instances: Dict[str, Any] = {}
11+
12+
def __init__(self, id: str):
13+
"""Initialize base model.
14+
15+
Args:
16+
id: Unique identifier for the model instance
17+
"""
18+
self.id = id
19+
if not hasattr(self.__class__, '_instances'):
20+
self.__class__._instances = {}
21+
22+
@classmethod
23+
def get_by_id(cls: Type[T], id: str) -> Optional[T]:
24+
"""Get model instance by ID.
25+
26+
Args:
27+
id: Instance identifier
28+
29+
Returns:
30+
Model instance if found, None otherwise
31+
"""
32+
return cls._instances.get(id)
33+
34+
class RelationshipManager:
35+
"""Manages relationships between different model objects."""
36+
37+
_instance = None
38+
_initialized = False
39+
40+
def __new__(cls):
41+
if cls._instance is None:
42+
cls._instance = super(RelationshipManager, cls).__new__(cls)
43+
return cls._instance
44+
45+
def __init__(self):
46+
"""Initialize the relationship manager.
47+
48+
Uses singleton pattern to ensure only one instance exists.
49+
"""
50+
if self._initialized:
51+
return
52+
self._data = {}
53+
self._unresolved_faqs = {}
54+
self._initialized = True
55+
56+
def associate_objects(self, obj1: BaseModel, obj2: BaseModel) -> None:
57+
"""Associate two objects bidirectionally.
58+
59+
Args:
60+
obj1: First object to associate
61+
obj2: Second object to associate
62+
"""
63+
obj1_type = obj1.object_type
64+
obj2_type = obj2.object_type
65+
obj1_id = obj1.id
66+
obj2_id = obj2.id
67+
68+
# Create nested dictionaries if they don't exist
69+
for obj_type, obj_id in [(obj1_type, obj1_id), (obj2_type, obj2_id)]:
70+
if obj_type not in self._data:
71+
self._data[obj_type] = {}
72+
if obj_id not in self._data[obj_type]:
73+
self._data[obj_type][obj_id] = {}
74+
75+
# Add bidirectional relationship
76+
for (src_type, src_id, src_obj, dest_type, dest_obj) in [
77+
(obj1_type, obj1_id, obj1, obj2_type, obj2),
78+
(obj2_type, obj2_id, obj2, obj1_type, obj1)
79+
]:
80+
if dest_type not in self._data[src_type][src_id]:
81+
self._data[src_type][src_id][dest_type] = []
82+
if dest_obj not in self._data[src_type][src_id][dest_type]:
83+
self._data[src_type][src_id][dest_type].append(dest_obj)
84+
85+
def add_unresolved_faqs(self, faq1_id: str, faq2_id: str) -> None:
86+
"""Add unresolved FAQ relationship to be resolved later.
87+
88+
Args:
89+
faq1_id: ID of first FAQ
90+
faq2_id: ID of second FAQ
91+
"""
92+
for id1, id2 in [(faq1_id, faq2_id), (faq2_id, faq1_id)]:
93+
if id1 not in self._unresolved_faqs:
94+
self._unresolved_faqs[id1] = []
95+
if id2 not in self._unresolved_faqs[id1]:
96+
self._unresolved_faqs[id1].append(id2)
97+
98+
def resolve_faqs(self) -> None:
99+
"""Resolve all unresolved FAQ relationships."""
100+
from .faq import Faq # Import here to avoid circular imports
101+
102+
for faq_id in self._unresolved_faqs:
103+
for faq2_id in self._unresolved_faqs[faq_id]:
104+
faq1 = Faq.get_by_id(faq_id)
105+
faq2 = Faq.get_by_id(faq2_id)
106+
if faq1 and faq2:
107+
self.associate_objects(faq1, faq2)
108+
109+
def get_related_objects(self, obj: BaseModel, related_type: str) -> List[Any]:
110+
"""Get all related objects of a specific type for an object.
111+
112+
Args:
113+
obj: Source object
114+
related_type: Type of related objects to retrieve
115+
116+
Returns:
117+
List of related objects
118+
"""
119+
try:
120+
return self._data[obj.object_type][obj.id][related_type]
121+
except KeyError:
122+
return []
123+
124+
def get_sorted_related_objects(
125+
self,
126+
obj: BaseModel,
127+
related_type: str,
128+
key: str = 'sort_key'
129+
) -> List[Any]:
130+
"""Get sorted related objects of a specific type.
131+
132+
Args:
133+
obj: Source object
134+
related_type: Type of related objects to get
135+
key: Key to sort by (defaults to 'sort_key')
136+
137+
Returns:
138+
Sorted list of related objects
139+
"""
140+
objects = self.get_related_objects(obj, related_type)
141+
return sorted(objects, key=lambda x: getattr(x, key))

0 commit comments

Comments
 (0)