diff --git a/api/converters.py b/api/converters.py
index 00642815..dcd9ffc5 100644
--- a/api/converters.py
+++ b/api/converters.py
@@ -14,11 +14,12 @@
# limitations under the License.
import datetime
-from typing import Any
+from typing import Any, TypedDict
from google.cloud import ndb # type: ignore
from internals.core_enums import *
from internals.core_models import FeatureEntry, MilestoneSet, Stage
+from internals.data_types import StageDict, VerboseFeatureDict
from internals.review_models import Vote, Gate
from internals import approval_defs
@@ -72,29 +73,38 @@ def _date_to_str(date: Optional[datetime.datetime]) -> Optional[str]:
return str(date) if date is not None else None
-def _val_to_list(items: Optional[list]) -> list:
+def _val_to_list(items: list | None) -> list:
"""Returns the given list, or returns an empty list if null."""
return items if items is not None else []
-def _stage_attr(
- stage: Optional[Stage], field: str, is_mstone: bool=False) -> Optional[Any]:
+def _stage_attr(stage: Stage | None, field: str) -> Any:
"""Returns a specified field of a Stage entity."""
- if stage is None:
- return None
- if not is_mstone:
- return getattr(stage, field)
+ return None if stage is None else getattr(stage, field)
- if stage.milestones is None:
+def _get_milestone_attr(stage: Stage | None, field: str) -> int | None:
+ """Returns a specified milestone field of a Stage entity."""
+ if stage is None or stage.milestones is None:
return None
return getattr(stage.milestones, field)
-def _prep_stage_gate_info(
- fe: FeatureEntry, d: dict,
+# Return type for _prep_stage_info function.
+class StagePrepResponse(TypedDict):
+ proto: Stage | None
+ dev_trial: Stage | None
+ ot: Stage | None
+ extend: Stage | None
+ ship: Stage | None
+ rollout: Stage | None
+ all_stages: list[StageDict]
+
+
+def _prep_stage_info(
+ fe: FeatureEntry,
prefetched_stages: list[Stage] | None=None
- ) -> dict[str, Optional[Stage]]:
- """Adds stage and gate info to the dict and returns major stage info."""
+ ) -> StagePrepResponse:
+ """Prepares stage info of a feature to help create JSON dictionaries."""
proto_type = STAGE_TYPES_PROTOTYPE[fe.feature_type]
dev_trial_type = STAGE_TYPES_DEV_TRIAL[fe.feature_type]
ot_type = STAGE_TYPES_ORIGIN_TRIAL[fe.feature_type]
@@ -107,18 +117,18 @@ def _prep_stage_gate_info(
prefetched_stages.sort(key=lambda s: s.stage_type)
stages = prefetched_stages
else:
- stages = Stage.query(Stage.feature_id == d['id']).order(Stage.stage_type)
+ stages = Stage.query(
+ Stage.feature_id == fe.key.integer_id()).order(Stage.stage_type)
- major_stages: dict[str, Optional[Stage]] = {
+ stage_info: StagePrepResponse = {
'proto': None,
'dev_trial': None,
'ot': None,
'extend': None,
'ship': None,
- 'rollout': None}
-
- # Write a list of stages associated with the feature.
- d['stages'] = []
+ 'rollout': None,
+ # Write a list of all stages associated with the feature.
+ 'all_stages': []}
# Keep track of trial stage indexes so that we can add trial extension
# stages as a property of the trial stage later.
@@ -127,296 +137,368 @@ def _prep_stage_gate_info(
stage_dict = stage_to_json_dict(s, fe.feature_type)
# Keep major stages for referencing additional fields.
if s.stage_type == proto_type:
- major_stages['proto'] = s
+ stage_info['proto'] = s
elif s.stage_type == dev_trial_type:
- major_stages['dev_trial'] = s
+ stage_info['dev_trial'] = s
elif s.stage_type == ot_type:
# Keep the stage's index to add trial extensions later.
- ot_stage_indexes[s.key.integer_id()] = len(d['stages'])
+ ot_stage_indexes[s.key.integer_id()] = len(stage_info['all_stages'])
stage_dict['extensions'] = []
- major_stages['ot'] = s
+ stage_info['ot'] = s
elif s.stage_type == extend_type:
# Trial extensions are kept as a list on the associated trial stage dict.
if s.ot_stage_id and s.ot_stage_id in ot_stage_indexes:
- (d['stages'][ot_stage_indexes[s.ot_stage_id]]['extensions']
+ (stage_info['all_stages'][ot_stage_indexes[s.ot_stage_id]]['extensions']
.append(stage_dict))
- major_stages['extend'] = s
+ stage_info['extend'] = s
# No need to append the extension stage to the overall stages list.
continue
elif s.stage_type == ship_type:
- major_stages['ship'] = s
+ stage_info['ship'] = s
elif s.stage_type == rollout_type:
- major_stages['rollout'] = s
- d['stages'].append(stage_dict)
+ stage_info['rollout'] = s
+ stage_info['all_stages'].append(stage_dict)
- return major_stages
-
-
-# This function is a workaround to reference extension stage info on
-# origin trial stages.
-# TODO(danielrsmith): Remove this once users can manually add trial extension
-# stages.
-def _add_ot_extension_fields(d: dict):
- """Adds info from the trial extension stage to the OT stage dict."""
- extension_stage = Stage.query(
- Stage.ot_stage_id == d['id']).get()
- if extension_stage is None:
- return
- d['experiment_extension_reason'] = extension_stage.experiment_extension_reason
- d['intent_to_extend_experiment_url'] = (
- extension_stage.intent_thread_url)
+ return stage_info
def stage_to_json_dict(
- stage: Stage, feature_type: int | None=None) -> dict[str, Any]:
+ stage: Stage, feature_type: int | None=None) -> StageDict:
"""Convert a stage entity into a JSON dict."""
# Get feature type if not supplied.
if feature_type is None:
f = FeatureEntry.get_by_id(stage.feature_id)
feature_type = f.feature_type
+ d: StageDict = {
+ 'id': stage.key.integer_id(),
+ 'feature_id': stage.feature_id,
+ 'stage_type': stage.stage_type,
+ 'intent_stage': INTENT_STAGES_BY_STAGE_TYPE.get(
+ stage.stage_type, INTENT_NONE),
+ 'pm_emails': stage.pm_emails,
+ 'tl_emails': stage.tl_emails,
+ 'ux_emails': stage.ux_emails,
+ 'te_emails': stage.te_emails,
+ 'intent_thread_url': stage.intent_thread_url,
- d: dict[str, Any] = {}
- d['id'] = stage.key.integer_id()
- d['feature_id'] = stage.feature_id
- d['stage_type'] = stage.stage_type
- d['intent_stage'] = INTENT_STAGES_BY_STAGE_TYPE.get(
- d['stage_type'], INTENT_NONE)
+ 'announcement_url': stage.announcement_url,
+ 'intent_to_experiment_url': stage.intent_thread_url,
+ 'experiment_goals': stage.experiment_goals,
+ 'experiment_risks': stage.experiment_risks,
+ 'origin_trial_feedback_url': stage.origin_trial_feedback_url,
+ 'extensions': [],
+ 'experiment_extension_reason': stage.experiment_extension_reason,
+ 'intent_to_extend_experiment_url': stage.intent_thread_url,
+ 'ot_stage_id': stage.ot_stage_id,
+ 'intent_to_ship_url': stage.intent_thread_url,
+ 'finch_url': stage.finch_url,
- # Determine the stage type and handle stage-specific fields.
- # TODO(danielrsmith): Change client to use new field names.
- milestone_field_names: list[dict] | None = None
+ 'rollout_milestone': stage.rollout_milestone,
+ 'rollout_platforms': stage.rollout_platforms,
+ 'rollout_details': stage.rollout_details,
+ 'rollout_impact': stage.rollout_impact,
+ 'enterprise_policies': stage.enterprise_policies,
+
+ # Milestone fields to be populated later.
+ 'desktop_first': None,
+ 'android_first': None,
+ 'ios_first': None,
+ 'webview_first': None,
+ 'desktop_last': None,
+ 'android_last': None,
+ 'ios_last': None,
+ 'webview_last': None,
+
+ # TODO(danielrsmith): Change client to use new field names.
+ # Legacy field names.
+ 'intent_to_implement_url': None,
+ 'intent_to_experiment_url': None,
+ 'intent_to_extend_experiment_url': None,
+ 'intent_to_ship_url': None,
+ 'ready_for_trial_url': stage.announcement_url,
+
+ # Legacy milestone field names.
+ 'shipped_milestone': None,
+ 'shipped_android_milestone': None,
+ 'shipped_ios_milestone': None,
+ 'shipped_webview_milestone': None,
+ 'ot_milestone_desktop_start': None,
+ 'ot_milestone_desktop_end': None,
+ 'ot_milestone_android_start': None,
+ 'ot_milestone_android_end': None,
+ 'ot_milestone_webview_start': None,
+ 'ot_milestone_webview_end': None,
+ 'dt_milestone_desktop_start': None,
+ 'dt_milestone_android_start': None,
+ 'dt_milestone_ios_start': None,
+ 'dt_milestone_webview_start': None,
+ }
+
+ # Determine milestone fields to use and intent fields to populate.
+ milestone_field_names: list[dict[str, str]] | None = None
if d['stage_type'] == STAGE_TYPES_PROTOTYPE[feature_type]:
d['intent_to_implement_url'] = stage.intent_thread_url
elif d['stage_type'] == STAGE_TYPES_DEV_TRIAL[feature_type]:
- d['ready_for_trial_url'] = stage.announcement_url
milestone_field_names = MilestoneSet.DEV_TRIAL_MILESTONE_FIELD_NAMES
elif d['stage_type'] == STAGE_TYPES_ORIGIN_TRIAL[feature_type]:
d['intent_to_experiment_url'] = stage.intent_thread_url
- d['experiment_goals'] = stage.experiment_goals
- d['experiment_risks'] = stage.experiment_risks
- d['origin_trial_feedback_url'] = stage.origin_trial_feedback_url
- _add_ot_extension_fields(d)
milestone_field_names = MilestoneSet.OT_MILESTONE_FIELD_NAMES
elif d['stage_type'] == STAGE_TYPES_EXTEND_ORIGIN_TRIAL[feature_type]:
- d['experiment_extension_reason'] = stage.experiment_extension_reason
d['intent_to_extend_experiment_url'] = stage.intent_thread_url
- d['ot_stage_id'] = stage.ot_stage_id
milestone_field_names = MilestoneSet.OT_EXTENSION_MILESTONE_FIELD_NAMES
elif d['stage_type'] == STAGE_TYPES_SHIPPING[feature_type]:
d['intent_to_ship_url'] = stage.intent_thread_url
- d['finch_url'] = stage.finch_url
milestone_field_names = MilestoneSet.SHIPPING_MILESTONE_FIELD_NAMES
- elif d['stage_type'] == STAGE_TYPES_ROLLOUT[feature_type]:
- d['rollout_impact'] = stage.rollout_impact
- d['rollout_milestone'] = stage.rollout_milestone
- d['rollout_platforms'] = stage.rollout_platforms
- d['rollout_details'] = stage.rollout_details
- d['enterprise_policies'] = stage.enterprise_policies
- # Add milestone fields
+ # Add milestone fields.
if stage.milestones is not None and milestone_field_names is not None:
for name_info in milestone_field_names:
# The old val name is still used on the client side.
# TODO(danielrsmith): Change client to use new field names.
- d[name_info['old']] = getattr(stage.milestones, name_info['new'])
- d[name_info['new']] = getattr(stage.milestones, name_info['new'])
-
- d['pm_emails'] = stage.pm_emails
- d['tl_emails'] = stage.tl_emails
- d['ux_emails'] = stage.ux_emails
- d['te_emails'] = stage.te_emails
- d['intent_thread_url'] = stage.intent_thread_url
+ # Must be type ignored until this is (eventually) removed.
+ d[name_info['old']] = getattr(stage.milestones, name_info['new']) # type: ignore
+ d[name_info['new']] = getattr(stage.milestones, name_info['new']) # type: ignore
return d
def feature_entry_to_json_verbose(
fe: FeatureEntry, prefetched_stages: list[Stage] | None=None
- ) -> dict[str, Any]:
+ ) -> VerboseFeatureDict:
"""Returns a verbose dictionary with all info about a feature."""
# Do not convert to JSON if the entity has not been saved.
if not fe.key:
- return {}
+ raise Exception('Unsaved FeatureEntry cannot be converted.')
- d: dict[str, Any] = fe.to_dict()
+ id: int = fe.key.integer_id()
- d['id'] = fe.key.integer_id()
+ # Get stage info, returning it to be more explicitly added.
+ stage_info = _prep_stage_info(
+ fe, prefetched_stages=prefetched_stages)
- # Get stage and gate info, returning stage info to be more explicitly added.
- stages = _prep_stage_gate_info(fe, d, prefetched_stages=prefetched_stages)
- # Prototype stage fields.
- d['intent_to_implement_url'] = _stage_attr(
- stages['proto'], 'intent_thread_url')
-
- # Dev trial stage fields.
- d['dt_milestone_desktop_start'] = _stage_attr(
- stages['dev_trial'], 'desktop_first', True)
- d['dt_milestone_android_start'] = _stage_attr(
- stages['dev_trial'], 'android_first', True)
- d['dt_milestone_ios_start'] = _stage_attr(
- stages['dev_trial'], 'ios_first', True)
- d['dt_milestone_webview_start'] = _stage_attr(
- stages['dev_trial'], 'webview_first', True)
- d['ready_for_trial_url'] = _stage_attr(
- stages['dev_trial'], 'announcement_url')
-
- # Origin trial stage fields.
- d['ot_milestone_desktop_start'] = _stage_attr(
- stages['ot'], 'desktop_first', True)
- d['ot_milestone_android_start'] = _stage_attr(
- stages['ot'], 'android_first', True)
- d['ot_milestone_webview_start'] = _stage_attr(
- stages['ot'], 'webview_first', True)
- d['ot_milestone_desktop_end'] = _stage_attr(
- stages['ot'], 'desktop_last', True)
- d['ot_milestone_android_end'] = _stage_attr(
- stages['ot'], 'android_last', True)
- d['ot_milestone_webview_end'] = _stage_attr(
- stages['ot'], 'webview_last', True)
- d['origin_trial_feeback_url'] = _stage_attr(
- stages['ot'], 'origin_trial_feedback_url')
- d['intent_to_experiment_url'] = _stage_attr(
- stages['ot'], 'intent_thread_url')
- d['experiment_goals'] = _stage_attr(stages['ot'], 'experiment_goals')
- d['experiment_risks'] = _stage_attr(stages['ot'], 'experiment_risks')
- d['announcement_url'] = _stage_attr(stages['ot'], 'announcement_url')
-
- # Extend origin trial stage fields.
- d['experiment_extension_reason'] = _stage_attr(
- stages['extend'], 'experiment_extension_reason')
- d['intent_to_extend_experiment_url'] = _stage_attr(
- stages['extend'], 'intent_thread_url')
-
- # Ship stage fields.
- d['intent_to_ship_url'] = _stage_attr(stages['ship'], 'intent_thread_url')
- d['finch_url'] = _stage_attr(stages['ship'], 'finch_url')
- d['rollout_impact'] = _stage_attr(stages['rollout'], 'rollout_impact')
- d['rollout_milestone'] = _stage_attr(stages['rollout'], 'rollout_milestone')
- d['rollout_platforms'] = _stage_attr(stages['rollout'], 'rollout_platforms')
- d['rollout_details'] = _stage_attr(stages['rollout'], 'rollout_details')
- d['enterprise_policies'] = _stage_attr(stages['rollout'], 'enterprise_policies')
-
- # TODO(danielrsmith): Adjust the references to this JSON to use
- # the new renamed field names.
- impl_status_chrome = d.pop('impl_status_chrome', None)
- standard_maturity = d.pop('standard_maturity', None)
- d['is_released'] = fe.impl_status_chrome in RELEASE_IMPL_STATES
- d['category'] = FEATURE_CATEGORIES[fe.category]
- d['category_int'] = fe.category
- d['enterprise_feature_categories'] = d.pop('enterprise_feature_categories', [])
- if fe.feature_type is not None:
- d['feature_type'] = FEATURE_TYPES[fe.feature_type]
- d['feature_type_int'] = fe.feature_type
- d['is_enterprise_feature'] = fe.feature_type == FEATURE_TYPE_ENTERPRISE_ID
- if fe.intent_stage is not None:
- d['intent_stage'] = INTENT_STAGES.get(
- fe.intent_stage, INTENT_STAGES[INTENT_NONE])
- d['intent_stage_int'] = fe.intent_stage
- d['active_stage_id'] = fe.active_stage_id
- d['created'] = {
- 'by': d.pop('creator_email', None),
- 'when': _date_to_str(fe.created),
- }
- d['updated'] = {
- 'by': d.pop('updater_email', None),
- 'when': _date_to_str(fe.updated),
- }
- d['accurate_as_of'] = _date_to_str(fe.accurate_as_of)
- d['standards'] = {
- 'spec': fe.spec_link,
- 'maturity': {
- 'text': STANDARD_MATURITY_CHOICES.get(standard_maturity),
- 'short_text': STANDARD_MATURITY_SHORT.get(standard_maturity),
- 'val': standard_maturity,
+ d: VerboseFeatureDict = {
+ 'id': id,
+ 'name': fe.name,
+ 'summary': fe.summary,
+ 'blink_components': fe.blink_components or [],
+ 'star_count': fe.star_count,
+ 'search_tags': fe.search_tags or [],
+ 'created': {
+ 'by': fe.creator_email,
+ 'when': _date_to_str(fe.created),
},
- }
- d['spec_mentors'] = fe.spec_mentor_emails
- d['tag_review_status'] = REVIEW_STATUS_CHOICES[fe.tag_review_status]
- d['tag_review_status_int'] = fe.tag_review_status
- d['security_review_status'] = REVIEW_STATUS_CHOICES[
- fe.security_review_status]
- d['security_review_status_int'] = fe.security_review_status
- d['privacy_review_status'] = REVIEW_STATUS_CHOICES[fe.privacy_review_status]
- d['privacy_review_status_int'] = fe.privacy_review_status
- d['resources'] = {
- 'samples': _val_to_list(fe.sample_links),
- 'docs': _val_to_list(fe.doc_links),
- }
- d['tags'] = d.pop('search_tags', None)
- d['editors'] = d.pop('editor_emails', [])
- d['cc_recipients'] = d.pop('cc_emails', [])
- d['creator'] = fe.creator_email
- d['comments'] = d.pop('feature_notes', None)
+ 'updated': {
+ 'by': fe.updater_email,
+ 'when': _date_to_str(fe.updated),
+ },
+ 'category': FEATURE_CATEGORIES[fe.category],
+ 'category_int': fe.category,
+ 'feature_notes': fe.feature_notes,
+ 'enterprise_feature_categories': fe.enterprise_feature_categories or [],
+ 'stages': stage_info['all_stages'],
+ 'accurate_as_of': _date_to_str(fe.accurate_as_of),
+ 'creator_email': fe.creator_email,
+ 'updater_email': fe.updater_email,
+ 'owner_emails': fe.owner_emails or [],
+ 'editor_emails': fe.editor_emails or [],
+ 'cc_emails': fe.cc_emails or [],
+ 'spec_mentor_emails': fe.spec_mentor_emails or [],
+ 'unlisted': fe.unlisted,
+ 'deleted': fe.deleted,
+ 'editors': fe.editor_emails or [],
+ 'cc_recipients': fe.cc_emails or [],
+ 'spec_mentors': fe.spec_mentor_emails or [],
+ 'creator': fe.creator_email,
+ 'feature_type': FEATURE_TYPES[fe.feature_type],
+ 'feature_type_int': fe.feature_type,
+ 'intent_stage': INTENT_STAGES.get(
+ fe.intent_stage, INTENT_STAGES[INTENT_NONE]),
+ 'intent_stage_int': fe.intent_stage,
+ 'active_stage_id': fe.active_stage_id,
+ 'bug_url': fe.bug_url,
+ 'launch_bug_url': fe.launch_bug_url,
+ 'new_crbug_url': None,
+ 'breaking_change': fe.breaking_change,
+ 'flag_name': fe.flag_name,
+ 'ongoing_constraints': fe.ongoing_constraints,
+ 'motivation': fe.motivation,
+ 'devtrial_instructions': fe.devtrial_instructions,
+ 'activation_risks': fe.activation_risks,
+ 'measurement': fe.measurement,
+ 'availability_expectation': fe.availability_expectation,
+ 'adoption_expectation': fe.adoption_expectation,
+ 'adoption_plan': fe.adoption_plan,
+ 'initial_public_proposal_url': fe.initial_public_proposal_url,
+ 'explainer_links': fe.explainer_links,
+ 'requires_embedder_support': fe.requires_embedder_support,
+ 'spec_link': fe.spec_link,
+ 'api_spec': fe.api_spec,
+ 'interop_compat_risks': fe.interop_compat_risks,
+ 'all_platforms': fe.all_platforms,
+ 'all_platforms_descr': fe.all_platforms_descr,
+ 'non_oss_deps': fe.non_oss_deps,
+ 'anticipated_spec_changes': fe.anticipated_spec_changes,
+ 'security_risks': fe.security_risks,
+ 'ergonomics_risks': fe.ergonomics_risks,
+ 'wpt': fe.wpt,
+ 'wpt_descr': fe.wpt_descr,
+ 'webview_risks': fe.webview_risks,
+ 'devrel_emails': fe.devrel_emails or [],
+ 'debuggability': fe.debuggability,
+ 'doc_links': fe.doc_links or [],
+ 'sample_links': fe.sample_links or [],
+ 'prefixed': fe.prefixed,
+ 'tags': fe.search_tags,
+ 'tag_review': fe.tag_review,
+ 'tag_review_status': REVIEW_STATUS_CHOICES[fe.tag_review_status],
+ 'tag_review_status_int': fe.tag_review_status,
+ 'security_review_status': REVIEW_STATUS_CHOICES[fe.security_review_status],
+ 'security_review_status_int': fe.security_review_status,
+ 'privacy_review_status': REVIEW_STATUS_CHOICES[fe.privacy_review_status],
+ 'privacy_review_status_int': fe.privacy_review_status,
+ 'updated_display': None,
+ 'resources': {
+ 'samples': fe.sample_links or [],
+ 'docs': fe.doc_links or [],
+ },
+ 'comments': fe.feature_notes,
+ 'ff_views': fe.ff_views or NO_PUBLIC_SIGNALS,
+ 'safari_views': fe.safari_views or NO_PUBLIC_SIGNALS,
+ 'web_dev_views': fe.web_dev_views or DEV_NO_SIGNALS,
+ 'browsers': {
+ 'chrome': {
+ 'bug': fe.bug_url,
+ 'blink_components': fe.blink_components or [],
+ 'devrel': fe.devrel_emails or [],
+ 'owners': fe.owner_emails or [],
+ 'origintrial': fe.impl_status_chrome == ORIGIN_TRIAL,
+ 'intervention': fe.impl_status_chrome == INTERVENTION,
+ 'prefixed': fe.prefixed,
+ 'flag': fe.impl_status_chrome == BEHIND_A_FLAG,
+ 'status': {
+ 'text': IMPLEMENTATION_STATUS[fe.impl_status_chrome],
+ 'val': fe.impl_status_chrome,
+ 'milestone_str': None
+ },
+
+ # TODO(danielrsmith): Find out if these are used and delete if not.
+ 'desktop': _get_milestone_attr(stage_info['ship'], 'desktop_first'),
+ 'android': _get_milestone_attr(stage_info['ship'], 'android_first'),
+ 'webview': _get_milestone_attr(stage_info['ship'], 'webview_first'),
+ 'ios': _get_milestone_attr(stage_info['ship'], 'ios_first'),
- ff_views = d.pop('ff_views', NO_PUBLIC_SIGNALS)
- safari_views = d.pop('safari_views', NO_PUBLIC_SIGNALS)
- web_dev_views = d.pop('web_dev_views', DEV_NO_SIGNALS)
- d['browsers'] = {
- 'chrome': {
- 'bug': fe.bug_url,
- 'blink_components': d.pop('blink_components', []),
- 'devrel': _val_to_list(fe.devrel_emails),
- 'owners': d.pop('owner_emails', []),
- 'origintrial': fe.impl_status_chrome == ORIGIN_TRIAL,
- 'intervention': fe.impl_status_chrome == INTERVENTION,
- 'prefixed': fe.prefixed,
- 'flag': fe.impl_status_chrome == BEHIND_A_FLAG,
- 'status': {
- 'text': IMPLEMENTATION_STATUS[impl_status_chrome],
- 'val': impl_status_chrome
},
- 'desktop': _stage_attr(stages['ship'], 'desktop_first', True),
- 'android': _stage_attr(stages['ship'], 'android_first', True),
- 'webview': _stage_attr(stages['ship'], 'webview_first', True),
- 'ios': _stage_attr(stages['ship'], 'ios_first', True),
+ 'ff': {
+ 'view': {
+ 'text': VENDOR_VIEWS.get(
+ fe.ff_views, VENDOR_VIEWS_COMMON[NO_PUBLIC_SIGNALS]),
+ 'val': fe.ff_views if fe.ff_views in VENDOR_VIEWS else NO_PUBLIC_SIGNALS,
+ 'url': fe.ff_views_link,
+ 'notes': fe.ff_views_notes,
+ },
+ },
+ 'safari': {
+ 'view': {
+ 'text': VENDOR_VIEWS.get(
+ fe.safari_views,VENDOR_VIEWS_COMMON[NO_PUBLIC_SIGNALS]),
+ 'val': (fe.safari_views if fe.safari_views in VENDOR_VIEWS
+ else NO_PUBLIC_SIGNALS),
+ 'url': fe.safari_views_link,
+ 'notes': fe.safari_views_notes,
+ },
+ },
+ 'webdev': {
+ 'view': {
+ 'text': WEB_DEV_VIEWS.get(fe.web_dev_views, WEB_DEV_VIEWS[DEV_NO_SIGNALS]),
+ 'val': (fe.web_dev_views if fe.web_dev_views in WEB_DEV_VIEWS
+ else DEV_NO_SIGNALS),
+ 'url': fe.web_dev_views_link,
+ 'notes': fe.web_dev_views_notes,
+ },
+ },
+ 'other': {
+ 'view': {
+ 'text': None,
+ 'val': None,
+ 'url': None,
+ 'notes':fe.other_views_notes,
+ },
+ },
},
- 'ff': {
- 'view': {
- 'text': VENDOR_VIEWS.get(ff_views,
- VENDOR_VIEWS_COMMON[NO_PUBLIC_SIGNALS]),
- 'val': ff_views if ff_views in VENDOR_VIEWS else NO_PUBLIC_SIGNALS,
- 'url': d.pop('ff_views_link', None),
- 'notes': d.pop('ff_views_notes'),
- }
- },
- 'safari': {
- 'view': {
- 'text': VENDOR_VIEWS.get(safari_views,
- VENDOR_VIEWS_COMMON[NO_PUBLIC_SIGNALS]),
- 'val': (safari_views if safari_views in VENDOR_VIEWS
- else NO_PUBLIC_SIGNALS),
- 'url': d.pop('safari_views_link', None),
- 'notes': d.pop('safari_views_notes', None),
- }
- },
- 'webdev': {
- 'view': {
- 'text': WEB_DEV_VIEWS.get(web_dev_views,
- WEB_DEV_VIEWS[DEV_NO_SIGNALS]),
- 'val': (web_dev_views if web_dev_views in WEB_DEV_VIEWS
- else DEV_NO_SIGNALS),
- 'url': d.pop('web_dev_views_link', None),
- 'notes': d.pop('web_dev_views_notes', None),
- }
- },
- 'other': {
- 'view': {
- 'notes': d.pop('other_views_notes', None),
- }
+ 'enterprise_feature_categories': fe.enterprise_feature_categories or [],
+ 'standards': {
+ 'spec': fe.spec_link,
+ 'maturity': {
+ 'text': STANDARD_MATURITY_CHOICES.get(fe.standard_maturity),
+ 'short_text': STANDARD_MATURITY_SHORT.get(fe.standard_maturity),
+ 'val': fe.standard_maturity,
+ },
},
+ 'is_released': fe.impl_status_chrome in RELEASE_IMPL_STATES,
+ 'is_enterprise_feature': fe.feature_type == FEATURE_TYPE_ENTERPRISE_ID,
+
+ 'experiment_timeline': fe.experiment_timeline,
+ # TODO(danielrsmith): Adjust the references to this JSON to use
+ # the new renamed field names.
+ # Prototype stage fields.
+ 'intent_to_implement_url': _stage_attr(
+ stage_info['proto'], 'intent_thread_url'),
+ # Dev trial stage fields.
+ 'dt_milestone_desktop_start': _get_milestone_attr(
+ stage_info['dev_trial'], 'desktop_first'),
+ 'dt_milestone_android_start': _get_milestone_attr(
+ stage_info['dev_trial'], 'android_first'),
+ 'dt_milestone_ios_start': _get_milestone_attr(
+ stage_info['dev_trial'], 'ios_first'),
+ 'dt_milestone_webview_start': _get_milestone_attr(
+ stage_info['dev_trial'], 'webview_first'),
+ 'ready_for_trial_url': _stage_attr(
+ stage_info['dev_trial'], 'announcement_url'),
+ # Origin trial stage fields.
+ 'ot_milestone_desktop_start': _get_milestone_attr(
+ stage_info['ot'], 'desktop_first'),
+ 'ot_milestone_android_start': _get_milestone_attr(
+ stage_info['ot'], 'android_first'),
+ 'ot_milestone_webview_start': _get_milestone_attr(
+ stage_info['ot'], 'webview_first'),
+ 'ot_milestone_desktop_end': _get_milestone_attr(
+ stage_info['ot'], 'desktop_last'),
+ 'ot_milestone_android_end': _get_milestone_attr(
+ stage_info['ot'], 'android_last'),
+ 'ot_milestone_webview_end': _get_milestone_attr(
+ stage_info['ot'], 'webview_last'),
+ 'origin_trial_feeback_url': _stage_attr(
+ stage_info['ot'], 'origin_trial_feedback_url'),
+ 'intent_to_experiment_url': _stage_attr(
+ stage_info['ot'], 'intent_thread_url'),
+ 'experiment_goals': _stage_attr(stage_info['ot'], 'experiment_goals'),
+ 'experiment_risks': _stage_attr(stage_info['ot'], 'experiment_risks'),
+ 'announcement_url': _stage_attr(stage_info['ot'], 'announcement_url'),
+ # Extend origin trial stage fields.
+ 'experiment_extension_reason': _stage_attr(
+ stage_info['extend'], 'experiment_extension_reason'),
+ 'intent_to_extend_experiment_url': _stage_attr(
+ stage_info['extend'], 'intent_thread_url'),
+ # Ship stage fields.
+ 'intent_to_ship_url': _stage_attr(stage_info['ship'], 'intent_thread_url'),
+ 'finch_url': _stage_attr(stage_info['ship'], 'finch_url'),
+ 'rollout_milestone': _stage_attr(stage_info['rollout'], 'rollout_milestone'),
+ 'rollout_details': _stage_attr(stage_info['rollout'], 'rollout_details'),
+ 'rollout_impact': _stage_attr(stage_info['rollout'], 'rollout_impact'),
}
- if d['is_released'] and _stage_attr(stages['ship'], 'desktop_first', True):
- d['browsers']['chrome']['status']['milestone_str'] = (
- _stage_attr(stages['ship'], 'desktop_first', True))
- elif d['is_released'] and _stage_attr(stages['ship'], 'android_first', True):
- d['browsers']['chrome']['status']['milestone_str'] = (
- _stage_attr(stages['ship'], 'android_first', True))
+ if (d['is_released'] and
+ _get_milestone_attr(stage_info['ship'], 'desktop_first')):
+ d['browsers']['chrome']['status']['milestone_str'] = str(
+ _get_milestone_attr(stage_info['ship'], 'desktop_first'))
+ elif (d['is_released'] and
+ _get_milestone_attr(stage_info['ship'], 'android_first')):
+ d['browsers']['chrome']['status']['milestone_str'] = str(
+ _get_milestone_attr(stage_info['ship'], 'android_first'))
else:
d['browsers']['chrome']['status']['milestone_str'] = (
d['browsers']['chrome']['status']['text'])
- del_none(d) # Further prune response by removing null/[] values.
return d
diff --git a/api/converters_test.py b/api/converters_test.py
index ae3a8698..759222ee 100644
--- a/api/converters_test.py
+++ b/api/converters_test.py
@@ -248,9 +248,9 @@ class FeatureConvertersTest(testing_config.CustomTestCase):
'other': {
'view': {
'notes': 'other notes',
- }
- }
- }
+ },
+ },
+ },
}
self.assertEqual(result, expected)
@@ -333,7 +333,6 @@ class FeatureConvertersTest(testing_config.CustomTestCase):
'when': str(self.date)
},
'accurate_as_of': str(self.date),
- 'outstanding_notifications': 0,
'resources': {
'samples': ['https://example.com/samples'],
'docs': ['https://example.com/docs'],
@@ -347,6 +346,64 @@ class FeatureConvertersTest(testing_config.CustomTestCase):
'val': 1,
},
},
+
+ 'activation_risks': None,
+ 'active_stage_id': None,
+ 'adoption_expectation': None,
+ 'adoption_plan': None,
+ 'all_platforms': None,
+ 'all_platforms_descr': None,
+ 'anticipated_spec_changes': None,
+ 'availability_expectation': None,
+ 'blink_components': ['Blink'],
+
+ 'cc_emails': [],
+ 'cc_recipients': [],
+ 'creator_email': 'creator@example.com',
+ 'debuggability': None,
+ 'devtrial_instructions': None,
+ 'dt_milestone_ios_start': None,
+ 'dt_milestone_webview_start': None,
+ 'editor_emails': ['feature_editor@example.com', 'owner_1@example.com'],
+ 'enterprise_feature_categories': [],
+ 'ergonomics_risks': None,
+ 'experiment_timeline': None,
+ 'explainer_links': [],
+ 'feature_notes': 'notes',
+ 'ff_views': 5,
+ 'flag_name': None,
+ 'initial_public_proposal_url': None,
+ 'interop_compat_risks': None,
+ 'measurement': None,
+ 'motivation': None,
+ 'new_crbug_url': None,
+ 'non_oss_deps': None,
+ 'ongoing_constraints': None,
+ 'origin_trial_feeback_url': None,
+ 'ot_milestone_android_end': None,
+ 'ot_milestone_webview_end': None,
+ 'ot_milestone_webview_start': None,
+ 'owner_emails': ['feature_owner@example.com'],
+ 'ot_milestone_webview_end': None,
+ 'ot_milestone_webview_start': None,
+ 'owner_emails': ['feature_owner@example.com'],
+ 'ready_for_trial_url': None,
+ 'rollout_details': None,
+ 'rollout_milestone': None,
+ 'safari_views': 1,
+ 'search_tags': [],
+ 'security_risks': None,
+ 'spec_mentor_emails': [],
+ 'spec_mentors': [],
+ 'tag_review': None,
+ 'tags': [],
+ 'updated_display': None,
+ 'updater_email': 'updater@example.com',
+ 'web_dev_views': 1,
+ 'webview_risks': None,
+ 'wpt': None,
+ 'wpt_descr': None,
+
'tag_review_status': 'Pending',
'tag_review_status_int': 1,
'security_review_status': 'Issues open',
@@ -364,12 +421,15 @@ class FeatureConvertersTest(testing_config.CustomTestCase):
'owners':['feature_owner@example.com'],
'desktop': 1,
'android': 1,
+ 'ios': None,
+
'origintrial': False,
'intervention': False,
'prefixed': False,
'flag': False,
+ 'webview': None,
'status': {
- 'milestone_str': 1,
+ 'milestone_str': '1',
'text': 'Enabled by default',
'val': 5
}
@@ -392,6 +452,7 @@ class FeatureConvertersTest(testing_config.CustomTestCase):
},
'webdev': {
'view': {
+ 'notes': None,
'text': 'Strongly positive',
'val': 1,
'url': 'https://example.com/web_dev',
@@ -399,10 +460,13 @@ class FeatureConvertersTest(testing_config.CustomTestCase):
},
'other': {
'view': {
- 'notes': 'other notes',
- }
- }
- }
+ 'notes': 'other notes',
+ 'text': None,
+ 'url': None,
+ 'val': None,
+ },
+ },
+ },
}
self.assertEqual(result, expected)
@@ -430,9 +494,8 @@ class FeatureConvertersTest(testing_config.CustomTestCase):
"""Function handles an empty feature."""
empty_fe = FeatureEntry()
- result = converters.feature_entry_to_json_verbose(empty_fe)
-
- self.assertEqual(result, {})
+ with self.assertRaises(Exception):
+ converters.feature_entry_to_json_verbose(empty_fe)
class VoteConvertersTest(testing_config.CustomTestCase):
diff --git a/api/features_api.py b/api/features_api.py
index 5cf8109e..b1c60723 100644
--- a/api/features_api.py
+++ b/api/features_api.py
@@ -13,7 +13,7 @@
# See the License for the specific language governing permissions and
# limitations under the License.
-from typing import Any, Optional
+from typing import Optional, TypedDict
from api import converters
from framework import basehandlers
@@ -22,19 +22,21 @@ from framework import rediscache
from framework import users
from internals.core_enums import *
from internals.core_models import FeatureEntry
+from internals.data_types import VerboseFeatureDict
from internals import feature_helpers
from internals import search
+
class FeaturesAPI(basehandlers.APIHandler):
"""Features are the the main records that we track."""
- def get_one_feature(self, feature_id: int) -> dict[str, Any]:
+ def get_one_feature(self, feature_id: int) -> VerboseFeatureDict:
feature = FeatureEntry.get_by_id(feature_id)
if not feature:
self.abort(404, msg='Feature %r not found' % feature_id)
return converters.feature_entry_to_json_verbose(feature)
- def do_search(self) -> dict[str, Any]:
+ def do_search(self):
user = users.get_current_user()
# Show unlisted features to site editors or admins.
show_unlisted_features = permissions.can_edit_any_feature(user)
@@ -69,8 +71,11 @@ class FeaturesAPI(basehandlers.APIHandler):
'features': features_on_page,
}
- def do_get(self, **kwargs) -> dict[str, Any]:
+ def do_get(self, **kwargs):
"""Handle GET requests for a single feature or a search."""
+ # TODO(danielrsmith): This request gives two independent return types
+ # based on whether a feature_id was specified. Determine the best
+ # way to handle this in a strictly-typed manner and implement it.
feature_id = kwargs.get('feature_id', None)
if feature_id:
return self.get_one_feature(feature_id)
diff --git a/api/stages_api_test.py b/api/stages_api_test.py
index d18c7937..2525ec80 100644
--- a/api/stages_api_test.py
+++ b/api/stages_api_test.py
@@ -74,31 +74,55 @@ class StagesAPITest(testing_config.CustomTestCase):
self.stage_5.put()
self.expected_stage_1 = {
- 'id': 10,
- 'feature_id': 1,
- 'stage_type': 150,
- 'intent_stage': 3,
- 'pm_emails': [],
- 'tl_emails': [],
- 'ux_emails': ['ux_person@example.com'],
- 'te_emails': [],
- 'intent_thread_url': 'https://example.com/intent',
- 'intent_to_experiment_url': 'https://example.com/intent',
- 'desktop_first': 100,
- 'desktop_last': None,
'android_first': None,
'android_last': None,
- 'webview_first': None,
- 'webview_last': None,
+ 'announcement_url': None,
+ 'desktop_first': 100,
+ 'desktop_last': None,
+ 'dt_milestone_android_start': None,
+ 'dt_milestone_desktop_start': None,
+ 'dt_milestone_ios_start': None,
+ 'dt_milestone_webview_start': None,
+ 'enterprise_policies': [],
+ 'experiment_extension_reason': None,
'experiment_goals': 'To be the very best.',
'experiment_risks': None,
+ 'extensions': [],
+ 'feature_id': 1,
+ 'finch_url': None,
+ 'id': 10,
+ 'intent_stage': 3,
+ 'intent_thread_url': 'https://example.com/intent',
+ 'intent_to_experiment_url': 'https://example.com/intent',
+ 'intent_to_extend_experiment_url': None,
+ 'intent_to_implement_url': None,
+ 'intent_to_ship_url': None,
+ 'ios_first': None,
+ 'ios_last': None,
+ 'origin_trial_feedback_url': None,
'ot_milestone_android_end': None,
'ot_milestone_android_start': None,
'ot_milestone_desktop_end': None,
'ot_milestone_desktop_start': 100,
'ot_milestone_webview_end': None,
'ot_milestone_webview_start': None,
- 'origin_trial_feedback_url': None}
+ 'ot_stage_id': None,
+ 'pm_emails': [],
+ 'ready_for_trial_url': None,
+ 'rollout_details': None,
+ 'rollout_impact': 2,
+ 'rollout_milestone': None,
+ 'rollout_platforms': [],
+ 'shipped_android_milestone': None,
+ 'shipped_ios_milestone': None,
+ 'shipped_milestone': None,
+ 'shipped_webview_milestone': None,
+ 'stage_type': 150,
+ 'te_emails': [],
+ 'tl_emails': [],
+ 'ux_emails': ['ux_person@example.com'],
+ 'webview_first': None,
+ 'webview_last': None}
self.handler = stages_api.StagesAPI()
self.request_path = '/api/v0/features/'
@@ -164,7 +188,31 @@ class StagesAPITest(testing_config.CustomTestCase):
'ot_milestone_desktop_start': 100,
'ot_milestone_webview_end': None,
'ot_milestone_webview_start': None,
- 'origin_trial_feedback_url': None}
+ 'origin_trial_feedback_url': None,
+ 'announcement_url': None,
+ 'dt_milestone_android_start': None,
+ 'dt_milestone_desktop_start': None,
+ 'dt_milestone_ios_start': None,
+ 'dt_milestone_webview_start': None,
+ 'enterprise_policies': [],
+ 'experiment_extension_reason': None,
+ 'extensions': [],
+ 'finch_url': None,
+ 'intent_to_extend_experiment_url': None,
+ 'intent_to_implement_url': None,
+ 'intent_to_ship_url': None,
+ 'ios_first': None,
+ 'ios_last': None,
+ 'ot_stage_id': 40,
+ 'ready_for_trial_url': None,
+ 'rollout_details': None,
+ 'rollout_impact': 2,
+ 'rollout_milestone': None,
+ 'rollout_platforms': [],
+ 'shipped_android_milestone': None,
+ 'shipped_ios_milestone': None,
+ 'shipped_milestone': None,
+ 'shipped_webview_milestone': None}
expect = {
'id': 40,
@@ -177,7 +225,7 @@ class StagesAPITest(testing_config.CustomTestCase):
'te_emails': [],
'intent_thread_url': 'https://example.com/intent',
'intent_to_experiment_url': 'https://example.com/intent',
- 'intent_to_extend_experiment_url': 'https://example.com/intent',
+ 'intent_to_extend_experiment_url': None,
'desktop_first': 100,
'desktop_last': None,
'android_first': None,
@@ -188,13 +236,36 @@ class StagesAPITest(testing_config.CustomTestCase):
'experiment_goals': 'To be the very best.',
'experiment_risks': None,
'extensions': [extension],
+ 'announcement_url': None,
'ot_milestone_android_end': None,
'ot_milestone_android_start': None,
'ot_milestone_desktop_end': None,
'ot_milestone_desktop_start': 100,
'ot_milestone_webview_end': None,
'ot_milestone_webview_start': None,
- 'origin_trial_feedback_url': None}
+ 'dt_milestone_android_start': None,
+ 'dt_milestone_desktop_start': None,
+ 'dt_milestone_ios_start': None,
+ 'dt_milestone_webview_start': None,
+ 'enterprise_policies': [],
+ 'origin_trial_feedback_url': None,
+ 'ready_for_trial_url': None,
+ 'rollout_details': None,
+ 'rollout_impact': 2,
+ 'rollout_milestone': None,
+ 'rollout_platforms': [],
+ 'shipped_android_milestone': None,
+ 'shipped_ios_milestone': None,
+ 'shipped_milestone': None,
+ 'shipped_webview_milestone': None,
+ 'ot_stage_id': None,
+ 'intent_to_implement_url': None,
+ 'intent_to_ship_url': None,
+ 'ios_first': None,
+ 'ios_last': None,
+ 'finch_url': None,
+
+}
with test_app.test_request_context(f'{self.request_path}1/stages/10'):
actual = self.handler.do_get(feature_id=1, stage_id=40)
diff --git a/internals/core_models.py b/internals/core_models.py
index 8871d3fd..6762418e 100644
--- a/internals/core_models.py
+++ b/internals/core_models.py
@@ -171,8 +171,6 @@ class MilestoneSet(ndb.Model): # copy from milestone fields of Feature
'ot_milestone_desktop_end': 'desktop_last',
'ot_milestone_android_start': 'android_first',
'ot_milestone_android_end': 'android_last',
- 'ot_milestone_ios_start': 'ios_first',
- 'ot_milestone_ios_end': 'ios_last',
'ot_milestone_webview_start': 'webview_first',
'ot_milestone_webview_end': 'webview_last',
'dt_milestone_desktop_start': 'desktop_first',
diff --git a/internals/data_types.py b/internals/data_types.py
new file mode 100644
index 00000000..bb62b535
--- /dev/null
+++ b/internals/data_types.py
@@ -0,0 +1,312 @@
+# -*- coding: utf-8 -*-
+# Copyright 2023 Google Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License")
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# Import needed to reference a class within its own class method.
+# https://stackoverflow.com/a/33533514
+from __future__ import annotations
+
+from typing import TypedDict
+
+from internals.core_enums import *
+
+
+# JSON representation of Stage entity data.
+class StageDict(TypedDict):
+ id: int
+ feature_id: int
+ stage_type: int
+ intent_stage: int
+ intent_thread_url: str | None
+
+ # Dev trial specific fields.
+ ready_for_trial_url: str | None
+ announcement_url: str | None
+
+
+ # Origin trial specific fields.
+ experiment_goals: str | None
+ experiment_risks: str | None
+ extensions: list[StageDict] # type: ignore
+ origin_trial_feedback_url: str | None
+
+ # Trial extension specific fields.
+ ot_stage_id: int | None
+ experiment_extension_reason: str | None
+
+ # Ship specific fields
+ finch_url: str | None
+
+ # Enterprise specific fields
+ rollout_details: str | None
+ rollout_impact: int | None
+ rollout_milestone: int | None
+ rollout_platforms: list[str]
+ enterprise_policies: list[str]
+
+ # Email information
+ pm_emails: list[str]
+ tl_emails: list[str]
+ ux_emails: list[str]
+ te_emails: list[str]
+
+ # Milestone fields
+ desktop_first: int | None
+ android_first: int | None
+ ios_first: int | None
+ webview_first: int | None
+ desktop_last: int | None
+ android_last: int | None
+ ios_last: int | None
+ webview_last: int | None
+
+ # Legacy fields or fields that now live on stage entities.
+ # TODO(danielrsmith): stop representing these on the feature dict
+ # and references them direct from stage entities.
+ intent_to_implement_url: str | None
+ intent_to_experiment_url: str | None
+ intent_to_extend_experiment_url: str | None
+ intent_to_ship_url: str | None
+
+ # Legacy milestone fields that now live on stage entities.
+ dt_milestone_desktop_start: int | None
+ dt_milestone_android_start: int | None
+ dt_milestone_ios_start: int | None
+ dt_milestone_webview_start: int | None
+ ot_milestone_desktop_start: int | None
+ ot_milestone_android_start: int | None
+ ot_milestone_webview_start: int | None
+ ot_milestone_desktop_end: int | None
+ ot_milestone_android_end: int | None
+ ot_milestone_webview_end: int | None
+ shipped_milestone: int | None
+ shipped_android_milestone: int | None
+ shipped_ios_milestone: int | None
+ shipped_webview_milestone: int | None
+
+
+#############################
+## FeatureDict definitions ##
+#############################
+# Nested JSON type definitions.
+class FeatureDictInnerResourceInfo(TypedDict):
+ samples: list[str]
+ docs: list[str]
+
+
+class FeatureDictInnerStandardsInfo(TypedDict):
+ spec: str | None
+ maturity: FeatureDictInnerMaturityInfo
+
+
+class FeatureDictInnerMaturityInfo(TypedDict):
+ text: str | None
+ short_text: str | None
+ val: int
+
+
+class FeatureDictInnerBrowserStatus(TypedDict):
+ text: str | None
+ val: str | None
+ milestone_str: str | None
+
+
+class FeatureDictInnerViewInfo(TypedDict):
+ text: str | None
+ val: int | None
+ url: str | None
+ notes: str | None
+
+
+class FeatureDictInnerChromeBrowserInfo(TypedDict):
+ bug: str | None
+ blink_components: list[str] | None
+ devrel: list[str] | None
+ owners: list[str] | None
+ origintrial: bool | None
+ intervention: bool | None
+ prefixed: bool | None
+ flag: bool | None
+ status: FeatureDictInnerBrowserStatus
+ desktop: int | None
+ android: int | None
+ webview: int | None
+ ios: int | None
+
+
+class FeatureDictInnerSingleBrowserInfo(TypedDict):
+ view: FeatureDictInnerViewInfo | None
+
+
+class FeatureBrowsersInfo(TypedDict):
+ chrome: FeatureDictInnerChromeBrowserInfo
+ ff: FeatureDictInnerSingleBrowserInfo
+ safari: FeatureDictInnerSingleBrowserInfo
+ webdev: FeatureDictInnerSingleBrowserInfo
+ other: FeatureDictInnerSingleBrowserInfo
+
+
+# Basic user info displayed for create/update attributes in
+# FeatureEntry edit information.
+class FeatureDictInnerUserEditInfo(TypedDict):
+ by: str | None
+ when: str | None
+
+
+# JSON representation of FeatureEntry entity. Created from
+# converters.feature_entry_to_json_verbose().
+class VerboseFeatureDict(TypedDict):
+
+ # Metadata: Creation and updates.
+ id: int
+ created: FeatureDictInnerUserEditInfo
+ updated: FeatureDictInnerUserEditInfo
+ accurate_as_of: str | None
+ creator_email: str | None
+ updater_email: str | None
+
+ # Metadata: Access controls
+ owner_emails: list[str]
+ editor_emails: list[str]
+ cc_emails: list[str]
+ spec_mentor_emails: list[str]
+ unlisted: bool
+ deleted: bool
+
+ # Renamed metadata fields
+ editors: list[str]
+ cc_recipients: list[str]
+ spec_mentors: list[str]
+ creator: str | None
+
+ # Descriptive info.
+ name: str
+ summary: str
+ category: str
+ category_int: int
+ blink_components: list[str]
+ star_count: int
+ search_tags: list[str]
+ feature_notes: str | None
+ enterprise_feature_categories: list[str]
+
+ # Metadata: Process information
+ feature_type: str
+ feature_type_int: int
+ intent_stage: str
+ intent_stage_int: int
+ active_stage_id: int | None
+ bug_url: str | None
+ launch_bug_url: str | None
+ breaking_change: bool
+
+ # Implementation in Chrome
+ flag_name: str | None
+ ongoing_constraints: str | None
+
+ # Topic: Adoption
+ motivation: str | None
+ devtrial_instructions: str | None
+ activation_risks: str | None
+ measurement: str | None
+ availability_expectation: str | None
+ adoption_expectation: str | None
+ adoption_plan: str | None
+
+ # Gate: Standardization and Interop
+ initial_public_proposal_url: str | None
+ explainer_links: list[str]
+ requires_embedder_support: bool
+ spec_link: str | None
+ api_spec: str | None
+ prefixed: bool | None
+ interop_compat_risks: str | None
+ all_platforms: bool | None
+ all_platforms_descr: bool | None
+ tag_review: str | None
+ non_oss_deps: str | None
+ anticipated_spec_changes: str | None
+
+ # Gate: Security & Privacy
+ security_risks: str | None
+ tags: list[str]
+ tag_review_status: str
+ tag_review_status_int: int | None
+ security_review_status: str
+ security_review_status_int: int | None
+ privacy_review_status: str
+ privacy_review_status_int: int | None
+
+ # Gate: Testing / Regressions
+ ergonomics_risks: str | None
+ wpt: bool | None
+ wpt_descr: str | None
+ webview_risks: str | None
+
+ # Gate: Devrel & Docs
+ devrel_emails: list[str]
+ debuggability: str | None
+ doc_links: list[str]
+ sample_links: list[str]
+
+ stages: list[StageDict]
+
+ # Legacy fields or fields that now live on stage entities.
+ # TODO(danielrsmith): stop representing these on the feature dict
+ # and references them direct from stage entities.
+ intent_to_implement_url: str | None
+ intent_to_experiment_url: str | None
+ intent_to_extend_experiment_url: str | None
+ intent_to_ship_url: str | None
+ ready_for_trial_url: str | None
+ origin_trial_feeback_url: str | None
+ experiment_goals: str | None
+ experiment_risks: str | None
+ announcement_url: str | None
+ experiment_extension_reason: str | None
+
+ rollout_details: str | None
+ rollout_impact: int | None
+ rollout_milestone: int | None
+
+ experiment_timeline: str | None
+ resources: FeatureDictInnerResourceInfo
+ comments: str | None # feature_notes
+
+ # Repeated in 'browsers' section. TODO(danielrsmith): delete these?
+ ff_views: int
+ safari_views: int
+ web_dev_views: int
+
+ browsers: FeatureBrowsersInfo
+
+ # Legacy milestone fields that now live on stage entities.
+ dt_milestone_desktop_start: int | None
+ dt_milestone_android_start: int | None
+ dt_milestone_ios_start: int | None
+ dt_milestone_webview_start: int | None
+ ot_milestone_desktop_start: int | None
+ ot_milestone_android_start: int | None
+ ot_milestone_webview_start: int | None
+ ot_milestone_desktop_end: int | None
+ ot_milestone_android_end: int | None
+ ot_milestone_webview_end: int | None
+
+ finch_url: str | None
+
+ standards: FeatureDictInnerStandardsInfo
+ is_released: bool
+ is_enterprise_feature: bool
+ updated_display: str | None
+ new_crbug_url: str | None
diff --git a/pages/testdata/intentpreview_test/test_html_rendering.html b/pages/testdata/intentpreview_test/test_html_rendering.html
index e8f97dd9..42919fff 100644
--- a/pages/testdata/intentpreview_test/test_html_rendering.html
+++ b/pages/testdata/intentpreview_test/test_html_rendering.html
@@ -167,7 +167,7 @@ limitations under the License.
None
None
None
None
@@ -247,7 +247,7 @@ No