diff --git a/app/__init__.py b/app/__init__.py index 5292c767b..3c711c580 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -23,12 +23,6 @@ from flask_wtf import CSRFProtect from flask_wtf.csrf import CSRFError from itsdangerous import BadSignature from notifications_python_client.errors import HTTPError -from notifications_utils import logging, request_helper -from notifications_utils.formatters import ( - formatted_list, - get_lines_with_normalised_whitespace, -) -from notifications_utils.recipients import format_phone_number_human_readable from werkzeug.exceptions import HTTPException as WerkzeugHTTPException from werkzeug.exceptions import abort from werkzeug.local import LocalProxy @@ -113,6 +107,12 @@ from app.notify_client.upload_api_client import upload_api_client from app.notify_client.user_api_client import user_api_client from app.url_converters import SimpleDateTypeConverter, TemplateTypeConverter from app.utils.govuk_frontend_jinja.flask_ext import init_govuk_frontend +from notifications_utils import logging, request_helper +from notifications_utils.formatters import ( + formatted_list, + get_lines_with_normalised_whitespace, +) +from notifications_utils.recipients import format_phone_number_human_readable login_manager = LoginManager() csrf = CSRFProtect() diff --git a/app/config.py b/app/config.py index 0a05908f5..8ec4db186 100644 --- a/app/config.py +++ b/app/config.py @@ -2,9 +2,9 @@ import json from os import getenv import newrelic.agent -from notifications_utils import DAILY_MESSAGE_LIMIT from app.cloudfoundry_config import cloud_config +from notifications_utils import DAILY_MESSAGE_LIMIT class Config(object): diff --git a/app/formatters.py b/app/formatters.py index 2bb8854f3..e5a6699a0 100644 --- a/app/formatters.py +++ b/app/formatters.py @@ -15,15 +15,15 @@ import pytz from bs4 import BeautifulSoup from flask import Markup, render_template_string, url_for from flask.helpers import get_root_path + +from app.utils.csv import get_user_preferred_timezone +from app.utils.time import parse_dt, parse_naive_dt from notifications_utils.field import Field from notifications_utils.formatters import make_quotes_smart from notifications_utils.formatters import nl2br as utils_nl2br from notifications_utils.recipients import InvalidPhoneError, validate_phone_number from notifications_utils.take import Take -from app.utils.csv import get_user_preferred_timezone -from app.utils.time import parse_dt, parse_naive_dt - def apply_html_class(tags, html_file): new_html = html_file diff --git a/app/main/forms.py b/app/main/forms.py index 13a463a51..c82048f37 100644 --- a/app/main/forms.py +++ b/app/main/forms.py @@ -10,9 +10,6 @@ from flask_wtf import FlaskForm as Form from flask_wtf.file import FileAllowed from flask_wtf.file import FileField as FileField_wtf from flask_wtf.file import FileSize -from notifications_utils.formatters import strip_all_whitespace -from notifications_utils.insensitive_dict import InsensitiveDict -from notifications_utils.recipients import InvalidPhoneError, validate_phone_number from werkzeug.utils import cached_property from wtforms import ( BooleanField, @@ -65,6 +62,9 @@ from app.models.organization import Organization from app.utils import merge_jsonlike from app.utils.csv import get_user_preferred_timezone from app.utils.user_permissions import all_ui_permissions, permission_options +from notifications_utils.formatters import strip_all_whitespace +from notifications_utils.insensitive_dict import InsensitiveDict +from notifications_utils.recipients import InvalidPhoneError, validate_phone_number def get_time_value_and_label(future_time): diff --git a/app/main/validators.py b/app/main/validators.py index 8ea7934bf..4dd04017b 100644 --- a/app/main/validators.py +++ b/app/main/validators.py @@ -1,15 +1,15 @@ import re from abc import ABC, abstractmethod -from notifications_utils.field import Field -from notifications_utils.formatters import formatted_list -from notifications_utils.recipients import InvalidEmailError, validate_email_address -from notifications_utils.sanitise_text import SanitiseSMS from wtforms import ValidationError from app.main._commonly_used_passwords import commonly_used_passwords from app.models.spreadsheet import Spreadsheet from app.utils.user import is_gov_user +from notifications_utils.field import Field +from notifications_utils.formatters import formatted_list +from notifications_utils.recipients import InvalidEmailError, validate_email_address +from notifications_utils.sanitise_text import SanitiseSMS class CommonlyUsedPassword: diff --git a/app/main/views/conversation.py b/app/main/views/conversation.py index 4b14ddf31..a3ac47da7 100644 --- a/app/main/views/conversation.py +++ b/app/main/views/conversation.py @@ -1,14 +1,14 @@ from flask import jsonify, redirect, render_template, session, url_for from flask_login import current_user from notifications_python_client.errors import HTTPError -from notifications_utils.recipients import format_phone_number_human_readable -from notifications_utils.template import SMSPreviewTemplate from app import current_service, notification_api_client, service_api_client from app.main import main from app.main.forms import SearchByNameForm from app.models.template_list import TemplateList from app.utils.user import user_has_permissions +from notifications_utils.recipients import format_phone_number_human_readable +from notifications_utils.template import SMSPreviewTemplate @main.route("/services//conversation/") diff --git a/app/main/views/dashboard.py b/app/main/views/dashboard.py index 4d5aca14d..52f3ed775 100644 --- a/app/main/views/dashboard.py +++ b/app/main/views/dashboard.py @@ -6,7 +6,6 @@ from itertools import groupby from flask import Response, abort, jsonify, render_template, request, session, url_for from flask_login import current_user -from notifications_utils.recipients import format_phone_number_human_readable from werkzeug.utils import redirect from app import ( @@ -30,6 +29,7 @@ from app.utils.csv import Spreadsheet from app.utils.pagination import generate_next_dict, generate_previous_dict from app.utils.time import get_current_financial_year from app.utils.user import user_has_permissions +from notifications_utils.recipients import format_phone_number_human_readable @main.route("/services//dashboard") diff --git a/app/main/views/jobs.py b/app/main/views/jobs.py index 0c0848e46..cb1b7fdf4 100644 --- a/app/main/views/jobs.py +++ b/app/main/views/jobs.py @@ -15,7 +15,6 @@ from flask import ( url_for, ) from flask_login import current_user -from notifications_utils.template import EmailPreviewTemplate, SMSBodyPreviewTemplate from app import ( current_service, @@ -35,6 +34,7 @@ from app.utils.pagination import ( get_page_from_request, ) from app.utils.user import user_has_permissions +from notifications_utils.template import EmailPreviewTemplate, SMSBodyPreviewTemplate @main.route("/services//jobs") diff --git a/app/main/views/new_password.py b/app/main/views/new_password.py index 4bf374717..222268c36 100644 --- a/app/main/views/new_password.py +++ b/app/main/views/new_password.py @@ -10,12 +10,12 @@ from flask import ( url_for, ) from itsdangerous import SignatureExpired -from notifications_utils.url_safe_token import check_token from app.main import main from app.main.forms import NewPasswordForm from app.models.user import User from app.utils.login import log_in_user +from notifications_utils.url_safe_token import check_token @main.route("/new-password/", methods=["GET", "POST"]) diff --git a/app/main/views/pricing.py b/app/main/views/pricing.py index 0e5fb361d..5e60e6768 100644 --- a/app/main/views/pricing.py +++ b/app/main/views/pricing.py @@ -1,11 +1,11 @@ from flask import current_app, render_template from flask_login import current_user -from notifications_utils.international_billing_rates import INTERNATIONAL_BILLING_RATES from app.main import main from app.main.forms import SearchByNameForm from app.main.views.sub_navigation_dictionaries import using_notify_nav from app.utils.user import user_is_logged_in +from notifications_utils.international_billing_rates import INTERNATIONAL_BILLING_RATES CURRENT_SMS_RATE = "1.72" diff --git a/app/main/views/send.py b/app/main/views/send.py index 3aa5bd830..8b16f2b5e 100644 --- a/app/main/views/send.py +++ b/app/main/views/send.py @@ -7,10 +7,6 @@ from flask import abort, flash, redirect, render_template, request, session, url from flask_login import current_user from markupsafe import Markup from notifications_python_client.errors import HTTPError -from notifications_utils import SMS_CHAR_COUNT_LIMIT -from notifications_utils.insensitive_dict import InsensitiveDict -from notifications_utils.recipients import RecipientCSV, first_column_headings -from notifications_utils.sanitise_text import SanitiseASCII from xlrd.biffh import XLRDError from xlrd.xldate import XLDateError @@ -39,6 +35,10 @@ from app.utils import PermanentRedirect, should_skip_template_page, unicode_trun from app.utils.csv import Spreadsheet, get_errors_for_csv from app.utils.templates import get_template from app.utils.user import user_has_permissions +from notifications_utils import SMS_CHAR_COUNT_LIMIT +from notifications_utils.insensitive_dict import InsensitiveDict +from notifications_utils.recipients import RecipientCSV, first_column_headings +from notifications_utils.sanitise_text import SanitiseASCII def get_example_csv_fields(column_headers, use_example_as_example, submitted_fields): diff --git a/app/main/views/sign_in.py b/app/main/views/sign_in.py index 402e61a08..0039efdc5 100644 --- a/app/main/views/sign_in.py +++ b/app/main/views/sign_in.py @@ -16,7 +16,6 @@ from flask import ( url_for, ) from flask_login import current_user -from notifications_utils.url_safe_token import generate_token from app import login_manager, user_api_client from app.main import main @@ -26,6 +25,7 @@ from app.main.views.verify import activate_user from app.models.user import InvitedUser, User from app.utils import hide_from_search_engines from app.utils.login import is_safe_redirect_url +from notifications_utils.url_safe_token import generate_token def _reformat_keystring(orig): diff --git a/app/main/views/templates.py b/app/main/views/templates.py index e9e5f5b61..5c59e1e7c 100644 --- a/app/main/views/templates.py +++ b/app/main/views/templates.py @@ -4,7 +4,6 @@ from flask import abort, flash, jsonify, redirect, render_template, request, url from flask_login import current_user from markupsafe import Markup from notifications_python_client.errors import HTTPError -from notifications_utils import SMS_CHAR_COUNT_LIMIT from app import ( current_service, @@ -30,6 +29,7 @@ from app.models.template_list import TemplateList, TemplateLists from app.utils import NOTIFICATION_TYPES, should_skip_template_page from app.utils.templates import get_template from app.utils.user import user_has_permissions +from notifications_utils import SMS_CHAR_COUNT_LIMIT form_objects = { "email": EmailTemplateForm, diff --git a/app/main/views/two_factor.py b/app/main/views/two_factor.py index 560df1fad..093e79845 100644 --- a/app/main/views/two_factor.py +++ b/app/main/views/two_factor.py @@ -3,7 +3,6 @@ import json from flask import current_app, redirect, render_template, request, session, url_for from flask_login import current_user from itsdangerous import SignatureExpired -from notifications_utils.url_safe_token import check_token from app import user_api_client from app.main import main @@ -15,6 +14,7 @@ from app.utils.login import ( redirect_to_sign_in, redirect_when_logged_in, ) +from notifications_utils.url_safe_token import check_token @main.route("/two-factor-email-sent", methods=["GET"]) diff --git a/app/main/views/user_profile.py b/app/main/views/user_profile.py index 5d10fbf75..b718bb484 100644 --- a/app/main/views/user_profile.py +++ b/app/main/views/user_profile.py @@ -11,7 +11,6 @@ from flask import ( url_for, ) from flask_login import current_user -from notifications_utils.url_safe_token import check_token from app import user_api_client from app.event_handlers import ( @@ -31,6 +30,7 @@ from app.main.forms import ( ) from app.models.user import User from app.utils.user import user_is_gov_user, user_is_logged_in +from notifications_utils.url_safe_token import check_token NEW_EMAIL = "new-email" NEW_MOBILE = "new-mob" diff --git a/app/main/views/verify.py b/app/main/views/verify.py index d87c9c6dc..48e1cd9e6 100644 --- a/app/main/views/verify.py +++ b/app/main/views/verify.py @@ -2,7 +2,6 @@ import json from flask import abort, current_app, flash, redirect, render_template, session, url_for from itsdangerous import SignatureExpired -from notifications_utils.url_safe_token import check_token from app import user_api_client from app.extensions import redis_client @@ -10,6 +9,7 @@ from app.main import main from app.main.forms import TwoFactorForm from app.models.user import InvitedOrgUser, InvitedUser, User from app.utils.login import redirect_to_sign_in +from notifications_utils.url_safe_token import check_token @main.route("/verify", methods=["GET", "POST"]) diff --git a/app/models/__init__.py b/app/models/__init__.py index 0c3613519..e9adf75a4 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -1,6 +1,7 @@ from abc import abstractmethod from flask import abort + from notifications_utils.serialised_model import ( SerialisedModel, SerialisedModelCollection, diff --git a/app/models/event.py b/app/models/event.py index 3af502ea7..b57f11e73 100644 --- a/app/models/event.py +++ b/app/models/event.py @@ -1,10 +1,9 @@ from abc import ABC, abstractmethod -from notifications_utils.formatters import formatted_list - from app.formatters import format_thousands from app.models import ModelList from app.notify_client.service_api_client import service_api_client +from notifications_utils.formatters import formatted_list class Event(ABC): diff --git a/app/models/service.py b/app/models/service.py index e06a1b16d..375047d8d 100644 --- a/app/models/service.py +++ b/app/models/service.py @@ -1,5 +1,4 @@ from flask import abort, current_app -from notifications_utils.serialised_model import SerialisedModelCollection from werkzeug.utils import cached_property from app.models import JSONModel, SortByNameMixin @@ -15,6 +14,7 @@ from app.notify_client.organizations_api_client import organizations_client from app.notify_client.service_api_client import service_api_client from app.notify_client.template_folder_api_client import template_folder_api_client from app.utils import get_default_sms_sender +from notifications_utils.serialised_model import SerialisedModelCollection class Service(JSONModel, SortByNameMixin): diff --git a/app/notify_client/__init__.py b/app/notify_client/__init__.py index 1fc14f811..2cad0b68a 100644 --- a/app/notify_client/__init__.py +++ b/app/notify_client/__init__.py @@ -2,9 +2,9 @@ from flask import abort, has_request_context, request from flask_login import current_user from notifications_python_client import __version__ from notifications_python_client.base import BaseAPIClient -from notifications_utils.clients.redis import RequestCache from app.extensions import redis_client +from notifications_utils.clients.redis import RequestCache cache = RequestCache(redis_client) diff --git a/app/s3_client/s3_csv_client.py b/app/s3_client/s3_csv_client.py index d3be10b94..21c329887 100644 --- a/app/s3_client/s3_csv_client.py +++ b/app/s3_client/s3_csv_client.py @@ -1,7 +1,6 @@ import uuid from flask import current_app -from notifications_utils.s3 import s3upload as utils_s3upload from app.s3_client import ( get_s3_contents, @@ -9,6 +8,7 @@ from app.s3_client import ( get_s3_object, set_s3_metadata, ) +from notifications_utils.s3 import s3upload as utils_s3upload FILE_LOCATION_STRUCTURE = "service-{}-notify/{}.csv" diff --git a/app/s3_client/s3_logo_client.py b/app/s3_client/s3_logo_client.py index e27218fef..5a8453ad1 100644 --- a/app/s3_client/s3_logo_client.py +++ b/app/s3_client/s3_logo_client.py @@ -2,9 +2,9 @@ import uuid from boto3 import Session from flask import current_app -from notifications_utils.s3 import s3upload as utils_s3upload from app.s3_client import get_s3_object +from notifications_utils.s3 import s3upload as utils_s3upload TEMP_TAG = "temp-{user_id}_" EMAIL_LOGO_LOCATION_STRUCTURE = "{temp}{unique_id}-{filename}" diff --git a/app/utils/__init__.py b/app/utils/__init__.py index cb0da0723..6e9c8aa88 100644 --- a/app/utils/__init__.py +++ b/app/utils/__init__.py @@ -3,11 +3,12 @@ from itertools import chain from flask import abort, g, make_response, request from flask_login import current_user -from notifications_utils.field import Field from ordered_set import OrderedSet from werkzeug.datastructures import MultiDict from werkzeug.routing import RequestRedirect +from notifications_utils.field import Field + SENDING_STATUSES = ["created", "pending", "sending"] DELIVERED_STATUSES = ["delivered", "sent"] FAILURE_STATUSES = [ diff --git a/app/utils/csv.py b/app/utils/csv.py index 04d5d19ed..69a3c3ef8 100644 --- a/app/utils/csv.py +++ b/app/utils/csv.py @@ -3,10 +3,10 @@ import datetime import pytz from flask import current_app from flask_login import current_user -from notifications_utils.recipients import RecipientCSV from app.models.spreadsheet import Spreadsheet from app.utils.templates import get_sample_template +from notifications_utils.recipients import RecipientCSV def get_errors_for_csv(recipients, template_type): diff --git a/notifications_utils/__init__.py b/notifications_utils/__init__.py new file mode 100644 index 000000000..84a55d644 --- /dev/null +++ b/notifications_utils/__init__.py @@ -0,0 +1,25 @@ +import re + +SMS_CHAR_COUNT_LIMIT = 918 # 153 * 6, no network issues but check with providers before upping this further +LETTER_MAX_PAGE_COUNT = 10 +DAILY_MESSAGE_LIMIT = 10000 + +# regexes for use in recipients.validate_email_address. +# Valid characters taken from https://en.wikipedia.org/wiki/Email_address#Local-part +# Note: Normal apostrophe eg `Firstname-o'surname@domain.com` is allowed. +# hostname_part regex: xn in regex signifies possible punycode conversions, which would start `xn--`; +# the hyphens are matched for later in the regex. +hostname_part = re.compile(r"^(xn|[a-z0-9]+)(-?-[a-z0-9]+)*$", re.IGNORECASE) +tld_part = re.compile(r"^([a-z]{2,63}|xn--([a-z0-9]+-)*[a-z0-9]+)$", re.IGNORECASE) +VALID_LOCAL_CHARS = r"a-zA-Z0-9.!#$%&'*+/=?^_`{|}~\-" +EMAIL_REGEX_PATTERN = r"^[{}]+@([^.@][^@\s]+)$".format(VALID_LOCAL_CHARS) +email_with_smart_quotes_regex = re.compile( + # matches wider than an email - everything between an at sign and the nearest whitespace + r"(^|\s)\S+@\S+(\s|$)", + flags=re.MULTILINE, +) + +# The magic sequence is a ‘unique’ series of characters which we temporarily insert +# and then later remove when performing tricky formatting operations +MAGIC_SEQUENCE = "🇬🇧🐦✉️" +magic_sequence_regex = re.compile(MAGIC_SEQUENCE) diff --git a/notifications_utils/base64_uuid.py b/notifications_utils/base64_uuid.py new file mode 100644 index 000000000..9721bf2ec --- /dev/null +++ b/notifications_utils/base64_uuid.py @@ -0,0 +1,22 @@ +from base64 import urlsafe_b64decode, urlsafe_b64encode +from uuid import UUID + + +def base64_to_bytes(key): + return urlsafe_b64decode(key + "==") + + +def bytes_to_base64(bytes): + # remove trailing = to save precious bytes + return urlsafe_b64encode(bytes).decode("ascii").rstrip("=") + + +def base64_to_uuid(value): + # uuids are 16 bytes, and will always have two ==s of padding + return UUID(bytes=urlsafe_b64decode(value.encode("ascii") + b"==")) + + +def uuid_to_base64(value): + if not isinstance(value, UUID): + value = UUID(value) + return bytes_to_base64(value.bytes) diff --git a/notifications_utils/clients/__init__.py b/notifications_utils/clients/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/notifications_utils/clients/antivirus/__init__.py b/notifications_utils/clients/antivirus/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/notifications_utils/clients/antivirus/antivirus_client.py b/notifications_utils/clients/antivirus/antivirus_client.py new file mode 100644 index 000000000..affe8f27e --- /dev/null +++ b/notifications_utils/clients/antivirus/antivirus_client.py @@ -0,0 +1,55 @@ +import requests +from flask import current_app + + +class AntivirusError(Exception): + def __init__(self, message=None, status_code=None): + self.message = message + self.status_code = status_code + + @classmethod + def from_exception(cls, e): + try: + message = e.response.json()["error"] + status_code = e.response.status_code + except (TypeError, ValueError, AttributeError, KeyError): + message = "connection error" + status_code = 503 + + return cls(message, status_code) + + +class AntivirusClient: + def __init__(self, api_host=None, auth_token=None): + self.api_host = api_host + self.auth_token = auth_token + + def init_app(self, app): + self.api_host = app.config["ANTIVIRUS_API_HOST"] + self.auth_token = app.config["ANTIVIRUS_API_KEY"] + + def scan(self, document_stream): + try: + response = requests.post( + "{}/scan".format(self.api_host), + headers={ + "Authorization": "Bearer {}".format(self.auth_token), + }, + files={"document": document_stream}, + ) + + response.raise_for_status() + + except requests.RequestException as e: + error = AntivirusError.from_exception(e) + current_app.logger.warning( + "Notify Antivirus API request failed with error: {}".format( + error.message + ) + ) + + raise error + finally: + document_stream.seek(0) + + return response.json()["ok"] diff --git a/notifications_utils/clients/encryption/__init__.py b/notifications_utils/clients/encryption/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/notifications_utils/clients/encryption/encryption_client.py b/notifications_utils/clients/encryption/encryption_client.py new file mode 100644 index 000000000..cf5283208 --- /dev/null +++ b/notifications_utils/clients/encryption/encryption_client.py @@ -0,0 +1,86 @@ +from base64 import urlsafe_b64encode +from json import dumps, loads + +from cryptography.fernet import Fernet, InvalidToken +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC +from itsdangerous import BadSignature, URLSafeSerializer + + +class EncryptionError(Exception): + pass + + +class SaltLengthError(Exception): + pass + + +class Encryption: + def init_app(self, app): + self._serializer = URLSafeSerializer(app.config.get("SECRET_KEY")) + self._salt = app.config.get("DANGEROUS_SALT") + self._password = app.config.get("SECRET_KEY").encode() + + try: + self._shared_encryptor = Fernet(self._derive_key(self._salt)) + except SaltLengthError as reason: + raise EncryptionError( + "DANGEROUS_SALT must be at least 16 bytes" + ) from reason + + def encrypt(self, thing_to_encrypt, salt=None): + """Encrypt a string or object + + thing_to_encrypt must be serializable as JSON + Returns a UTF-8 string + """ + serialized_bytes = dumps(thing_to_encrypt).encode("utf-8") + encrypted_bytes = self._encryptor(salt).encrypt(serialized_bytes) + return encrypted_bytes.decode("utf-8") + + def decrypt(self, thing_to_decrypt, salt=None): + """Decrypt a UTF-8 string or bytes. + + Once decrypted, thing_to_decrypt must be deserializable from JSON. + """ + try: + return loads(self._encryptor(salt).decrypt(thing_to_decrypt)) + except InvalidToken as reason: + raise EncryptionError from reason + + def sign(self, thing_to_sign, salt=None): + return self._serializer.dumps(thing_to_sign, salt=(salt or self._salt)) + + def verify_signature(self, thing_to_verify, salt=None): + try: + return self._serializer.loads(thing_to_verify, salt=(salt or self._salt)) + except BadSignature as reason: + raise EncryptionError from reason + + def _encryptor(self, salt=None): + if salt is None: + return self._shared_encryptor + else: + try: + return Fernet(self._derive_key(salt)) + except SaltLengthError as reason: + raise EncryptionError( + "Custom salt value must be at least 16 bytes" + ) from reason + + def _derive_key(self, salt): + """Derive a key suitable for use within Fernet from the SECRET_KEY and salt + + * For the salt to be secure, it must be 16 bytes or longer and randomly generated. + * 600_000 was chosen for the iterations because it is what OWASP recommends as + * of [February 2023](https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html#pbkdf2) + * For more information, see https://cryptography.io/en/latest/hazmat/primitives/key-derivation-functions/#pbkdf2 + * and https://cryptography.io/en/latest/fernet/#using-passwords-with-fernet + """ + salt_bytes = salt.encode() + if len(salt_bytes) < 16: + raise SaltLengthError + kdf = PBKDF2HMAC( + algorithm=hashes.SHA256(), length=32, salt=salt_bytes, iterations=600_000 + ) + return urlsafe_b64encode(kdf.derive(self._password)) diff --git a/notifications_utils/clients/redis/__init__.py b/notifications_utils/clients/redis/__init__.py new file mode 100644 index 000000000..93a77d561 --- /dev/null +++ b/notifications_utils/clients/redis/__init__.py @@ -0,0 +1,13 @@ +from datetime import datetime + +from .request_cache import RequestCache # noqa: F401 (unused import) + + +def total_limit_cache_key(service_id): + return "{}-{}-{}".format( + str(service_id), datetime.utcnow().strftime("%Y-%m-%d"), "total-count" + ) + + +def rate_limit_cache_key(service_id, api_key_type): + return "{}-{}".format(str(service_id), api_key_type) diff --git a/notifications_utils/clients/redis/redis_client.py b/notifications_utils/clients/redis/redis_client.py new file mode 100644 index 000000000..6e81d85bf --- /dev/null +++ b/notifications_utils/clients/redis/redis_client.py @@ -0,0 +1,184 @@ +import numbers +import uuid +from time import time + +from flask import current_app +from flask_redis import FlaskRedis + + +def prepare_value(val): + """ + Only bytes, strings and numbers (ints, longs and floats) are acceptable + for keys and values. Previously redis-py attempted to cast other types + to str() and store the result. This caused must confusion and frustration + when passing boolean values (cast to 'True' and 'False') or None values + (cast to 'None'). It is now the user's responsibility to cast all + key names and values to bytes, strings or numbers before passing the + value to redis-py. + """ + # things redis-py natively supports + if isinstance( + val, + ( + bytes, + str, + numbers.Number, + ), + ): + return val + # things we know we can safely cast to string + elif isinstance(val, (uuid.UUID,)): + return str(val) + else: + raise ValueError("cannot cast {} to a string".format(type(val))) + + +class RedisClient: + redis_store = FlaskRedis() + active = False + scripts = {} + + def init_app(self, app): + self.active = app.config.get("REDIS_ENABLED") + if self.active: + self.redis_store.init_app(app) + + self.register_scripts() + + def register_scripts(self): + # delete keys matching a pattern supplied as a parameter. Does so in batches of 5000 to prevent unpack from + # exceeding lua's stack limit, and also to prevent errors if no keys match the pattern. + # Inspired by https://gist.github.com/ddre54/0a4751676272e0da8186 + self.scripts["delete-keys-by-pattern"] = self.redis_store.register_script( + """ + local keys = redis.call('keys', ARGV[1]) + local deleted = 0 + for i=1, #keys, 5000 do + deleted = deleted + redis.call('del', unpack(keys, i, math.min(i + 4999, #keys))) + end + return deleted + """ + ) + + def delete_by_pattern(self, pattern, raise_exception=False): + r""" + Deletes all keys matching a given pattern, and returns how many keys were deleted. + Pattern is defined as in the KEYS command: https://redis.io/commands/keys + + * h?llo matches hello, hallo and hxllo + * h*llo matches hllo and heeeello + * h[ae]llo matches hello and hallo, but not hillo + * h[^e]llo matches hallo, hbllo, ... but not hello + * h[a-b]llo matches hallo and hbllo + + Use \ to escape special characters if you want to match them verbatim + """ + if self.active: + try: + return self.scripts["delete-keys-by-pattern"](args=[pattern]) + except Exception as e: + self.__handle_exception( + e, raise_exception, "delete-by-pattern", pattern + ) + + return 0 + + def exceeded_rate_limit(self, cache_key, limit, interval, raise_exception=False): + """ + Rate limiting. + - Uses Redis sorted sets + - Also uses redis "multi" which is abstracted into pipeline() by FlaskRedis/PyRedis + - Sends all commands to redis as a group to be executed atomically + + Method: + (1) Add event, scored by timestamp (zadd). The score determines order in set. + (2) Use zremrangebyscore to delete all set members with a score between + - Earliest entry (lowest score == earliest timestamp) - represented as '-inf' + and + - Current timestamp minus the interval + - Leaves only relevant entries in the set (those between now and now - interval) + (3) Count the set + (4) If count > limit fail request + (5) Ensure we expire the set key to preserve space + + Notes: + - Failed requests count. If over the limit and keep making requests you'll stay over the limit. + - The actual value in the set is just the timestamp, the same as the score. We don't store any requets details. + - return value of pipe.execute() is an array containing the outcome of each call. + - result[2] == outcome of pipe.zcard() + - If redis is inactive, or we get an exception, allow the request + + :param cache_key: + :param limit: Number of requests permitted within interval + :param interval: Interval we measure requests in + :param raise_exception: Should throw exception + :return: + """ + cache_key = prepare_value(cache_key) + if self.active: + try: + pipe = self.redis_store.pipeline() + when = time() + pipe.zadd(cache_key, {when: when}) + pipe.zremrangebyscore(cache_key, "-inf", when - interval) + pipe.zcard(cache_key) + pipe.expire(cache_key, interval) + result = pipe.execute() + return result[2] > limit + except Exception as e: + self.__handle_exception( + e, raise_exception, "rate-limit-pipeline", cache_key + ) + return False + else: + return False + + def raw_set(self, key, value, ex=None, px=None, nx=False, xx=False): + self.redis_store.set(key, value, ex, px, nx, xx) + + def set( + self, key, value, ex=None, px=None, nx=False, xx=False, raise_exception=False + ): + key = prepare_value(key) + value = prepare_value(value) + if self.active: + try: + self.redis_store.set(key, value, ex, px, nx, xx) + except Exception as e: + self.__handle_exception(e, raise_exception, "set", key) + + def incr(self, key, raise_exception=False): + key = prepare_value(key) + if self.active: + try: + return self.redis_store.incr(key) + except Exception as e: + self.__handle_exception(e, raise_exception, "incr", key) + + def raw_get(self, key): + return self.redis_store.get(key) + + def get(self, key, raise_exception=False): + key = prepare_value(key) + if self.active: + try: + return self.redis_store.get(key) + except Exception as e: + self.__handle_exception(e, raise_exception, "get", key) + + return None + + def delete(self, *keys, raise_exception=False): + keys = [prepare_value(k) for k in keys] + if self.active: + try: + self.redis_store.delete(*keys) + except Exception as e: + self.__handle_exception(e, raise_exception, "delete", ", ".join(keys)) + + def __handle_exception(self, e, raise_exception, operation, key_name): + current_app.logger.exception( + "Redis error performing {} on {}".format(operation, key_name) + ) + if raise_exception: + raise e diff --git a/notifications_utils/clients/redis/request_cache.py b/notifications_utils/clients/redis/request_cache.py new file mode 100644 index 000000000..edb45c98e --- /dev/null +++ b/notifications_utils/clients/redis/request_cache.py @@ -0,0 +1,95 @@ +import json +from contextlib import suppress +from datetime import timedelta +from functools import wraps +from inspect import signature + + +class RequestCache: + DEFAULT_TTL = int(timedelta(days=7).total_seconds()) + + def __init__(self, redis_client): + self.redis_client = redis_client + + @staticmethod + def _get_argument(argument_name, client_method, args, kwargs): + with suppress(KeyError): + return kwargs[argument_name] + + with suppress(ValueError, IndexError): + argument_index = list(signature(client_method).parameters).index( + argument_name + ) + return args[argument_index] + + with suppress(KeyError): + return signature(client_method).parameters[argument_name].default + + raise TypeError( + "{}() takes no argument called '{}'".format( + client_method.__name__, argument_name + ) + ) + + @staticmethod + def _make_key(key_format, client_method, args, kwargs): + return key_format.format( + **{ + argument_name: RequestCache._get_argument( + argument_name, client_method, args, kwargs + ) + for argument_name in list(signature(client_method).parameters) + } + ) + + def set(self, key_format, *, ttl_in_seconds=DEFAULT_TTL): + def _set(client_method): + @wraps(client_method) + def new_client_method(*args, **kwargs): + redis_key = RequestCache._make_key( + key_format, client_method, args, kwargs + ) + cached = self.redis_client.get(redis_key) + if cached: + return json.loads(cached.decode("utf-8")) + api_response = client_method(*args, **kwargs) + self.redis_client.set( + redis_key, + json.dumps(api_response), + ex=int(ttl_in_seconds), + ) + return api_response + + return new_client_method + + return _set + + def delete(self, key_format): + def _delete(client_method): + @wraps(client_method) + def new_client_method(*args, **kwargs): + try: + api_response = client_method(*args, **kwargs) + finally: + redis_key = self._make_key(key_format, client_method, args, kwargs) + self.redis_client.delete(redis_key) + return api_response + + return new_client_method + + return _delete + + def delete_by_pattern(self, key_format): + def _delete(client_method): + @wraps(client_method) + def new_client_method(*args, **kwargs): + try: + api_response = client_method(*args, **kwargs) + finally: + redis_key = self._make_key(key_format, client_method, args, kwargs) + self.redis_client.delete_by_pattern(redis_key) + return api_response + + return new_client_method + + return _delete diff --git a/notifications_utils/clients/zendesk/__init__.py b/notifications_utils/clients/zendesk/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/notifications_utils/clients/zendesk/zendesk_client.py b/notifications_utils/clients/zendesk/zendesk_client.py new file mode 100644 index 000000000..c5c2c5d02 --- /dev/null +++ b/notifications_utils/clients/zendesk/zendesk_client.py @@ -0,0 +1,150 @@ +import requests +from flask import current_app + + +class ZendeskError(Exception): + def __init__(self, response): + self.response = response + + +class ZendeskClient: + # the account used to authenticate with. If no requester is provided, the ticket will come from this account. + NOTIFY_ZENDESK_EMAIL = "zd-api-notify@digital.cabinet-office.gov.uk" + + ZENDESK_TICKET_URL = "https://govuk.zendesk.com/api/v2/tickets.json" + + def __init__(self): + self.api_key = None + + def init_app(self, app, *args, **kwargs): + self.api_key = app.config.get("ZENDESK_API_KEY") + + def send_ticket_to_zendesk(self, ticket): + response = requests.post( + self.ZENDESK_TICKET_URL, + json=ticket.request_data, + auth=(f"{self.NOTIFY_ZENDESK_EMAIL}/token", self.api_key), + ) + + if response.status_code != 201: + current_app.logger.error( + f"Zendesk create ticket request failed with {response.status_code} '{response.json()}'" + ) + raise ZendeskError(response) + + ticket_id = response.json()["ticket"]["id"] + + current_app.logger.info(f"Zendesk create ticket {ticket_id} succeeded") + + +class NotifySupportTicket: + PRIORITY_URGENT = "urgent" + PRIORITY_HIGH = "high" + PRIORITY_NORMAL = "normal" + PRIORITY_LOW = "low" + + TAGS_P2 = "govuk_notify_support" + TAGS_P1 = "govuk_notify_emergency" + + TYPE_PROBLEM = "problem" + TYPE_INCIDENT = "incident" + TYPE_QUESTION = "question" + TYPE_TASK = "task" + + # Group: 3rd Line--Notify Support + NOTIFY_GROUP_ID = 360000036529 + # Organization: GDS + NOTIFY_ORG_ID = 21891972 + NOTIFY_TICKET_FORM_ID = 1900000284794 + + def __init__( + self, + subject, + message, + ticket_type, + p1=False, + user_name=None, + user_email=None, + requester_sees_message_content=True, + technical_ticket=False, + ticket_categories=None, + org_id=None, + org_type=None, + service_id=None, + email_ccs=None, + ): + self.subject = subject + self.message = message + self.ticket_type = ticket_type + self.p1 = p1 + self.user_name = user_name + self.user_email = user_email + self.requester_sees_message_content = requester_sees_message_content + self.technical_ticket = technical_ticket + self.ticket_categories = ticket_categories or [] + self.org_id = org_id + self.org_type = org_type + self.service_id = service_id + self.email_ccs = email_ccs + + @property + def request_data(self): + data = { + "ticket": { + "subject": self.subject, + "comment": { + "body": self.message, + "public": self.requester_sees_message_content, + }, + "group_id": self.NOTIFY_GROUP_ID, + "organization_id": self.NOTIFY_ORG_ID, + "ticket_form_id": self.NOTIFY_TICKET_FORM_ID, + "priority": self.PRIORITY_URGENT if self.p1 else self.PRIORITY_NORMAL, + "tags": [self.TAGS_P1 if self.p1 else self.TAGS_P2], + "type": self.ticket_type, + "custom_fields": self._get_custom_fields(), + } + } + + if self.email_ccs: + data["ticket"]["email_ccs"] = [ + {"user_email": email, "action": "put"} for email in self.email_ccs + ] + + # if no requester provided, then the call came from within Notify 👻 + if self.user_email: + data["ticket"]["requester"] = { + "email": self.user_email, + "name": self.user_name or "(no name supplied)", + } + + return data + + def _get_custom_fields(self): + technical_ticket_tag = ( + f'notify_ticket_type_{"" if self.technical_ticket else "non_"}technical' + ) + org_type_tag = f"notify_org_type_{self.org_type}" if self.org_type else None + + return [ + { + "id": "1900000744994", + "value": technical_ticket_tag, + }, # Notify Ticket type field + { + "id": "360022836500", + "value": self.ticket_categories, + }, # Notify Ticket category field + { + "id": "360022943959", + "value": self.org_id, + }, # Notify Organisation ID field + { + "id": "360022943979", + "value": org_type_tag, + }, # Notify Organisation type field + { + "id": "1900000745014", + "value": self.service_id, + }, # Notify Service ID field + ] diff --git a/notifications_utils/countries/__init__.py b/notifications_utils/countries/__init__.py new file mode 100644 index 000000000..ccd4a4e30 --- /dev/null +++ b/notifications_utils/countries/__init__.py @@ -0,0 +1,81 @@ +from functools import lru_cache + +from notifications_utils.insensitive_dict import InsensitiveDict +from notifications_utils.sanitise_text import SanitiseASCII + +from .data import ( + ADDITIONAL_SYNONYMS, + COUNTRIES_AND_TERRITORIES, + EUROPEAN_ISLANDS, + ROYAL_MAIL_EUROPEAN, + UK, + UK_ISLANDS, + WELSH_NAMES, + Postage, +) + + +class CountryMapping(InsensitiveDict): + @staticmethod + @lru_cache(maxsize=2048, typed=False) + def make_key(original_key): + original_key = original_key.replace("&", "and") + original_key = original_key.replace("+", "and") + + normalised = "".join( + character.lower() + for character in original_key + if character not in " _-'’,.()" + ) + + if "?" in SanitiseASCII.encode(normalised): + return normalised + + return SanitiseASCII.encode(normalised) + + def __contains__(self, key): + if any(c.isdigit() for c in key): + # A string with a digit can’t be a country and is probably a + # postcode, so let’s do a little optimisation, skip the + # expensive string manipulation to normalise the key and say + # that there’s no matching country + return False + return super().__contains__(key) + + def __getitem__(self, key): + for key_ in (key, f"the {key}", f"yr {key}", f"y {key}"): + if key_ in self: + return super().__getitem__(key_) + + raise CountryNotFoundError(f"Not a known country or territory ({key})") + + +countries = CountryMapping( + dict( + COUNTRIES_AND_TERRITORIES + + UK_ISLANDS + + EUROPEAN_ISLANDS + + WELSH_NAMES + + ADDITIONAL_SYNONYMS + ) +) + + +class Country: + def __init__(self, given_name): + self.canonical_name = countries[given_name] + + def __eq__(self, other): + return self.canonical_name == other.canonical_name + + @property + def postage_zone(self): + if self.canonical_name == UK: + return Postage.UK + if self.canonical_name in ROYAL_MAIL_EUROPEAN: + return Postage.EUROPE + return Postage.REST_OF_WORLD + + +class CountryNotFoundError(KeyError): + pass diff --git a/notifications_utils/countries/_data/ended-countries.json b/notifications_utils/countries/_data/ended-countries.json new file mode 100644 index 000000000..2d6011175 --- /dev/null +++ b/notifications_utils/countries/_data/ended-countries.json @@ -0,0 +1,6 @@ +{ + "Yugoslavia": null, + "USSR": null, + "East Germany": "Germany", + "Czechoslovakia": "Czechia" +} diff --git a/notifications_utils/countries/_data/europe.txt b/notifications_utils/countries/_data/europe.txt new file mode 100644 index 000000000..05e17e24f --- /dev/null +++ b/notifications_utils/countries/_data/europe.txt @@ -0,0 +1,62 @@ +Albania +Andorra +Armenia +Austria +Azerbaijan +Azores +Balearic Islands +Belarus +Belgium +Bosnia and Herzegovina +Bulgaria +Canary Islands +Corsica +Croatia +Cyprus +Czechia +Denmark +Estonia +Faroe Islands +Finland +France +Georgia +Germany +Gibraltar +Greece +Greenland +Hungary +Iceland +Ireland +Italy +Kazakhstan +Kosovo +Kyrgyzstan +Latvia +Liechtenstein +Lithuania +Luxembourg +North Macedonia +Madeira +Malta +Moldova +Monaco +Montenegro +Netherlands +Norway +Poland +Portugal +Romania +Russia +San Marino +Serbia +Slovakia +Slovenia +Spain +Sweden +Switzerland +Tajikistan +Turkey +Turkmenistan +Ukraine +Uzbekistan +Vatican City diff --git a/notifications_utils/countries/_data/european-islands.txt b/notifications_utils/countries/_data/european-islands.txt new file mode 100644 index 000000000..4899f327b --- /dev/null +++ b/notifications_utils/countries/_data/european-islands.txt @@ -0,0 +1,5 @@ +Azores +Balearic Islands +Canary Islands +Corsica +Madeira diff --git a/notifications_utils/countries/_data/location-autocomplete-graph.json b/notifications_utils/countries/_data/location-autocomplete-graph.json new file mode 100644 index 000000000..4313664d9 --- /dev/null +++ b/notifications_utils/countries/_data/location-autocomplete-graph.json @@ -0,0 +1,28607 @@ +{ + "country:AD": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Andorra" + } + }, + "country:AE": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "United Arab Emirates" + } + }, + "country:AF": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Afghanistan" + } + }, + "country:AG": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Antigua and Barbuda" + } + }, + "country:AL": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Albania" + } + }, + "country:AM": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Armenia" + } + }, + "country:AO": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Angola" + } + }, + "country:AR": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Argentina" + } + }, + "country:AT": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Austria" + } + }, + "country:AU": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Australia" + } + }, + "country:AZ": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Azerbaijan" + } + }, + "country:BA": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Bosnia and Herzegovina" + } + }, + "country:BB": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Barbados" + } + }, + "country:BD": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Bangladesh" + } + }, + "country:BE": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Belgium" + } + }, + "country:BF": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Burkina Faso" + } + }, + "country:BG": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Bulgaria" + } + }, + "country:BH": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Bahrain" + } + }, + "country:BI": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Burundi" + } + }, + "country:BJ": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Benin" + } + }, + "country:BN": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Brunei" + } + }, + "country:BO": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Bolivia" + } + }, + "country:BR": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Brazil" + } + }, + "country:BS": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Bahamas" + } + }, + "country:BT": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Bhutan" + } + }, + "country:BW": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Botswana" + } + }, + "country:BY": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Belarus" + } + }, + "country:BZ": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Belize" + } + }, + "country:CA": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Canada" + } + }, + "country:CD": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Congo (Democratic Republic)" + } + }, + "country:CF": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Central African Republic" + } + }, + "country:CG": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Congo" + } + }, + "country:CH": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Switzerland" + } + }, + "country:CI": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Ivory Coast" + } + }, + "country:CL": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Chile" + } + }, + "country:CM": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Cameroon" + } + }, + "country:CN": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "China" + } + }, + "country:CO": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Colombia" + } + }, + "country:CR": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Costa Rica" + } + }, + "country:CS": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Czechoslovakia" + } + }, + "country:CU": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Cuba" + } + }, + "country:CV": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Cape Verde" + } + }, + "country:CY": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Cyprus" + } + }, + "country:CZ": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Czechia" + } + }, + "country:DD": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "East Germany" + } + }, + "country:DE": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Germany" + } + }, + "country:DJ": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Djibouti" + } + }, + "country:DK": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Denmark" + } + }, + "country:DM": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Dominica" + } + }, + "country:DO": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Dominican Republic" + } + }, + "country:DZ": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Algeria" + } + }, + "country:EC": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Ecuador" + } + }, + "country:EE": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Estonia" + } + }, + "country:EG": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Egypt" + } + }, + "country:ER": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Eritrea" + } + }, + "country:ES": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Spain" + } + }, + "country:ET": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Ethiopia" + } + }, + "country:FI": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Finland" + } + }, + "country:FJ": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Fiji" + } + }, + "country:FM": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Micronesia" + } + }, + "country:FR": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "France" + } + }, + "country:GA": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Gabon" + } + }, + "country:GB": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "United Kingdom" + } + }, + "country:GD": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Grenada" + } + }, + "country:GE": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Georgia" + } + }, + "country:GH": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Ghana" + } + }, + "country:GM": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Gambia" + } + }, + "country:GN": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Guinea" + } + }, + "country:GQ": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Equatorial Guinea" + } + }, + "country:GR": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Greece" + } + }, + "country:GT": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Guatemala" + } + }, + "country:GW": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Guinea-Bissau" + } + }, + "country:GY": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Guyana" + } + }, + "country:HN": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Honduras" + } + }, + "country:HR": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Croatia" + } + }, + "country:HT": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Haiti" + } + }, + "country:HU": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Hungary" + } + }, + "country:ID": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Indonesia" + } + }, + "country:IE": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Ireland" + } + }, + "country:IL": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Israel" + } + }, + "country:IN": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "India" + } + }, + "country:IQ": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Iraq" + } + }, + "country:IR": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Iran" + } + }, + "country:IS": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Iceland" + } + }, + "country:IT": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Italy" + } + }, + "country:JM": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Jamaica" + } + }, + "country:JO": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Jordan" + } + }, + "country:JP": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Japan" + } + }, + "country:KE": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Kenya" + } + }, + "country:KG": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Kyrgyzstan" + } + }, + "country:KH": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Cambodia" + } + }, + "country:KI": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Kiribati" + } + }, + "country:KM": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Comoros" + } + }, + "country:KN": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "St Kitts and Nevis" + } + }, + "country:KP": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "North Korea" + } + }, + "country:KR": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "South Korea" + } + }, + "country:KW": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Kuwait" + } + }, + "country:KZ": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Kazakhstan" + } + }, + "country:LA": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Laos" + } + }, + "country:LB": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Lebanon" + } + }, + "country:LC": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "St Lucia" + } + }, + "country:LI": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Liechtenstein" + } + }, + "country:LK": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Sri Lanka" + } + }, + "country:LR": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Liberia" + } + }, + "country:LS": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Lesotho" + } + }, + "country:LT": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Lithuania" + } + }, + "country:LU": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Luxembourg" + } + }, + "country:LV": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Latvia" + } + }, + "country:LY": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Libya" + } + }, + "country:MA": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Morocco" + } + }, + "country:MC": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Monaco" + } + }, + "country:MD": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Moldova" + } + }, + "country:ME": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Montenegro" + } + }, + "country:MG": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Madagascar" + } + }, + "country:MH": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Marshall Islands" + } + }, + "country:MK": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "North Macedonia" + } + }, + "country:ML": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Mali" + } + }, + "country:MM": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Myanmar (Burma)" + } + }, + "country:MN": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Mongolia" + } + }, + "country:MR": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Mauritania" + } + }, + "country:MT": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Malta" + } + }, + "country:MU": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Mauritius" + } + }, + "country:MV": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Maldives" + } + }, + "country:MW": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Malawi" + } + }, + "country:MX": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Mexico" + } + }, + "country:MY": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Malaysia" + } + }, + "country:MZ": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Mozambique" + } + }, + "country:NA": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Namibia" + } + }, + "country:NE": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Niger" + } + }, + "country:NG": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Nigeria" + } + }, + "country:NI": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Nicaragua" + } + }, + "country:NL": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Netherlands" + } + }, + "country:NO": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Norway" + } + }, + "country:NP": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Nepal" + } + }, + "country:NR": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Nauru" + } + }, + "country:NZ": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "New Zealand" + } + }, + "country:OM": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Oman" + } + }, + "country:PA": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Panama" + } + }, + "country:PE": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Peru" + } + }, + "country:PG": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Papua New Guinea" + } + }, + "country:PH": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Philippines" + } + }, + "country:PK": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Pakistan" + } + }, + "country:PL": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Poland" + } + }, + "country:PT": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Portugal" + } + }, + "country:PW": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Palau" + } + }, + "country:PY": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Paraguay" + } + }, + "country:QA": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Qatar" + } + }, + "country:RO": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Romania" + } + }, + "country:RS": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Serbia" + } + }, + "country:RU": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Russia" + } + }, + "country:RW": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Rwanda" + } + }, + "country:SA": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Saudi Arabia" + } + }, + "country:SB": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Solomon Islands" + } + }, + "country:SC": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Seychelles" + } + }, + "country:SD": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Sudan" + } + }, + "country:SE": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Sweden" + } + }, + "country:SG": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Singapore" + } + }, + "country:SI": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Slovenia" + } + }, + "country:SK": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Slovakia" + } + }, + "country:SL": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Sierra Leone" + } + }, + "country:SM": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "San Marino" + } + }, + "country:SN": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Senegal" + } + }, + "country:SO": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Somalia" + } + }, + "country:SR": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Suriname" + } + }, + "country:SS": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "South Sudan" + } + }, + "country:ST": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Sao Tome and Principe" + } + }, + "country:SU": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "USSR" + } + }, + "country:SV": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "El Salvador" + } + }, + "country:SY": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Syria" + } + }, + "country:SZ": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Eswatini" + } + }, + "country:TD": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Chad" + } + }, + "country:TG": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Togo" + } + }, + "country:TH": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Thailand" + } + }, + "country:TJ": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Tajikistan" + } + }, + "country:TL": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "East Timor" + } + }, + "country:TM": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Turkmenistan" + } + }, + "country:TN": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Tunisia" + } + }, + "country:TO": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Tonga" + } + }, + "country:TR": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Turkey" + } + }, + "country:TT": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Trinidad and Tobago" + } + }, + "country:TV": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Tuvalu" + } + }, + "country:TZ": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Tanzania" + } + }, + "country:UA": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Ukraine" + } + }, + "country:UG": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Uganda" + } + }, + "country:US": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "United States" + } + }, + "country:UY": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Uruguay" + } + }, + "country:UZ": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Uzbekistan" + } + }, + "country:VA": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Vatican City" + } + }, + "country:VC": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "St Vincent" + } + }, + "country:VE": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Venezuela" + } + }, + "country:VN": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Vietnam" + } + }, + "country:VU": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Vanuatu" + } + }, + "country:WS": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Samoa" + } + }, + "country:XK": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Kosovo" + } + }, + "country:YE": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Yemen" + } + }, + "country:YU": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Yugoslavia" + } + }, + "country:ZA": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "South Africa" + } + }, + "country:ZM": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Zambia" + } + }, + "country:ZW": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Zimbabwe" + } + }, + "nym:AD": { + "edges": { + "from": [ + "country:AD" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "AD" + } + }, + "nym:AE": { + "edges": { + "from": [ + "country:AE" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "AE" + } + }, + "nym:AE-AJ": { + "edges": { + "from": [ + "territory:AE-AJ" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "AE-AJ" + } + }, + "nym:AE-AZ": { + "edges": { + "from": [ + "territory:AE-AZ" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "AE-AZ" + } + }, + "nym:AE-DU": { + "edges": { + "from": [ + "territory:AE-DU" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "AE-DU" + } + }, + "nym:AE-FU": { + "edges": { + "from": [ + "territory:AE-FU" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "AE-FU" + } + }, + "nym:AE-RK": { + "edges": { + "from": [ + "territory:AE-RK" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "AE-RK" + } + }, + "nym:AE-SH": { + "edges": { + "from": [ + "territory:AE-SH" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "AE-SH" + } + }, + "nym:AE-UQ": { + "edges": { + "from": [ + "territory:AE-UQ" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "AE-UQ" + } + }, + "nym:AF": { + "edges": { + "from": [ + "country:AF" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "AF" + } + }, + "nym:AG": { + "edges": { + "from": [ + "country:AG" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "AG" + } + }, + "nym:AI": { + "edges": { + "from": [ + "territory:AI" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "AI" + } + }, + "nym:AL": { + "edges": { + "from": [ + "country:AL" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "AL" + } + }, + "nym:AM": { + "edges": { + "from": [ + "country:AM" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "AM" + } + }, + "nym:AO": { + "edges": { + "from": [ + "country:AO" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "AO" + } + }, + "nym:AQ": { + "edges": { + "from": [ + "territory:AQ" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "AQ" + } + }, + "nym:AR": { + "edges": { + "from": [ + "country:AR" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "AR" + } + }, + "nym:AS": { + "edges": { + "from": [ + "territory:AS" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "AS" + } + }, + "nym:AT": { + "edges": { + "from": [ + "country:AT" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "AT" + } + }, + "nym:AU": { + "edges": { + "from": [ + "country:AU" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "AU" + } + }, + "nym:AW": { + "edges": { + "from": [ + "territory:AW" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "AW" + } + }, + "nym:AX": { + "edges": { + "from": [ + "territory:AX" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "AX" + } + }, + "nym:AZ": { + "edges": { + "from": [ + "country:AZ" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "AZ" + } + }, + "nym:Aaland": { + "edges": { + "from": [ + "territory:AX" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Aaland" + } + }, + "nym:Abyssinia": { + "edges": { + "from": [ + "country:ET" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Abyssinia" + } + }, + "nym:Aeroes": { + "edges": { + "from": [ + "territory:FO" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Aeroes" + } + }, + "nym:Afghanestan": { + "edges": { + "from": [ + "country:AF" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Afghanestan" + } + }, + "nym:Aforika Borwa": { + "edges": { + "from": [ + "country:ZA" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Aforika Borwa" + } + }, + "nym:Afrika Borwa": { + "edges": { + "from": [ + "country:ZA" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Afrika Borwa" + } + }, + "nym:Afrika Dzonga": { + "edges": { + "from": [ + "country:ZA" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Afrika Dzonga" + } + }, + "nym:Afrika-Borwa": { + "edges": { + "from": [ + "country:ZA" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Afrika-Borwa" + } + }, + "nym:Afurika Tshipembe": { + "edges": { + "from": [ + "country:ZA" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Afurika Tshipembe" + } + }, + "nym:Agawec": { + "edges": { + "from": [ + "country:MR" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Agawec" + } + }, + "nym:Ahvenanmaa": { + "edges": { + "from": [ + "territory:AX" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Ahvenanmaa" + } + }, + "nym:Al itihaad al islamiya": { + "edges": { + "from": [ + "country:SO" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Al itihaad al islamiya" + } + }, + "nym:Al-'Iraq": { + "edges": { + "from": [ + "country:IQ" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Al-'Iraq" + } + }, + "nym:Al-Baḥrayn": { + "edges": { + "from": [ + "country:BH" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Al-Baḥrayn" + } + }, + "nym:Al-Iraq": { + "edges": { + "from": [ + "country:IQ" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Al-Iraq" + } + }, + "nym:Al-Jazā'ir": { + "edges": { + "from": [ + "country:DZ" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Al-Jazā'ir" + } + }, + "nym:Al-Mamlaka Al-‘Arabiyyah as Sa‘ūdiyyah": { + "edges": { + "from": [ + "country:SA" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Al-Mamlaka Al-‘Arabiyyah as Sa‘ūdiyyah" + } + }, + "nym:Al-Yaman": { + "edges": { + "from": [ + "country:YE" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Al-Yaman" + } + }, + "nym:Al-itihaad al-islamiya": { + "edges": { + "from": [ + "country:SO" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Al-itihaad al-islamiya" + } + }, + "nym:Al-maɣréb": { + "edges": { + "from": [ + "country:MA" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Al-maɣréb" + } + }, + "nym:Al-’Imārat Al-‘Arabiyyah Al-Muttaḥidah": { + "edges": { + "from": [ + "country:AE" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Al-’Imārat Al-‘Arabiyyah Al-Muttaḥidah" + } + }, + "nym:Al-’Urdun": { + "edges": { + "from": [ + "country:JO" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Al-’Urdun" + } + }, + "nym:Aland": { + "edges": { + "from": [ + "territory:AX" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Aland" + } + }, + "nym:Ameeri": { + "edges": { + "from": [ + "country:US" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Ameeri" + } + }, + "nym:Ameica": { + "edges": { + "from": [ + "country:US" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Ameica" + } + }, + "nym:Amelika-hui-pu-'ia": { + "edges": { + "from": [ + "country:US" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Amelika-hui-pu-'ia" + } + }, + "nym:Amerca": { + "edges": { + "from": [ + "country:US" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Amerca" + } + }, + "nym:Amercia": { + "edges": { + "from": [ + "country:US" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Amercia" + } + }, + "nym:Ameria": { + "edges": { + "from": [ + "country:US" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Ameria" + } + }, + "nym:American Virgin Islands": { + "edges": { + "from": [ + "territory:VI" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "American Virgin Islands" + } + }, + "nym:Amerika Sāmoa": { + "edges": { + "from": [ + "country:WS" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Amerika Sāmoa" + } + }, + "nym:Amerruk": { + "edges": { + "from": [ + "country:MA" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Amerruk" + } + }, + "nym:Amrica": { + "edges": { + "from": [ + "country:US" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Amrica" + } + }, + "nym:An Rywvaneth Unys": { + "edges": { + "from": [ + "country:GB" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "An Rywvaneth Unys" + } + }, + "nym:Anguilla": { + "edges": { + "from": [ + "territory:AI" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Anguilla" + } + }, + "nym:Anmerica": { + "edges": { + "from": [ + "country:US" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Anmerica" + } + }, + "nym:Annam": { + "edges": { + "from": [ + "country:VN" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Annam" + } + }, + "nym:Antarctica": { + "edges": { + "from": [ + "territory:AQ" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Antarctica" + } + }, + "nym:Antigua and Barbuda": { + "edges": { + "from": [ + "country:AG" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Antigua and Barbuda" + } + }, + "nym:Aorōkin M̧ajeļ": { + "edges": { + "from": [ + "country:MH" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Aorōkin M̧ajeļ" + } + }, + "nym:Aotearoa": { + "edges": { + "from": [ + "country:NZ" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Aotearoa" + } + }, + "nym:Arab Republic of Egypt": { + "edges": { + "from": [ + "country:EG" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Arab Republic of Egypt" + } + }, + "nym:Argenina": { + "edges": { + "from": [ + "country:AR" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Argenina" + } + }, + "nym:Argentine Republic": { + "edges": { + "from": [ + "country:AR" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Argentine Republic" + } + }, + "nym:Argentinia": { + "edges": { + "from": [ + "country:AR" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Argentinia" + } + }, + "nym:Aruba": { + "edges": { + "from": [ + "territory:AW" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Aruba" + } + }, + "nym:As-Sudan": { + "edges": { + "from": [ + "country:SD" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "As-Sudan" + } + }, + "nym:Ascension Island": { + "edges": { + "from": [ + "territory:SH-AC" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Ascension Island" + } + }, + "nym:Ayiti": { + "edges": { + "from": [ + "country:HT" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Ayiti" + } + }, + "nym:Azerbaijani Republic": { + "edges": { + "from": [ + "country:AZ" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Azerbaijani Republic" + } + }, + "nym:Azerbajdzhan": { + "edges": { + "from": [ + "country:AZ" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Azerbajdzhan" + } + }, + "nym:Azerbajdzhan Republic": { + "edges": { + "from": [ + "country:AZ" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Azerbajdzhan Republic" + } + }, + "nym:Azərbaycan": { + "edges": { + "from": [ + "country:AZ" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Azərbaycan" + } + }, + "nym:B.V.I.": { + "edges": { + "from": [ + "territory:VG" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "B.V.I." + } + }, + "nym:BA": { + "edges": { + "from": [ + "country:BA" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "BA" + } + }, + "nym:BAT": { + "edges": { + "from": [ + "territory:BAT" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "BAT" + } + }, + "nym:BB": { + "edges": { + "from": [ + "country:BB" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "BB" + } + }, + "nym:BD": { + "edges": { + "from": [ + "country:BD" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "BD" + } + }, + "nym:BE": { + "edges": { + "from": [ + "country:BE" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "BE" + } + }, + "nym:BF": { + "edges": { + "from": [ + "country:BF" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "BF" + } + }, + "nym:BG": { + "edges": { + "from": [ + "country:BG" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "BG" + } + }, + "nym:BH": { + "edges": { + "from": [ + "country:BH" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "BH" + } + }, + "nym:BI": { + "edges": { + "from": [ + "country:BI" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "BI" + } + }, + "nym:BJ": { + "edges": { + "from": [ + "country:BJ" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "BJ" + } + }, + "nym:BL": { + "edges": { + "from": [ + "territory:BL" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "BL" + } + }, + "nym:BM": { + "edges": { + "from": [ + "territory:BM" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "BM" + } + }, + "nym:BN": { + "edges": { + "from": [ + "country:BN" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "BN" + } + }, + "nym:BO": { + "edges": { + "from": [ + "country:BO" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "BO" + } + }, + "nym:BQ-BO": { + "edges": { + "from": [ + "territory:BQ-BO" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "BQ-BO" + } + }, + "nym:BQ-SA": { + "edges": { + "from": [ + "territory:BQ-SA" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "BQ-SA" + } + }, + "nym:BQ-SE": { + "edges": { + "from": [ + "territory:BQ-SE" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "BQ-SE" + } + }, + "nym:BR": { + "edges": { + "from": [ + "country:BR" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "BR" + } + }, + "nym:BS": { + "edges": { + "from": [ + "country:BS" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "BS" + } + }, + "nym:BT": { + "edges": { + "from": [ + "country:BT" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "BT" + } + }, + "nym:BV": { + "edges": { + "from": [ + "territory:BV" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "BV" + } + }, + "nym:BVI": { + "edges": { + "from": [ + "territory:VG" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "BVI" + } + }, + "nym:BW": { + "edges": { + "from": [ + "country:BW" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "BW" + } + }, + "nym:BY": { + "edges": { + "from": [ + "country:BY" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "BY" + } + }, + "nym:BZ": { + "edges": { + "from": [ + "country:BZ" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "BZ" + } + }, + "nym:Bailiwick of Guernsey": { + "edges": { + "from": [ + "territory:GG" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Bailiwick of Guernsey" + } + }, + "nym:Bailiwick of Jersey": { + "edges": { + "from": [ + "territory:JE" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Bailiwick of Jersey" + } + }, + "nym:Baker Island": { + "edges": { + "from": [ + "territory:UM-81" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Baker Island" + } + }, + "nym:Bangla Desh": { + "edges": { + "from": [ + "country:BD" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Bangla Desh" + } + }, + "nym:Barbados": { + "edges": { + "from": [ + "country:BB" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Barbados" + } + }, + "nym:Basutoland": { + "edges": { + "from": [ + "country:LS" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Basutoland" + } + }, + "nym:Belarus": { + "edges": { + "from": [ + "country:BY" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Belarus" + } + }, + "nym:Belau": { + "edges": { + "from": [ + "country:PW" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Belau" + } + }, + "nym:Belgie": { + "edges": { + "from": [ + "country:BE" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Belgie" + } + }, + "nym:Belgien": { + "edges": { + "from": [ + "country:BE" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Belgien" + } + }, + "nym:Belgique": { + "edges": { + "from": [ + "country:BE" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Belgique" + } + }, + "nym:België": { + "edges": { + "from": [ + "country:BE" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "België" + } + }, + "nym:Belguim": { + "edges": { + "from": [ + "country:BE" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Belguim" + } + }, + "nym:Belize": { + "edges": { + "from": [ + "country:BZ" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Belize" + } + }, + "nym:Bermuda": { + "edges": { + "from": [ + "territory:BM" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Bermuda" + } + }, + "nym:Bermudas": { + "edges": { + "from": [ + "territory:BM" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Bermudas" + } + }, + "nym:Bharat": { + "edges": { + "from": [ + "country:IN" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Bharat" + } + }, + "nym:Bharôt": { + "edges": { + "from": [ + "country:IN" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Bharôt" + } + }, + "nym:Bharôtô": { + "edges": { + "from": [ + "country:IN" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Bharôtô" + } + }, + "nym:Bhārat": { + "edges": { + "from": [ + "country:IN" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Bhārat" + } + }, + "nym:Bhārata": { + "edges": { + "from": [ + "country:IN" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Bhārata" + } + }, + "nym:Bhāratadēsam": { + "edges": { + "from": [ + "country:IN" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Bhāratadēsam" + } + }, + "nym:Bhāratam": { + "edges": { + "from": [ + "country:IN" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Bhāratam" + } + }, + "nym:BiH": { + "edges": { + "from": [ + "country:BA" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "BiH" + } + }, + "nym:Bielaruś": { + "edges": { + "from": [ + "country:BY" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Bielaruś" + } + }, + "nym:Bitain": { + "edges": { + "from": [ + "country:GB" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Bitain" + } + }, + "nym:Bolivarian Republic of Venezuela": { + "edges": { + "from": [ + "country:VE" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Bolivarian Republic of Venezuela" + } + }, + "nym:Bolivia": { + "edges": { + "from": [ + "country:BO" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Bolivia" + } + }, + "nym:Bonaire": { + "edges": { + "from": [ + "territory:BQ-BO" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Bonaire" + } + }, + "nym:Bosna i Hercegovina": { + "edges": { + "from": [ + "country:BA" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Bosna i Hercegovina" + } + }, + "nym:Bosnia and Herzegovina": { + "edges": { + "from": [ + "country:BA" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Bosnia and Herzegovina" + } + }, + "nym:Bosnia-Herzegovina": { + "edges": { + "from": [ + "country:BA" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Bosnia-Herzegovina" + } + }, + "nym:Bouvet Island": { + "edges": { + "from": [ + "territory:BV" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Bouvet Island" + } + }, + "nym:Brasil": { + "edges": { + "from": [ + "country:BR" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Brasil" + } + }, + "nym:Brazzaville": { + "edges": { + "from": [ + "country:CG" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Brazzaville" + } + }, + "nym:Briain": { + "edges": { + "from": [ + "country:GB" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Briain" + } + }, + "nym:Britain": { + "edges": { + "from": [ + "country:GB" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Britain" + } + }, + "nym:Britiain": { + "edges": { + "from": [ + "country:GB" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Britiain" + } + }, + "nym:British Antarctic Territory": { + "edges": { + "from": [ + "territory:BAT" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "British Antarctic Territory" + } + }, + "nym:British Guiana": { + "edges": { + "from": [ + "country:GY" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "British Guiana" + } + }, + "nym:British Honduras": { + "edges": { + "from": [ + "country:BZ" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "British Honduras" + } + }, + "nym:Brtain": { + "edges": { + "from": [ + "country:GB" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Brtain" + } + }, + "nym:Brunei Darussalam": { + "edges": { + "from": [ + "country:BN" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Brunei Darussalam" + } + }, + "nym:Bugaria": { + "edges": { + "from": [ + "country:BG" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Bugaria" + } + }, + "nym:Bukchosŏn": { + "edges": { + "from": [ + "country:KP" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Bukchosŏn" + } + }, + "nym:Bulagar": { + "edges": { + "from": [ + "country:BG" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Bulagar" + } + }, + "nym:Bulgariya": { + "edges": { + "from": [ + "country:BG" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Bulgariya" + } + }, + "nym:Buliwya": { + "edges": { + "from": [ + "country:BO" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Buliwya" + } + }, + "nym:Bundesrepublik": { + "edges": { + "from": [ + "country:DE" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Bundesrepublik" + } + }, + "nym:Burkina Faso": { + "edges": { + "from": [ + "country:BF" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Burkina Faso" + } + }, + "nym:Burkina Fasoupper": { + "edges": { + "from": [ + "country:BF" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Burkina Fasoupper" + } + }, + "nym:Byelarus": { + "edges": { + "from": [ + "country:BY" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Byelarus" + } + }, + "nym:Byelorussia": { + "edges": { + "from": [ + "country:BY" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Byelorussia" + } + }, + "nym:Bārata": { + "edges": { + "from": [ + "country:IN" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Bārata" + } + }, + "nym:Bălgarija": { + "edges": { + "from": [ + "country:BG" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Bălgarija" + } + }, + "nym:CA": { + "edges": { + "from": [ + "country:CA" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "CA" + } + }, + "nym:CAR": { + "edges": { + "from": [ + "country:CF" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "CAR" + } + }, + "nym:CC": { + "edges": { + "from": [ + "territory:CC" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "CC" + } + }, + "nym:CD": { + "edges": { + "from": [ + "country:CD" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "CD" + } + }, + "nym:CF": { + "edges": { + "from": [ + "country:CF" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "CF" + } + }, + "nym:CG": { + "edges": { + "from": [ + "country:CG" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "CG" + } + }, + "nym:CH": { + "edges": { + "from": [ + "country:CH" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "CH" + } + }, + "nym:CI": { + "edges": { + "from": [ + "country:CI" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "CI" + } + }, + "nym:CK": { + "edges": { + "from": [ + "territory:CK" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "CK" + } + }, + "nym:CL": { + "edges": { + "from": [ + "country:CL" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "CL" + } + }, + "nym:CM": { + "edges": { + "from": [ + "country:CM" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "CM" + } + }, + "nym:CN": { + "edges": { + "from": [ + "country:CN" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "CN" + } + }, + "nym:CO": { + "edges": { + "from": [ + "country:CO" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "CO" + } + }, + "nym:CR": { + "edges": { + "from": [ + "country:CR" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "CR" + } + }, + "nym:CS": { + "edges": { + "from": [ + "country:CS" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "CS" + } + }, + "nym:CU": { + "edges": { + "from": [ + "country:CU" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "CU" + } + }, + "nym:CV": { + "edges": { + "from": [ + "country:CV" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "CV" + } + }, + "nym:CW": { + "edges": { + "from": [ + "territory:CW" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "CW" + } + }, + "nym:CX": { + "edges": { + "from": [ + "territory:CX" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "CX" + } + }, + "nym:CY": { + "edges": { + "from": [ + "country:CY" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "CY" + } + }, + "nym:CZ": { + "edges": { + "from": [ + "country:CZ" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "CZ" + } + }, + "nym:Cabo": { + "edges": { + "from": [ + "country:CV" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Cabo" + } + }, + "nym:Cabo Verde": { + "edges": { + "from": [ + "country:CV" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Cabo Verde" + } + }, + "nym:Cameroon": { + "edges": { + "from": [ + "country:CM" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Cameroon" + } + }, + "nym:Cameroun": { + "edges": { + "from": [ + "country:CM" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Cameroun" + } + }, + "nym:Canada": { + "edges": { + "from": [ + "country:CA" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Canada" + } + }, + "nym:Canadaigua": { + "edges": { + "from": [ + "country:CA" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Canadaigua" + } + }, + "nym:Candada": { + "edges": { + "from": [ + "country:CA" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Candada" + } + }, + "nym:Cathay": { + "edges": { + "from": [ + "country:CN" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Cathay" + } + }, + "nym:Cayman Islands": { + "edges": { + "from": [ + "territory:KY" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Cayman Islands" + } + }, + "nym:Central Africa": { + "edges": { + "from": [ + "country:CF" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Central Africa" + } + }, + "nym:Central African Republic": { + "edges": { + "from": [ + "country:CF" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Central African Republic" + } + }, + "nym:Ceska": { + "edges": { + "from": [ + "country:CZ" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Ceska" + } + }, + "nym:Ceuta": { + "edges": { + "from": [ + "territory:ES-CE" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Ceuta" + } + }, + "nym:Ceylon": { + "edges": { + "from": [ + "country:LK" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Ceylon" + } + }, + "nym:Chinese Taipei": { + "edges": { + "from": [ + "territory:TW" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Chinese Taipei" + } + }, + "nym:Citta del Vaticano": { + "edges": { + "from": [ + "country:VA" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Citta del Vaticano" + } + }, + "nym:Co-operative Republic of Guyana": { + "edges": { + "from": [ + "country:GY" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Co-operative Republic of Guyana" + } + }, + "nym:Coasta Rica": { + "edges": { + "from": [ + "country:CR" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Coasta Rica" + } + }, + "nym:Collectivity of Saint Martin": { + "edges": { + "from": [ + "territory:MF" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Collectivity of Saint Martin" + } + }, + "nym:Commonwealth of Australia": { + "edges": { + "from": [ + "country:AU" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Commonwealth of Australia" + } + }, + "nym:Commonwealth of Bahamas": { + "edges": { + "from": [ + "country:BS" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Commonwealth of Bahamas" + } + }, + "nym:Commonwealth of Dominica": { + "edges": { + "from": [ + "country:DM" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Commonwealth of Dominica" + } + }, + "nym:Commonwealth of Puerto Rico": { + "edges": { + "from": [ + "territory:PR" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Commonwealth of Puerto Rico" + } + }, + "nym:Commonwealth of the Northern Mariana Islands": { + "edges": { + "from": [ + "territory:MP" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Commonwealth of the Northern Mariana Islands" + } + }, + "nym:Comores": { + "edges": { + "from": [ + "country:KM" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Comores" + } + }, + "nym:Congo-Brazzaville": { + "edges": { + "from": [ + "country:CD" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Congo-Brazzaville" + } + }, + "nym:Cook Islands": { + "edges": { + "from": [ + "territory:CK" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Cook Islands" + } + }, + "nym:Costa Rico": { + "edges": { + "from": [ + "country:CR" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Costa Rico" + } + }, + "nym:Cote D'Ivoire": { + "edges": { + "from": [ + "country:CI" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Cote D'Ivoire" + } + }, + "nym:Cote dIvoire": { + "edges": { + "from": [ + "country:CI" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Cote dIvoire" + } + }, + "nym:Country of Curaçao": { + "edges": { + "from": [ + "territory:CW" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Country of Curaçao" + } + }, + "nym:Crna Gora": { + "edges": { + "from": [ + "country:ME" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Crna Gora" + } + }, + "nym:Curacao": { + "edges": { + "from": [ + "territory:CW" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Curacao" + } + }, + "nym:Curaçao": { + "edges": { + "from": [ + "territory:CW" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Curaçao" + } + }, + "nym:Czech Republic": { + "edges": { + "from": [ + "country:CZ" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Czech Republic" + } + }, + "nym:Czechoslav": { + "edges": { + "from": [ + "country:CZ" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Czechoslav" + } + }, + "nym:Czechoslovak Republic": { + "edges": { + "from": [ + "country:CS" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Czechoslovak Republic" + } + }, + "nym:DD": { + "edges": { + "from": [ + "country:DD" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "DD" + } + }, + "nym:DE": { + "edges": { + "from": [ + "country:DE" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "DE" + } + }, + "nym:DJ": { + "edges": { + "from": [ + "country:DJ" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "DJ" + } + }, + "nym:DK": { + "edges": { + "from": [ + "country:DK" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "DK" + } + }, + "nym:DM": { + "edges": { + "from": [ + "country:DM" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "DM" + } + }, + "nym:DO": { + "edges": { + "from": [ + "country:DO" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "DO" + } + }, + "nym:DPRK": { + "edges": { + "from": [ + "country:KP" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "DPRK" + } + }, + "nym:DZ": { + "edges": { + "from": [ + "country:DZ" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "DZ" + } + }, + "nym:Dahomey": { + "edges": { + "from": [ + "country:BJ" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Dahomey" + } + }, + "nym:Danmark": { + "edges": { + "from": [ + "country:DK" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Danmark" + } + }, + "nym:Dawlat ul-Kuwayt": { + "edges": { + "from": [ + "country:KW" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Dawlat ul-Kuwayt" + } + }, + "nym:Democratic People's Republic of Koread": { + "edges": { + "from": [ + "country:KP" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Democratic People's Republic of Koread" + } + }, + "nym:Democratic Republic of Sao Tome and Principe": { + "edges": { + "from": [ + "country:ST" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Democratic Republic of Sao Tome and Principe" + } + }, + "nym:Democratic Republic of Timor-Lestetimor": { + "edges": { + "from": [ + "country:TL" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Democratic Republic of Timor-Lestetimor" + } + }, + "nym:Democratic Republic of the Congo": { + "edges": { + "from": [ + "country:CD" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Democratic Republic of the Congo" + } + }, + "nym:Democratic Socialist Republic of Sri Lanka": { + "edges": { + "from": [ + "country:LK" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Democratic Socialist Republic of Sri Lanka" + } + }, + "nym:Deutschland": { + "edges": { + "from": [ + "country:DE" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Deutschland" + } + }, + "nym:Dhivehi Raajje": { + "edges": { + "from": [ + "country:MV" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Dhivehi Raajje" + } + }, + "nym:Djibouti": { + "edges": { + "from": [ + "country:DJ" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Djibouti" + } + }, + "nym:Dominican Republic": { + "edges": { + "from": [ + "country:DO" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Dominican Republic" + } + }, + "nym:Dominique": { + "edges": { + "from": [ + "country:DM" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Dominique" + } + }, + "nym:Druk Yul": { + "edges": { + "from": [ + "country:BT" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Druk Yul" + } + }, + "nym:Ducie and Oeno Islands": { + "edges": { + "from": [ + "territory:PN" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Ducie and Oeno Islands" + } + }, + "nym:Dutch East Indies": { + "edges": { + "from": [ + "country:ID" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Dutch East Indies" + } + }, + "nym:Dzayer": { + "edges": { + "from": [ + "country:DZ" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Dzayer" + } + }, + "nym:E Civitate Vaticana": { + "edges": { + "from": [ + "country:VA" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "E Civitate Vaticana" + } + }, + "nym:EC": { + "edges": { + "from": [ + "country:EC" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "EC" + } + }, + "nym:EE": { + "edges": { + "from": [ + "country:EE" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "EE" + } + }, + "nym:EG": { + "edges": { + "from": [ + "country:EG" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "EG" + } + }, + "nym:EH": { + "edges": { + "from": [ + "territory:EH" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "EH" + } + }, + "nym:ENG": { + "edges": { + "from": [ + "uk:ENG" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "ENG" + } + }, + "nym:ER": { + "edges": { + "from": [ + "country:ER" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "ER" + } + }, + "nym:ES": { + "edges": { + "from": [ + "country:ES" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "ES" + } + }, + "nym:ES-CE": { + "edges": { + "from": [ + "territory:ES-CE" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "ES-CE" + } + }, + "nym:ES-ML": { + "edges": { + "from": [ + "territory:ES-ML" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "ES-ML" + } + }, + "nym:ET": { + "edges": { + "from": [ + "country:ET" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "ET" + } + }, + "nym:East Pakistan": { + "edges": { + "from": [ + "country:BD" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "East Pakistan" + } + }, + "nym:Eastern Samoa": { + "edges": { + "from": [ + "territory:AS" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Eastern Samoa" + } + }, + "nym:Eesti": { + "edges": { + "from": [ + "country:EE" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Eesti" + } + }, + "nym:Egpyt": { + "edges": { + "from": [ + "country:EG" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Egpyt" + } + }, + "nym:Egyot": { + "edges": { + "from": [ + "country:EG" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Egyot" + } + }, + "nym:Egyt": { + "edges": { + "from": [ + "country:EG" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Egyt" + } + }, + "nym:Eire": { + "edges": { + "from": [ + "country:IE" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Eire" + } + }, + "nym:Ellada": { + "edges": { + "from": [ + "country:GR" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Ellada" + } + }, + "nym:Ellan Vannin": { + "edges": { + "from": [ + "territory:IM" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Ellan Vannin" + } + }, + "nym:Ellas": { + "edges": { + "from": [ + "country:GR" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Ellas" + } + }, + "nym:Ellice Islands": { + "edges": { + "from": [ + "country:TV" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Ellice Islands" + } + }, + "nym:Elmeɣrib": { + "edges": { + "from": [ + "country:MA" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Elmeɣrib" + } + }, + "nym:Emirate of Abu Dhabi": { + "edges": { + "from": [ + "territory:AE-AZ" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Emirate of Abu Dhabi" + } + }, + "nym:Emirate of Ajman": { + "edges": { + "from": [ + "territory:AE-AJ" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Emirate of Ajman" + } + }, + "nym:Emirate of Dubai": { + "edges": { + "from": [ + "territory:AE-DU" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Emirate of Dubai" + } + }, + "nym:Emirate of Fujairah": { + "edges": { + "from": [ + "territory:AE-FU" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Emirate of Fujairah" + } + }, + "nym:Emirate of Ras al-Khaimah": { + "edges": { + "from": [ + "territory:AE-RK" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Emirate of Ras al-Khaimah" + } + }, + "nym:Emirate of Sharjah": { + "edges": { + "from": [ + "territory:AE-SH" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Emirate of Sharjah" + } + }, + "nym:Emirate of Umm al-Quwain": { + "edges": { + "from": [ + "territory:AE-UQ" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Emirate of Umm al-Quwain" + } + }, + "nym:England": { + "edges": { + "from": [ + "uk:ENG" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "England" + } + }, + "nym:Englsnd": { + "edges": { + "from": [ + "country:GB" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Englsnd" + } + }, + "nym:Ertra": { + "edges": { + "from": [ + "country:ER" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Ertra" + } + }, + "nym:Espainia": { + "edges": { + "from": [ + "country:ES" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Espainia" + } + }, + "nym:Espanha": { + "edges": { + "from": [ + "country:ES" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Espanha" + } + }, + "nym:Espanya": { + "edges": { + "from": [ + "country:ES" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Espanya" + } + }, + "nym:España": { + "edges": { + "from": [ + "country:ES" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "España" + } + }, + "nym:Estados Unidos": { + "edges": { + "from": [ + "country:US" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Estados Unidos" + } + }, + "nym:Esthonia": { + "edges": { + "from": [ + "country:EE" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Esthonia" + } + }, + "nym:Eswatini": { + "edges": { + "from": [ + "country:SZ" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Eswatini" + } + }, + "nym:Ethopi": { + "edges": { + "from": [ + "country:ET" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Ethopi" + } + }, + "nym:FI": { + "edges": { + "from": [ + "country:FI" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "FI" + } + }, + "nym:FJ": { + "edges": { + "from": [ + "country:FJ" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "FJ" + } + }, + "nym:FK": { + "edges": { + "from": [ + "territory:FK" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "FK" + } + }, + "nym:FM": { + "edges": { + "from": [ + "country:FM" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "FM" + } + }, + "nym:FO": { + "edges": { + "from": [ + "territory:FO" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "FO" + } + }, + "nym:FR": { + "edges": { + "from": [ + "country:FR" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "FR" + } + }, + "nym:FRG": { + "edges": { + "from": [ + "country:DE" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "FRG" + } + }, + "nym:Falkland Islands": { + "edges": { + "from": [ + "territory:FK" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Falkland Islands" + } + }, + "nym:Faroe Islands": { + "edges": { + "from": [ + "territory:FO" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Faroe Islands" + } + }, + "nym:Faroes": { + "edges": { + "from": [ + "territory:FO" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Faroes" + } + }, + "nym:Federal Democratic Republic of Ethiopia": { + "edges": { + "from": [ + "country:ET" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Federal Democratic Republic of Ethiopia" + } + }, + "nym:Federal Democratic Republic of Nepal": { + "edges": { + "from": [ + "country:NP" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Federal Democratic Republic of Nepal" + } + }, + "nym:Federal Islamic Republic of the Comoros": { + "edges": { + "from": [ + "country:KM" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Federal Islamic Republic of the Comoros" + } + }, + "nym:Federal Republic of Germany": { + "edges": { + "from": [ + "country:DE" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Federal Republic of Germany" + } + }, + "nym:Federal Republic of Nigeria": { + "edges": { + "from": [ + "country:NG" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Federal Republic of Nigeria" + } + }, + "nym:Federal Republic of Somalia": { + "edges": { + "from": [ + "country:SO" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Federal Republic of Somalia" + } + }, + "nym:Federal Republic of Somaliaaiai": { + "edges": { + "from": [ + "country:SO" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Federal Republic of Somaliaaiai" + } + }, + "nym:Federated States of Micronesia": { + "edges": { + "from": [ + "country:FM" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Federated States of Micronesia" + } + }, + "nym:Federation of Malaysia": { + "edges": { + "from": [ + "country:MY" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Federation of Malaysia" + } + }, + "nym:Federation of Saint Christopher and Nevis": { + "edges": { + "from": [ + "country:KN" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Federation of Saint Christopher and Nevis" + } + }, + "nym:Federative Republic of Brazil": { + "edges": { + "from": [ + "country:BR" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Federative Republic of Brazil" + } + }, + "nym:Fiji": { + "edges": { + "from": [ + "country:FJ" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Fiji" + } + }, + "nym:French Congo": { + "edges": { + "from": [ + "country:CG" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "French Congo" + } + }, + "nym:French Guiana": { + "edges": { + "from": [ + "territory:GF" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "French Guiana" + } + }, + "nym:French Guinea": { + "edges": { + "from": [ + "country:GN" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "French Guinea" + } + }, + "nym:French Oceania": { + "edges": { + "from": [ + "territory:PF" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "French Oceania" + } + }, + "nym:French Polynesia": { + "edges": { + "from": [ + "territory:PF" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "French Polynesia" + } + }, + "nym:French Republic": { + "edges": { + "from": [ + "country:FR" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "French Republic" + } + }, + "nym:French Southern Territories": { + "edges": { + "from": [ + "territory:TF" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "French Southern Territories" + } + }, + "nym:French Sudan": { + "edges": { + "from": [ + "country:ML" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "French Sudan" + } + }, + "nym:Friendly islands": { + "edges": { + "from": [ + "country:TO" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Friendly islands" + } + }, + "nym:Færøerne": { + "edges": { + "from": [ + "territory:FO" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Færøerne" + } + }, + "nym:Føroyar": { + "edges": { + "from": [ + "territory:FO" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Føroyar" + } + }, + "nym:GA": { + "edges": { + "from": [ + "country:GA" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "GA" + } + }, + "nym:GB": { + "edges": { + "from": [ + "country:GB" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "GB" + } + }, + "nym:GBN": { + "edges": { + "from": [ + "uk:GBN" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "GBN" + } + }, + "nym:GD": { + "edges": { + "from": [ + "country:GD" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "GD" + } + }, + "nym:GE": { + "edges": { + "from": [ + "country:GE" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "GE" + } + }, + "nym:GF": { + "edges": { + "from": [ + "territory:GF" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "GF" + } + }, + "nym:GG": { + "edges": { + "from": [ + "territory:GG" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "GG" + } + }, + "nym:GH": { + "edges": { + "from": [ + "country:GH" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "GH" + } + }, + "nym:GI": { + "edges": { + "from": [ + "territory:GI" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "GI" + } + }, + "nym:GL": { + "edges": { + "from": [ + "territory:GL" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "GL" + } + }, + "nym:GM": { + "edges": { + "from": [ + "country:GM" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "GM" + } + }, + "nym:GN": { + "edges": { + "from": [ + "country:GN" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "GN" + } + }, + "nym:GP": { + "edges": { + "from": [ + "territory:GP" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "GP" + } + }, + "nym:GQ": { + "edges": { + "from": [ + "country:GQ" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "GQ" + } + }, + "nym:GR": { + "edges": { + "from": [ + "country:GR" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "GR" + } + }, + "nym:GS": { + "edges": { + "from": [ + "territory:GS" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "GS" + } + }, + "nym:GT": { + "edges": { + "from": [ + "country:GT" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "GT" + } + }, + "nym:GU": { + "edges": { + "from": [ + "territory:GU" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "GU" + } + }, + "nym:GW": { + "edges": { + "from": [ + "country:GW" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "GW" + } + }, + "nym:GY": { + "edges": { + "from": [ + "country:GY" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "GY" + } + }, + "nym:Gabonese Republic": { + "edges": { + "from": [ + "country:GA" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Gabonese Republic" + } + }, + "nym:Gabun": { + "edges": { + "from": [ + "country:GA" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Gabun" + } + }, + "nym:Gabuuti": { + "edges": { + "from": [ + "country:DJ" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Gabuuti" + } + }, + "nym:Genus Argentina": { + "edges": { + "from": [ + "country:AR" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Genus Argentina" + } + }, + "nym:Georgia": { + "edges": { + "from": [ + "country:GE" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Georgia" + } + }, + "nym:Germany Democratic Republic": { + "edges": { + "from": [ + "country:DD" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Germany Democratic Republic" + } + }, + "nym:Gibraltar": { + "edges": { + "from": [ + "territory:GI" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Gibraltar" + } + }, + "nym:Gine": { + "edges": { + "from": [ + "country:GN" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Gine" + } + }, + "nym:Gold Coast": { + "edges": { + "from": [ + "country:GH" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Gold Coast" + } + }, + "nym:Grand Duchy of Luxembourg": { + "edges": { + "from": [ + "country:LU" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Grand Duchy of Luxembourg" + } + }, + "nym:Grat Britain": { + "edges": { + "from": [ + "country:GB" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Grat Britain" + } + }, + "nym:Great Britain": { + "edges": { + "from": [ + "uk:GBN" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Great Britain" + } + }, + "nym:Great Britan": { + "edges": { + "from": [ + "country:GB" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Great Britan" + } + }, + "nym:Greenland": { + "edges": { + "from": [ + "territory:GL" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Greenland" + } + }, + "nym:Grenada": { + "edges": { + "from": [ + "country:GD" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Grenada" + } + }, + "nym:Gret Britain": { + "edges": { + "from": [ + "country:GB" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Gret Britain" + } + }, + "nym:Gronland": { + "edges": { + "from": [ + "territory:GL" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Gronland" + } + }, + "nym:Grønland": { + "edges": { + "from": [ + "territory:GL" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Grønland" + } + }, + "nym:Guadeloupe": { + "edges": { + "from": [ + "territory:GP" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Guadeloupe" + } + }, + "nym:Guinea Ecuatorial": { + "edges": { + "from": [ + "country:GQ" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Guinea Ecuatorial" + } + }, + "nym:Guinée": { + "edges": { + "from": [ + "country:GN" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Guinée" + } + }, + "nym:Guyane": { + "edges": { + "from": [ + "territory:GF" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Guyane" + } + }, + "nym:Guåhån": { + "edges": { + "from": [ + "territory:GU" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Guåhån" + } + }, + "nym:HK": { + "edges": { + "from": [ + "territory:HK" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "HK" + } + }, + "nym:HM": { + "edges": { + "from": [ + "territory:HM" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "HM" + } + }, + "nym:HN": { + "edges": { + "from": [ + "country:HN" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "HN" + } + }, + "nym:HR": { + "edges": { + "from": [ + "country:HR" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "HR" + } + }, + "nym:HT": { + "edges": { + "from": [ + "country:HT" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "HT" + } + }, + "nym:HU": { + "edges": { + "from": [ + "country:HU" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "HU" + } + }, + "nym:Hanguk": { + "edges": { + "from": [ + "country:KR" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Hanguk" + } + }, + "nym:Hashemite Kingdom of Jordan": { + "edges": { + "from": [ + "country:JO" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Hashemite Kingdom of Jordan" + } + }, + "nym:Hayastan": { + "edges": { + "from": [ + "country:AM" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Hayastan" + } + }, + "nym:Hayastán": { + "edges": { + "from": [ + "country:AM" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Hayastán" + } + }, + "nym:Haïti": { + "edges": { + "from": [ + "country:HT" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Haïti" + } + }, + "nym:Hellas": { + "edges": { + "from": [ + "country:GR" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Hellas" + } + }, + "nym:Hellenic Republic": { + "edges": { + "from": [ + "country:GR" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Hellenic Republic" + } + }, + "nym:Henderson": { + "edges": { + "from": [ + "territory:PN" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Henderson" + } + }, + "nym:Heung Gong": { + "edges": { + "from": [ + "territory:HK" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Heung Gong" + } + }, + "nym:Hindustan": { + "edges": { + "from": [ + "country:IN" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Hindustan" + } + }, + "nym:Holland": { + "edges": { + "from": [ + "country:NL" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Holland" + } + }, + "nym:Holy See": { + "edges": { + "from": [ + "country:VA" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Holy See" + } + }, + "nym:Hong Kong Special Administrative Region": { + "edges": { + "from": [ + "territory:HK" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Hong Kong Special Administrative Region" + } + }, + "nym:Howland Island": { + "edges": { + "from": [ + "territory:UM-84" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Howland Island" + } + }, + "nym:Hrvatska": { + "edges": { + "from": [ + "country:HR" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Hrvatska" + } + }, + "nym:Hungary": { + "edges": { + "from": [ + "country:HU" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Hungary" + } + }, + "nym:I.O.T.": { + "edges": { + "from": [ + "territory:IO" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "I.O.T." + } + }, + "nym:ID": { + "edges": { + "from": [ + "country:ID" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "ID" + } + }, + "nym:IE": { + "edges": { + "from": [ + "country:IE" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "IE" + } + }, + "nym:IL": { + "edges": { + "from": [ + "country:IL" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "IL" + } + }, + "nym:IM": { + "edges": { + "from": [ + "territory:IM" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "IM" + } + }, + "nym:IN": { + "edges": { + "from": [ + "country:IN" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "IN" + } + }, + "nym:IO": { + "edges": { + "from": [ + "territory:IO" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "IO" + } + }, + "nym:IOT": { + "edges": { + "from": [ + "territory:IO" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "IOT" + } + }, + "nym:IQ": { + "edges": { + "from": [ + "country:IQ" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "IQ" + } + }, + "nym:IR": { + "edges": { + "from": [ + "country:IR" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "IR" + } + }, + "nym:IS": { + "edges": { + "from": [ + "country:IS" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "IS" + } + }, + "nym:IT": { + "edges": { + "from": [ + "country:IT" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "IT" + } + }, + "nym:Independent State of Papua New Guinea": { + "edges": { + "from": [ + "country:PG" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Independent State of Papua New Guinea" + } + }, + "nym:Independent State of Samoa": { + "edges": { + "from": [ + "country:WS" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Independent State of Samoa" + } + }, + "nym:Irak": { + "edges": { + "from": [ + "country:IQ" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Irak" + } + }, + "nym:Ireland": { + "edges": { + "from": [ + "country:IE" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Ireland" + } + }, + "nym:Irelend": { + "edges": { + "from": [ + "country:IE" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Irelend" + } + }, + "nym:Irish Republic": { + "edges": { + "from": [ + "country:IE" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Irish Republic" + } + }, + "nym:Iritriya": { + "edges": { + "from": [ + "country:ER" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Iritriya" + } + }, + "nym:Islamic Republic of Afghanistan": { + "edges": { + "from": [ + "country:AF" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Islamic Republic of Afghanistan" + } + }, + "nym:Islamic Republic of Gambia": { + "edges": { + "from": [ + "country:GM" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Islamic Republic of Gambia" + } + }, + "nym:Islamic Republic of Iran": { + "edges": { + "from": [ + "country:IR" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Islamic Republic of Iran" + } + }, + "nym:Islamic Republic of Mauritania": { + "edges": { + "from": [ + "country:MR" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Islamic Republic of Mauritania" + } + }, + "nym:Islamic Republic of Pakistan": { + "edges": { + "from": [ + "country:PK" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Islamic Republic of Pakistan" + } + }, + "nym:Island": { + "edges": { + "from": [ + "country:IS" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Island" + } + }, + "nym:Island of Guernsey": { + "edges": { + "from": [ + "territory:GG" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Island of Guernsey" + } + }, + "nym:Island of Jersey": { + "edges": { + "from": [ + "territory:JE" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Island of Jersey" + } + }, + "nym:Isle of Man": { + "edges": { + "from": [ + "territory:IM" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Isle of Man" + } + }, + "nym:Israʼiyl": { + "edges": { + "from": [ + "country:IL" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Israʼiyl" + } + }, + "nym:Isreal": { + "edges": { + "from": [ + "country:IL" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Isreal" + } + }, + "nym:Italia": { + "edges": { + "from": [ + "country:IT" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Italia" + } + }, + "nym:Italian Republic": { + "edges": { + "from": [ + "country:IT" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Italian Republic" + } + }, + "nym:Itlay": { + "edges": { + "from": [ + "country:IT" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Itlay" + } + }, + "nym:Ityop'ia": { + "edges": { + "from": [ + "country:ET" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Ityop'ia" + } + }, + "nym:JE": { + "edges": { + "from": [ + "territory:JE" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "JE" + } + }, + "nym:JM": { + "edges": { + "from": [ + "country:JM" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "JM" + } + }, + "nym:JO": { + "edges": { + "from": [ + "country:JO" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "JO" + } + }, + "nym:JP": { + "edges": { + "from": [ + "country:JP" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "JP" + } + }, + "nym:Jabuuti": { + "edges": { + "from": [ + "country:DJ" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Jabuuti" + } + }, + "nym:Jamaca": { + "edges": { + "from": [ + "country:JM" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Jamaca" + } + }, + "nym:Jamacia": { + "edges": { + "from": [ + "country:JM" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Jamacia" + } + }, + "nym:Jamahiriya": { + "edges": { + "from": [ + "country:LY" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Jamahiriya" + } + }, + "nym:Jamaica": { + "edges": { + "from": [ + "country:JM" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Jamaica" + } + }, + "nym:Japan": { + "edges": { + "from": [ + "country:JP" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Japan" + } + }, + "nym:Jarvis Island": { + "edges": { + "from": [ + "territory:UM-86" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Jarvis Island" + } + }, + "nym:Johnston Atoll": { + "edges": { + "from": [ + "territory:UM-67" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Johnston Atoll" + } + }, + "nym:Jugoslavija": { + "edges": { + "from": [ + "country:YU" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Jugoslavija" + } + }, + "nym:Juzur al-Qamar": { + "edges": { + "from": [ + "country:KM" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Juzur al-Qamar" + } + }, + "nym:Jèrri": { + "edges": { + "from": [ + "country:TV" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Jèrri" + } + }, + "nym:Jībūtī": { + "edges": { + "from": [ + "country:DJ" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Jībūtī" + } + }, + "nym:KE": { + "edges": { + "from": [ + "country:KE" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "KE" + } + }, + "nym:KG": { + "edges": { + "from": [ + "country:KG" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "KG" + } + }, + "nym:KH": { + "edges": { + "from": [ + "country:KH" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "KH" + } + }, + "nym:KI": { + "edges": { + "from": [ + "country:KI" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "KI" + } + }, + "nym:KM": { + "edges": { + "from": [ + "country:KM" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "KM" + } + }, + "nym:KN": { + "edges": { + "from": [ + "country:KN" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "KN" + } + }, + "nym:KP": { + "edges": { + "from": [ + "country:KP" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "KP" + } + }, + "nym:KR": { + "edges": { + "from": [ + "country:KR" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "KR" + } + }, + "nym:KW": { + "edges": { + "from": [ + "country:KW" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "KW" + } + }, + "nym:KY": { + "edges": { + "from": [ + "territory:KY" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "KY" + } + }, + "nym:KZ": { + "edges": { + "from": [ + "country:KZ" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "KZ" + } + }, + "nym:Kalaallit Nunaat": { + "edges": { + "from": [ + "territory:GL" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Kalaallit Nunaat" + } + }, + "nym:Kampuchea": { + "edges": { + "from": [ + "country:KH" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Kampuchea" + } + }, + "nym:Katar": { + "edges": { + "from": [ + "country:QA" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Katar" + } + }, + "nym:Kazakh": { + "edges": { + "from": [ + "country:KZ" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Kazakh" + } + }, + "nym:Kazakhstán": { + "edges": { + "from": [ + "country:KZ" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Kazakhstán" + } + }, + "nym:Kazakstan": { + "edges": { + "from": [ + "country:KZ" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Kazakstan" + } + }, + "nym:Kingdom of Bahrain": { + "edges": { + "from": [ + "country:BH" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Kingdom of Bahrain" + } + }, + "nym:Kingdom of Belgium": { + "edges": { + "from": [ + "country:BE" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Kingdom of Belgium" + } + }, + "nym:Kingdom of Bhutan": { + "edges": { + "from": [ + "country:BT" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Kingdom of Bhutan" + } + }, + "nym:Kingdom of Cambodia": { + "edges": { + "from": [ + "country:KH" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Kingdom of Cambodia" + } + }, + "nym:Kingdom of Denmark": { + "edges": { + "from": [ + "country:DK" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Kingdom of Denmark" + } + }, + "nym:Kingdom of Eswatini": { + "edges": { + "from": [ + "country:SZ" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Kingdom of Eswatini" + } + }, + "nym:Kingdom of Lesotho": { + "edges": { + "from": [ + "country:LS" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Kingdom of Lesotho" + } + }, + "nym:Kingdom of Moroccoal-Magrib": { + "edges": { + "from": [ + "country:MA" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Kingdom of Moroccoal-Magrib" + } + }, + "nym:Kingdom of Norway": { + "edges": { + "from": [ + "country:NO" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Kingdom of Norway" + } + }, + "nym:Kingdom of Saudi Arabia": { + "edges": { + "from": [ + "country:SA" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Kingdom of Saudi Arabia" + } + }, + "nym:Kingdom of Spain": { + "edges": { + "from": [ + "country:ES" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Kingdom of Spain" + } + }, + "nym:Kingdom of Swaziland": { + "edges": { + "from": [ + "country:SZ" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Kingdom of Swaziland" + } + }, + "nym:Kingdom of Sweden": { + "edges": { + "from": [ + "country:SE" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Kingdom of Sweden" + } + }, + "nym:Kingdom of Thailand": { + "edges": { + "from": [ + "country:TH" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Kingdom of Thailand" + } + }, + "nym:Kingdom of Tonga": { + "edges": { + "from": [ + "country:TO" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Kingdom of Tonga" + } + }, + "nym:Kingdom of the Netherlands": { + "edges": { + "from": [ + "country:NL" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Kingdom of the Netherlands" + } + }, + "nym:Kingman Reef": { + "edges": { + "from": [ + "territory:UM-89" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Kingman Reef" + } + }, + "nym:Kirghizia": { + "edges": { + "from": [ + "country:KG" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Kirghizia" + } + }, + "nym:Kirghizstan": { + "edges": { + "from": [ + "country:KG" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Kirghizstan" + } + }, + "nym:Kirgiz": { + "edges": { + "from": [ + "country:KG" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Kirgiz" + } + }, + "nym:Kirgizia": { + "edges": { + "from": [ + "country:KG" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Kirgizia" + } + }, + "nym:Kirgizija": { + "edges": { + "from": [ + "country:KG" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Kirgizija" + } + }, + "nym:Kirgizstan": { + "edges": { + "from": [ + "country:KG" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Kirgizstan" + } + }, + "nym:Komori": { + "edges": { + "from": [ + "country:KM" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Komori" + } + }, + "nym:Kosova": { + "edges": { + "from": [ + "country:XK" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Kosova" + } + }, + "nym:Koweit": { + "edges": { + "from": [ + "country:KW" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Koweit" + } + }, + "nym:Kypros": { + "edges": { + "from": [ + "country:CY" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Kypros" + } + }, + "nym:Kyrgyz Republic": { + "edges": { + "from": [ + "country:KG" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Kyrgyz Republic" + } + }, + "nym:Kyrgyz Republickirghiz": { + "edges": { + "from": [ + "country:KG" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Kyrgyz Republickirghiz" + } + }, + "nym:Kyrgyzstan": { + "edges": { + "from": [ + "country:KG" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Kyrgyzstan" + } + }, + "nym:Kòrsou": { + "edges": { + "from": [ + "territory:CW" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Kòrsou" + } + }, + "nym:Ködörösêse tî Bêafrîka": { + "edges": { + "from": [ + "country:CF" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Ködörösêse tî Bêafrîka" + } + }, + "nym:Kýpros": { + "edges": { + "from": [ + "country:CY" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Kýpros" + } + }, + "nym:Kıbrıs": { + "edges": { + "from": [ + "country:CY" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Kıbrıs" + } + }, + "nym:LA": { + "edges": { + "from": [ + "country:LA" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "LA" + } + }, + "nym:LB": { + "edges": { + "from": [ + "country:LB" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "LB" + } + }, + "nym:LC": { + "edges": { + "from": [ + "country:LC" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "LC" + } + }, + "nym:LI": { + "edges": { + "from": [ + "country:LI" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "LI" + } + }, + "nym:LK": { + "edges": { + "from": [ + "country:LK" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "LK" + } + }, + "nym:LR": { + "edges": { + "from": [ + "country:LR" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "LR" + } + }, + "nym:LS": { + "edges": { + "from": [ + "country:LS" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "LS" + } + }, + "nym:LT": { + "edges": { + "from": [ + "country:LT" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "LT" + } + }, + "nym:LU": { + "edges": { + "from": [ + "country:LU" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "LU" + } + }, + "nym:LV": { + "edges": { + "from": [ + "country:LV" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "LV" + } + }, + "nym:LY": { + "edges": { + "from": [ + "country:LY" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "LY" + } + }, + "nym:Lao": { + "edges": { + "from": [ + "country:LA" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Lao" + } + }, + "nym:Lao People's Democratic Republic": { + "edges": { + "from": [ + "country:LA" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Lao People's Democratic Republic" + } + }, + "nym:Las Malvinas": { + "edges": { + "from": [ + "territory:FK" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Las Malvinas" + } + }, + "nym:Latvija": { + "edges": { + "from": [ + "country:LV" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Latvija" + } + }, + "nym:Latvijaz": { + "edges": { + "from": [ + "country:LV" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Latvijaz" + } + }, + "nym:Lebanese Republic": { + "edges": { + "from": [ + "country:LB" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Lebanese Republic" + } + }, + "nym:Libya": { + "edges": { + "from": [ + "country:LY" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Libya" + } + }, + "nym:Lietuva": { + "edges": { + "from": [ + "country:LT" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Lietuva" + } + }, + "nym:Lubnān": { + "edges": { + "from": [ + "country:LB" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Lubnān" + } + }, + "nym:Luxemborg": { + "edges": { + "from": [ + "country:LU" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Luxemborg" + } + }, + "nym:Luxemburg": { + "edges": { + "from": [ + "country:LU" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Luxemburg" + } + }, + "nym:Lëtzebuerg": { + "edges": { + "from": [ + "country:LU" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Lëtzebuerg" + } + }, + "nym:Lībiyā": { + "edges": { + "from": [ + "country:LY" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Lībiyā" + } + }, + "nym:MA": { + "edges": { + "from": [ + "country:MA" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "MA" + } + }, + "nym:MC": { + "edges": { + "from": [ + "country:MC" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "MC" + } + }, + "nym:MD": { + "edges": { + "from": [ + "country:MD" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "MD" + } + }, + "nym:ME": { + "edges": { + "from": [ + "country:ME" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "ME" + } + }, + "nym:MF": { + "edges": { + "from": [ + "territory:MF" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "MF" + } + }, + "nym:MG": { + "edges": { + "from": [ + "country:MG" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "MG" + } + }, + "nym:MH": { + "edges": { + "from": [ + "country:MH" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "MH" + } + }, + "nym:MK": { + "edges": { + "from": [ + "country:MK" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "MK" + } + }, + "nym:ML": { + "edges": { + "from": [ + "country:ML" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "ML" + } + }, + "nym:MM": { + "edges": { + "from": [ + "country:MM" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "MM" + } + }, + "nym:MN": { + "edges": { + "from": [ + "country:MN" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "MN" + } + }, + "nym:MO": { + "edges": { + "from": [ + "territory:MO" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "MO" + } + }, + "nym:MP": { + "edges": { + "from": [ + "territory:MP" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "MP" + } + }, + "nym:MQ": { + "edges": { + "from": [ + "territory:MQ" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "MQ" + } + }, + "nym:MR": { + "edges": { + "from": [ + "country:MR" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "MR" + } + }, + "nym:MS": { + "edges": { + "from": [ + "territory:MS" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "MS" + } + }, + "nym:MT": { + "edges": { + "from": [ + "country:MT" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "MT" + } + }, + "nym:MU": { + "edges": { + "from": [ + "country:MU" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "MU" + } + }, + "nym:MV": { + "edges": { + "from": [ + "country:MV" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "MV" + } + }, + "nym:MW": { + "edges": { + "from": [ + "country:MW" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "MW" + } + }, + "nym:MX": { + "edges": { + "from": [ + "country:MX" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "MX" + } + }, + "nym:MY": { + "edges": { + "from": [ + "country:MY" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "MY" + } + }, + "nym:MZ": { + "edges": { + "from": [ + "country:MZ" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "MZ" + } + }, + "nym:Macao Special Administrative Region": { + "edges": { + "from": [ + "territory:MO" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Macao Special Administrative Region" + } + }, + "nym:Macedon": { + "edges": { + "from": [ + "country:MK" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Macedon" + } + }, + "nym:Madagascar": { + "edges": { + "from": [ + "country:MG" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Madagascar" + } + }, + "nym:Madagasikara": { + "edges": { + "from": [ + "country:MG" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Madagasikara" + } + }, + "nym:Magyarorszag": { + "edges": { + "from": [ + "country:HU" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Magyarorszag" + } + }, + "nym:Magyarország": { + "edges": { + "from": [ + "country:HU" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Magyarország" + } + }, + "nym:Mainland china": { + "edges": { + "from": [ + "country:CN" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Mainland china" + } + }, + "nym:Makedonija": { + "edges": { + "from": [ + "country:MK" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Makedonija" + } + }, + "nym:Malagasy Republic": { + "edges": { + "from": [ + "country:MG" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Malagasy Republic" + } + }, + "nym:Malaysia": { + "edges": { + "from": [ + "country:MY" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Malaysia" + } + }, + "nym:Malyasi": { + "edges": { + "from": [ + "country:MY" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Malyasi" + } + }, + "nym:Malēṣiyā": { + "edges": { + "from": [ + "country:MY" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Malēṣiyā" + } + }, + "nym:Maroc": { + "edges": { + "from": [ + "country:MA" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Maroc" + } + }, + "nym:Marruecos": { + "edges": { + "from": [ + "country:MA" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Marruecos" + } + }, + "nym:Martinique": { + "edges": { + "from": [ + "territory:MQ" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Martinique" + } + }, + "nym:Masr": { + "edges": { + "from": [ + "country:EG" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Masr" + } + }, + "nym:Maurice": { + "edges": { + "from": [ + "country:MU" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Maurice" + } + }, + "nym:Mauritanie": { + "edges": { + "from": [ + "country:MR" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Mauritanie" + } + }, + "nym:Mauritius": { + "edges": { + "from": [ + "country:MU" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Mauritius" + } + }, + "nym:Mayotte": { + "edges": { + "from": [ + "territory:YT" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Mayotte" + } + }, + "nym:Melilla": { + "edges": { + "from": [ + "territory:ES-ML" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Melilla" + } + }, + "nym:Mexcio": { + "edges": { + "from": [ + "country:MX" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Mexcio" + } + }, + "nym:Mexicanos": { + "edges": { + "from": [ + "country:MX" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Mexicanos" + } + }, + "nym:Mexixo": { + "edges": { + "from": [ + "country:MX" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Mexixo" + } + }, + "nym:Midway Islands": { + "edges": { + "from": [ + "territory:UM-71" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Midway Islands" + } + }, + "nym:Misr": { + "edges": { + "from": [ + "country:EG" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Misr" + } + }, + "nym:Mocambique": { + "edges": { + "from": [ + "country:MZ" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Mocambique" + } + }, + "nym:Moldavia": { + "edges": { + "from": [ + "country:MD" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Moldavia" + } + }, + "nym:Mongol Uls": { + "edges": { + "from": [ + "country:MN" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Mongol Uls" + } + }, + "nym:Mongolia": { + "edges": { + "from": [ + "country:MN" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Mongolia" + } + }, + "nym:Mongγol Ulus": { + "edges": { + "from": [ + "country:MN" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Mongγol Ulus" + } + }, + "nym:Mongγol ulus": { + "edges": { + "from": [ + "country:MN" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Mongγol ulus" + } + }, + "nym:Montenegro": { + "edges": { + "from": [ + "country:ME" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Montenegro" + } + }, + "nym:Montserrat": { + "edges": { + "from": [ + "territory:MS" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Montserrat" + } + }, + "nym:Moris": { + "edges": { + "from": [ + "country:MU" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Moris" + } + }, + "nym:Moçambique": { + "edges": { + "from": [ + "country:MZ" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Moçambique" + } + }, + "nym:Mueang Thai": { + "edges": { + "from": [ + "country:TH" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Mueang Thai" + } + }, + "nym:Muritan": { + "edges": { + "from": [ + "country:MR" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Muritan" + } + }, + "nym:Muritaniya": { + "edges": { + "from": [ + "country:MR" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Muritaniya" + } + }, + "nym:Muscat and Oman": { + "edges": { + "from": [ + "country:OM" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Muscat and Oman" + } + }, + "nym:Myanma": { + "edges": { + "from": [ + "country:MM" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Myanma" + } + }, + "nym:México": { + "edges": { + "from": [ + "country:MX" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "México" + } + }, + "nym:Mēxihco": { + "edges": { + "from": [ + "country:MX" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Mēxihco" + } + }, + "nym:Mūrītānyā": { + "edges": { + "from": [ + "country:MR" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Mūrītānyā" + } + }, + "nym:Mǎláixīyà": { + "edges": { + "from": [ + "country:MY" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Mǎláixīyà" + } + }, + "nym:NA": { + "edges": { + "from": [ + "country:NA" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "NA" + } + }, + "nym:NC": { + "edges": { + "from": [ + "territory:NC" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "NC" + } + }, + "nym:NE": { + "edges": { + "from": [ + "country:NE" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "NE" + } + }, + "nym:NF": { + "edges": { + "from": [ + "territory:NF" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "NF" + } + }, + "nym:NG": { + "edges": { + "from": [ + "country:NG" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "NG" + } + }, + "nym:NI": { + "edges": { + "from": [ + "country:NI" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "NI" + } + }, + "nym:NIR": { + "edges": { + "from": [ + "uk:NIR" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "NIR" + } + }, + "nym:NL": { + "edges": { + "from": [ + "country:NL" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "NL" + } + }, + "nym:NO": { + "edges": { + "from": [ + "country:NO" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "NO" + } + }, + "nym:NP": { + "edges": { + "from": [ + "country:NP" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "NP" + } + }, + "nym:NR": { + "edges": { + "from": [ + "country:NR" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "NR" + } + }, + "nym:NU": { + "edges": { + "from": [ + "territory:NU" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "NU" + } + }, + "nym:NZ": { + "edges": { + "from": [ + "country:NZ" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "NZ" + } + }, + "nym:Namhan": { + "edges": { + "from": [ + "country:KR" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Namhan" + } + }, + "nym:Namibië": { + "edges": { + "from": [ + "country:NA" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Namibië" + } + }, + "nym:Naoero": { + "edges": { + "from": [ + "country:NR" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Naoero" + } + }, + "nym:Navassa Island": { + "edges": { + "from": [ + "territory:UM-76" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Navassa Island" + } + }, + "nym:Naíjíríà": { + "edges": { + "from": [ + "country:NG" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Naíjíríà" + } + }, + "nym:Nederland": { + "edges": { + "from": [ + "country:NL" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Nederland" + } + }, + "nym:Nederlân": { + "edges": { + "from": [ + "country:NL" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Nederlân" + } + }, + "nym:Nepāl": { + "edges": { + "from": [ + "country:NP" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Nepāl" + } + }, + "nym:New Caledonia": { + "edges": { + "from": [ + "territory:NC" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "New Caledonia" + } + }, + "nym:New Hebrides": { + "edges": { + "from": [ + "country:VU" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "New Hebrides" + } + }, + "nym:New Zealand": { + "edges": { + "from": [ + "country:NZ" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "New Zealand" + } + }, + "nym:Ngwane": { + "edges": { + "from": [ + "country:SZ" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Ngwane" + } + }, + "nym:Nihon": { + "edges": { + "from": [ + "country:JP" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Nihon" + } + }, + "nym:Nijar": { + "edges": { + "from": [ + "country:NE" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Nijar" + } + }, + "nym:Nijeriya": { + "edges": { + "from": [ + "country:NG" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Nijeriya" + } + }, + "nym:Nippon": { + "edges": { + "from": [ + "country:JP" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Nippon" + } + }, + "nym:Niue": { + "edges": { + "from": [ + "territory:NU" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Niue" + } + }, + "nym:Niuē": { + "edges": { + "from": [ + "territory:NU" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Niuē" + } + }, + "nym:Noreg": { + "edges": { + "from": [ + "country:NO" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Noreg" + } + }, + "nym:Norge": { + "edges": { + "from": [ + "country:NO" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Norge" + } + }, + "nym:North Korea": { + "edges": { + "from": [ + "country:KP" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "North Korea" + } + }, + "nym:Northern Ireland": { + "edges": { + "from": [ + "uk:NIR" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Northern Ireland" + } + }, + "nym:Northern Marianas": { + "edges": { + "from": [ + "territory:MP" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Northern Marianas" + } + }, + "nym:Northern Rhodesia": { + "edges": { + "from": [ + "country:ZM" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Northern Rhodesia" + } + }, + "nym:Nouvelle-Calédonie": { + "edges": { + "from": [ + "territory:NC" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Nouvelle-Calédonie" + } + }, + "nym:Nyasaland": { + "edges": { + "from": [ + "country:MW" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Nyasaland" + } + }, + "nym:O'zbekstan": { + "edges": { + "from": [ + "country:UZ" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "O'zbekstan" + } + }, + "nym:OM": { + "edges": { + "from": [ + "country:OM" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "OM" + } + }, + "nym:Occupied Palestinian Territories": { + "edges": { + "from": [ + "territory:PS" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Occupied Palestinian Territories" + } + }, + "nym:Oesterreich": { + "edges": { + "from": [ + "country:AT" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Oesterreich" + } + }, + "nym:Oriental Republic of Uruguay": { + "edges": { + "from": [ + "country:UY" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Oriental Republic of Uruguay" + } + }, + "nym:Osterreich": { + "edges": { + "from": [ + "country:AT" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Osterreich" + } + }, + "nym:Outer Mongolia": { + "edges": { + "from": [ + "country:MN" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Outer Mongolia" + } + }, + "nym:O‘zbekiston": { + "edges": { + "from": [ + "country:UZ" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "O‘zbekiston" + } + }, + "nym:O’zbekstan": { + "edges": { + "from": [ + "country:UZ" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "O’zbekstan" + } + }, + "nym:PA": { + "edges": { + "from": [ + "country:PA" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "PA" + } + }, + "nym:PE": { + "edges": { + "from": [ + "country:PE" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "PE" + } + }, + "nym:PF": { + "edges": { + "from": [ + "territory:PF" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "PF" + } + }, + "nym:PG": { + "edges": { + "from": [ + "country:PG" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "PG" + } + }, + "nym:PH": { + "edges": { + "from": [ + "country:PH" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "PH" + } + }, + "nym:PK": { + "edges": { + "from": [ + "country:PK" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "PK" + } + }, + "nym:PL": { + "edges": { + "from": [ + "country:PL" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "PL" + } + }, + "nym:PM": { + "edges": { + "from": [ + "territory:PM" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "PM" + } + }, + "nym:PN": { + "edges": { + "from": [ + "territory:PN" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "PN" + } + }, + "nym:PNG": { + "edges": { + "from": [ + "country:PG" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "PNG" + } + }, + "nym:PR": { + "edges": { + "from": [ + "territory:PR" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "PR" + } + }, + "nym:PRC": { + "edges": { + "from": [ + "country:CN" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "PRC" + } + }, + "nym:PRK": { + "edges": { + "from": [ + "country:KP" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "PRK" + } + }, + "nym:PS": { + "edges": { + "from": [ + "territory:PS" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "PS" + } + }, + "nym:PT": { + "edges": { + "from": [ + "country:PT" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "PT" + } + }, + "nym:PW": { + "edges": { + "from": [ + "country:PW" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "PW" + } + }, + "nym:PY": { + "edges": { + "from": [ + "country:PY" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "PY" + } + }, + "nym:Palmyra Atoll": { + "edges": { + "from": [ + "territory:UM-95" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Palmyra Atoll" + } + }, + "nym:Panamá": { + "edges": { + "from": [ + "country:PA" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Panamá" + } + }, + "nym:Papua Niugini": { + "edges": { + "from": [ + "country:PG" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Papua Niugini" + } + }, + "nym:Paraguái": { + "edges": { + "from": [ + "country:PY" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Paraguái" + } + }, + "nym:Pelew": { + "edges": { + "from": [ + "country:PW" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Pelew" + } + }, + "nym:People's Democratic Republic of Algeriaalgerie": { + "edges": { + "from": [ + "country:DZ" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "People's Democratic Republic of Algeriaalgerie" + } + }, + "nym:People's Republic of Bangladesh": { + "edges": { + "from": [ + "country:BD" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "People's Republic of Bangladesh" + } + }, + "nym:People's Republic of China": { + "edges": { + "from": [ + "country:CN" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "People's Republic of China" + } + }, + "nym:Peoples Republic": { + "edges": { + "from": [ + "country:CN" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Peoples Republic" + } + }, + "nym:Persia": { + "edges": { + "from": [ + "country:IR" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Persia" + } + }, + "nym:Perú": { + "edges": { + "from": [ + "country:PE" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Perú" + } + }, + "nym:Philippine Islands": { + "edges": { + "from": [ + "country:PH" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Philippine Islands" + } + }, + "nym:Phillippine": { + "edges": { + "from": [ + "country:PH" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Phillippine" + } + }, + "nym:Pilipinas": { + "edges": { + "from": [ + "country:PH" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Pilipinas" + } + }, + "nym:Pinas": { + "edges": { + "from": [ + "country:PH" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Pinas" + } + }, + "nym:Piruw": { + "edges": { + "from": [ + "country:PE" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Piruw" + } + }, + "nym:Pitcairn": { + "edges": { + "from": [ + "territory:PN" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Pitcairn" + } + }, + "nym:Pitcairn, Henderson, Ducie and Oeno Islands": { + "edges": { + "from": [ + "territory:PN" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Pitcairn, Henderson, Ducie and Oeno Islands" + } + }, + "nym:Plurinational State of Bolivia": { + "edges": { + "from": [ + "country:BO" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Plurinational State of Bolivia" + } + }, + "nym:Polska": { + "edges": { + "from": [ + "country:PL" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Polska" + } + }, + "nym:Polynésie Française": { + "edges": { + "from": [ + "territory:PF" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Polynésie Française" + } + }, + "nym:Polynésie française": { + "edges": { + "from": [ + "territory:PF" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Polynésie française" + } + }, + "nym:Porto Rico": { + "edges": { + "from": [ + "territory:PR" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Porto Rico" + } + }, + "nym:Portuguesa": { + "edges": { + "from": [ + "country:PT" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Portuguesa" + } + }, + "nym:Portuguese Guinea": { + "edges": { + "from": [ + "country:GW" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Portuguese Guinea" + } + }, + "nym:Portuguese Republic": { + "edges": { + "from": [ + "country:PT" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Portuguese Republic" + } + }, + "nym:Prathet Thai": { + "edges": { + "from": [ + "country:TH" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Prathet Thai" + } + }, + "nym:Principality of Andorra": { + "edges": { + "from": [ + "country:AD" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Principality of Andorra" + } + }, + "nym:Principality of Liechtenstein": { + "edges": { + "from": [ + "country:LI" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Principality of Liechtenstein" + } + }, + "nym:Principality of Monaco": { + "edges": { + "from": [ + "country:MC" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Principality of Monaco" + } + }, + "nym:Puarto Rico": { + "edges": { + "from": [ + "territory:PR" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Puarto Rico" + } + }, + "nym:QA": { + "edges": { + "from": [ + "country:QA" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "QA" + } + }, + "nym:Qazaqstan": { + "edges": { + "from": [ + "country:KZ" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Qazaqstan" + } + }, + "nym:RE": { + "edges": { + "from": [ + "territory:RE" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "RE" + } + }, + "nym:RO": { + "edges": { + "from": [ + "country:RO" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "RO" + } + }, + "nym:RS": { + "edges": { + "from": [ + "country:RS" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "RS" + } + }, + "nym:RSA": { + "edges": { + "from": [ + "country:ZA" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "RSA" + } + }, + "nym:RSM": { + "edges": { + "from": [ + "country:SM" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "RSM" + } + }, + "nym:RU": { + "edges": { + "from": [ + "country:RU" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "RU" + } + }, + "nym:RW": { + "edges": { + "from": [ + "country:RW" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "RW" + } + }, + "nym:Rastafari": { + "edges": { + "from": [ + "country:JM" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Rastafari" + } + }, + "nym:Rastas": { + "edges": { + "from": [ + "country:JM" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Rastas" + } + }, + "nym:Ratcha-anachak Thai": { + "edges": { + "from": [ + "country:TH" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Ratcha-anachak Thai" + } + }, + "nym:Repubblica": { + "edges": { + "from": [ + "country:SM" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Repubblica" + } + }, + "nym:Repubilika ya Kongo": { + "edges": { + "from": [ + "country:CD" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Repubilika ya Kongo" + } + }, + "nym:Republic of Albania": { + "edges": { + "from": [ + "country:AL" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Republic of Albania" + } + }, + "nym:Republic of Angola": { + "edges": { + "from": [ + "country:AO" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Republic of Angola" + } + }, + "nym:Republic of Armenia": { + "edges": { + "from": [ + "country:AM" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Republic of Armenia" + } + }, + "nym:Republic of Austria": { + "edges": { + "from": [ + "country:AT" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Republic of Austria" + } + }, + "nym:Republic of Azerbaijan": { + "edges": { + "from": [ + "country:AZ" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Republic of Azerbaijan" + } + }, + "nym:Republic of Belarusbelorussia": { + "edges": { + "from": [ + "country:BY" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Republic of Belarusbelorussia" + } + }, + "nym:Republic of Benin": { + "edges": { + "from": [ + "country:BJ" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Republic of Benin" + } + }, + "nym:Republic of Bosnia and Herzegovina": { + "edges": { + "from": [ + "country:BA" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Republic of Bosnia and Herzegovina" + } + }, + "nym:Republic of Botswana": { + "edges": { + "from": [ + "country:BW" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Republic of Botswana" + } + }, + "nym:Republic of Bulgaria": { + "edges": { + "from": [ + "country:BG" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Republic of Bulgaria" + } + }, + "nym:Republic of Burundi": { + "edges": { + "from": [ + "country:BI" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Republic of Burundi" + } + }, + "nym:Republic of Cabo Verde": { + "edges": { + "from": [ + "country:CV" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Republic of Cabo Verde" + } + }, + "nym:Republic of Cameroon": { + "edges": { + "from": [ + "country:CM" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Republic of Cameroon" + } + }, + "nym:Republic of Chad": { + "edges": { + "from": [ + "country:TD" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Republic of Chad" + } + }, + "nym:Republic of Chile": { + "edges": { + "from": [ + "country:CL" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Republic of Chile" + } + }, + "nym:Republic of Colombia": { + "edges": { + "from": [ + "country:CO" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Republic of Colombia" + } + }, + "nym:Republic of Costa Rica": { + "edges": { + "from": [ + "country:CR" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Republic of Costa Rica" + } + }, + "nym:Republic of Cote D'Ivoire": { + "edges": { + "from": [ + "country:CI" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Republic of Cote D'Ivoire" + } + }, + "nym:Republic of Croatia": { + "edges": { + "from": [ + "country:HR" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Republic of Croatia" + } + }, + "nym:Republic of Cuba": { + "edges": { + "from": [ + "country:CU" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Republic of Cuba" + } + }, + "nym:Republic of Cyprus": { + "edges": { + "from": [ + "country:CY" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Republic of Cyprus" + } + }, + "nym:Republic of Djibouti": { + "edges": { + "from": [ + "country:DJ" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Republic of Djibouti" + } + }, + "nym:Republic of Ecuador": { + "edges": { + "from": [ + "country:EC" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Republic of Ecuador" + } + }, + "nym:Republic of El Salvador": { + "edges": { + "from": [ + "country:SV" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Republic of El Salvador" + } + }, + "nym:Republic of Equatorial Guinea": { + "edges": { + "from": [ + "country:GQ" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Republic of Equatorial Guinea" + } + }, + "nym:Republic of Estonia": { + "edges": { + "from": [ + "country:EE" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Republic of Estonia" + } + }, + "nym:Republic of Fiji": { + "edges": { + "from": [ + "country:FJ" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Republic of Fiji" + } + }, + "nym:Republic of Finland": { + "edges": { + "from": [ + "country:FI" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Republic of Finland" + } + }, + "nym:Republic of Ghana": { + "edges": { + "from": [ + "country:GH" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Republic of Ghana" + } + }, + "nym:Republic of Guatemala": { + "edges": { + "from": [ + "country:GT" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Republic of Guatemala" + } + }, + "nym:Republic of Guinea": { + "edges": { + "from": [ + "country:GN" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Republic of Guinea" + } + }, + "nym:Republic of Guinea-Bissau": { + "edges": { + "from": [ + "country:GW" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Republic of Guinea-Bissau" + } + }, + "nym:Republic of Haiti": { + "edges": { + "from": [ + "country:HT" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Republic of Haiti" + } + }, + "nym:Republic of Honduras": { + "edges": { + "from": [ + "country:HN" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Republic of Honduras" + } + }, + "nym:Republic of Iceland": { + "edges": { + "from": [ + "country:IS" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Republic of Iceland" + } + }, + "nym:Republic of India": { + "edges": { + "from": [ + "country:IN" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Republic of India" + } + }, + "nym:Republic of Indonesia": { + "edges": { + "from": [ + "country:ID" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Republic of Indonesia" + } + }, + "nym:Republic of Iraq": { + "edges": { + "from": [ + "country:IQ" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Republic of Iraq" + } + }, + "nym:Republic of Ireland": { + "edges": { + "from": [ + "country:IE" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Republic of Ireland" + } + }, + "nym:Republic of Kazakhstankazak": { + "edges": { + "from": [ + "country:KZ" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Republic of Kazakhstankazak" + } + }, + "nym:Republic of Kenya": { + "edges": { + "from": [ + "country:KE" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Republic of Kenya" + } + }, + "nym:Republic of Kiribati": { + "edges": { + "from": [ + "country:KI" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Republic of Kiribati" + } + }, + "nym:Republic of Korea": { + "edges": { + "from": [ + "country:KR" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Republic of Korea" + } + }, + "nym:Republic of Kosovo": { + "edges": { + "from": [ + "country:XK" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Republic of Kosovo" + } + }, + "nym:Republic of Latvia": { + "edges": { + "from": [ + "country:LV" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Republic of Latvia" + } + }, + "nym:Republic of Liberia": { + "edges": { + "from": [ + "country:LR" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Republic of Liberia" + } + }, + "nym:Republic of Lithuanialietuva": { + "edges": { + "from": [ + "country:LT" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Republic of Lithuanialietuva" + } + }, + "nym:Republic of Macedonia": { + "edges": { + "from": [ + "country:MK" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Republic of Macedonia" + } + }, + "nym:Republic of Madagascar": { + "edges": { + "from": [ + "country:MG" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Republic of Madagascar" + } + }, + "nym:Republic of Malawi": { + "edges": { + "from": [ + "country:MW" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Republic of Malawi" + } + }, + "nym:Republic of Maldives": { + "edges": { + "from": [ + "country:MV" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Republic of Maldives" + } + }, + "nym:Republic of Mali": { + "edges": { + "from": [ + "country:ML" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Republic of Mali" + } + }, + "nym:Republic of Malta": { + "edges": { + "from": [ + "country:MT" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Republic of Malta" + } + }, + "nym:Republic of Mauritius": { + "edges": { + "from": [ + "country:MU" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Republic of Mauritius" + } + }, + "nym:Republic of Moldova": { + "edges": { + "from": [ + "country:MD" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Republic of Moldova" + } + }, + "nym:Republic of Mozambique": { + "edges": { + "from": [ + "country:MZ" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Republic of Mozambique" + } + }, + "nym:Republic of Namibia": { + "edges": { + "from": [ + "country:NA" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Republic of Namibia" + } + }, + "nym:Republic of Nauru": { + "edges": { + "from": [ + "country:NR" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Republic of Nauru" + } + }, + "nym:Republic of Nicaragua": { + "edges": { + "from": [ + "country:NI" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Republic of Nicaragua" + } + }, + "nym:Republic of Niger": { + "edges": { + "from": [ + "country:NE" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Republic of Niger" + } + }, + "nym:Republic of Palau": { + "edges": { + "from": [ + "country:PW" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Republic of Palau" + } + }, + "nym:Republic of Panama": { + "edges": { + "from": [ + "country:PA" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Republic of Panama" + } + }, + "nym:Republic of Paraguay": { + "edges": { + "from": [ + "country:PY" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Republic of Paraguay" + } + }, + "nym:Republic of Peru": { + "edges": { + "from": [ + "country:PE" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Republic of Peru" + } + }, + "nym:Republic of Poland": { + "edges": { + "from": [ + "country:PL" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Republic of Poland" + } + }, + "nym:Republic of Rwandaruanda": { + "edges": { + "from": [ + "country:RW" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Republic of Rwandaruanda" + } + }, + "nym:Republic of San Marino": { + "edges": { + "from": [ + "country:SM" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Republic of San Marino" + } + }, + "nym:Republic of Senegal": { + "edges": { + "from": [ + "country:SN" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Republic of Senegal" + } + }, + "nym:Republic of Serbia": { + "edges": { + "from": [ + "country:RS" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Republic of Serbia" + } + }, + "nym:Republic of Seychelles": { + "edges": { + "from": [ + "country:SC" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Republic of Seychelles" + } + }, + "nym:Republic of Sierra Leone": { + "edges": { + "from": [ + "country:SL" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Republic of Sierra Leone" + } + }, + "nym:Republic of Singapore": { + "edges": { + "from": [ + "country:SG" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Republic of Singapore" + } + }, + "nym:Republic of Slovenia": { + "edges": { + "from": [ + "country:SI" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Republic of Slovenia" + } + }, + "nym:Republic of South Africa": { + "edges": { + "from": [ + "country:ZA" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Republic of South Africa" + } + }, + "nym:Republic of South Sudan": { + "edges": { + "from": [ + "country:SS" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Republic of South Sudan" + } + }, + "nym:Republic of Suriname": { + "edges": { + "from": [ + "country:SR" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Republic of Suriname" + } + }, + "nym:Republic of Tajikistantadjik": { + "edges": { + "from": [ + "country:TJ" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Republic of Tajikistantadjik" + } + }, + "nym:Republic of Trinidad and Tobago": { + "edges": { + "from": [ + "country:TT" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Republic of Trinidad and Tobago" + } + }, + "nym:Republic of Turkey": { + "edges": { + "from": [ + "country:TR" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Republic of Turkey" + } + }, + "nym:Republic of Uganda": { + "edges": { + "from": [ + "country:UG" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Republic of Uganda" + } + }, + "nym:Republic of Uzbekistan": { + "edges": { + "from": [ + "country:UZ" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Republic of Uzbekistan" + } + }, + "nym:Republic of Vanuatu": { + "edges": { + "from": [ + "country:VU" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Republic of Vanuatu" + } + }, + "nym:Republic of Yemen": { + "edges": { + "from": [ + "country:YE" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Republic of Yemen" + } + }, + "nym:Republic of Zambia": { + "edges": { + "from": [ + "country:ZM" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Republic of Zambia" + } + }, + "nym:Republic of Zimbabwe": { + "edges": { + "from": [ + "country:ZW" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Republic of Zimbabwe" + } + }, + "nym:Republic of the Congo": { + "edges": { + "from": [ + "country:CG" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Republic of the Congo" + } + }, + "nym:Republic of the Marshall Islands": { + "edges": { + "from": [ + "country:MH" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Republic of the Marshall Islands" + } + }, + "nym:Republic of the Philippines": { + "edges": { + "from": [ + "country:PH" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Republic of the Philippines" + } + }, + "nym:Republic of the Sudan": { + "edges": { + "from": [ + "country:SD" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Republic of the Sudan" + } + }, + "nym:Republic of the Union of Myanmar": { + "edges": { + "from": [ + "country:MM" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Republic of the Union of Myanmar" + } + }, + "nym:República Dominicana": { + "edges": { + "from": [ + "country:DO" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "República Dominicana" + } + }, + "nym:República Oriental del Uruguay": { + "edges": { + "from": [ + "country:UY" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "República Oriental del Uruguay" + } + }, + "nym:Reunion": { + "edges": { + "from": [ + "territory:RE" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Reunion" + } + }, + "nym:Rgypt": { + "edges": { + "from": [ + "country:EG" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Rgypt" + } + }, + "nym:Rhodesia": { + "edges": { + "from": [ + "country:ZW" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Rhodesia" + } + }, + "nym:Romania": { + "edges": { + "from": [ + "country:RO" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Romania" + } + }, + "nym:România": { + "edges": { + "from": [ + "country:RO" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "România" + } + }, + "nym:Rossiya": { + "edges": { + "from": [ + "country:RU" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Rossiya" + } + }, + "nym:Rossiâ": { + "edges": { + "from": [ + "country:RU" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Rossiâ" + } + }, + "nym:Roumania": { + "edges": { + "from": [ + "country:RO" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Roumania" + } + }, + "nym:Rumania": { + "edges": { + "from": [ + "country:RO" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Rumania" + } + }, + "nym:Russian Federation": { + "edges": { + "from": [ + "country:RU" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Russian Federation" + } + }, + "nym:Rwandese Republic": { + "edges": { + "from": [ + "country:RW" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Rwandese Republic" + } + }, + "nym:République Centrafricaine": { + "edges": { + "from": [ + "country:CF" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "République Centrafricaine" + } + }, + "nym:République Française": { + "edges": { + "from": [ + "country:FR" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "République Française" + } + }, + "nym:République Gabonaise": { + "edges": { + "from": [ + "country:GA" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "République Gabonaise" + } + }, + "nym:République du Congo": { + "edges": { + "from": [ + "country:CG" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "République du Congo" + } + }, + "nym:République démocratique du Congo": { + "edges": { + "from": [ + "country:CD" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "République démocratique du Congo" + } + }, + "nym:République française": { + "edges": { + "from": [ + "country:FR" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "République française" + } + }, + "nym:République gabonaise": { + "edges": { + "from": [ + "country:GA" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "République gabonaise" + } + }, + "nym:Réunion": { + "edges": { + "from": [ + "territory:RE" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Réunion" + } + }, + "nym:Rìoghachd Aonaichte": { + "edges": { + "from": [ + "country:GB" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Rìoghachd Aonaichte" + } + }, + "nym:Ríocht Aontaithe": { + "edges": { + "from": [ + "country:GB" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Ríocht Aontaithe" + } + }, + "nym:SA": { + "edges": { + "from": [ + "country:SA" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "SA" + } + }, + "nym:SB": { + "edges": { + "from": [ + "country:SB" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "SB" + } + }, + "nym:SC": { + "edges": { + "from": [ + "country:SC" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "SC" + } + }, + "nym:SCT": { + "edges": { + "from": [ + "uk:SCT" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "SCT" + } + }, + "nym:SD": { + "edges": { + "from": [ + "country:SD" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "SD" + } + }, + "nym:SE": { + "edges": { + "from": [ + "country:SE" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "SE" + } + }, + "nym:SG": { + "edges": { + "from": [ + "country:SG" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "SG" + } + }, + "nym:SH-AC": { + "edges": { + "from": [ + "territory:SH-AC" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "SH-AC" + } + }, + "nym:SH-HL": { + "edges": { + "from": [ + "territory:SH-HL" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "SH-HL" + } + }, + "nym:SH-TA": { + "edges": { + "from": [ + "territory:SH-TA" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "SH-TA" + } + }, + "nym:SI": { + "edges": { + "from": [ + "country:SI" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "SI" + } + }, + "nym:SJ": { + "edges": { + "from": [ + "territory:SJ" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "SJ" + } + }, + "nym:SK": { + "edges": { + "from": [ + "country:SK" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "SK" + } + }, + "nym:SL": { + "edges": { + "from": [ + "country:SL" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "SL" + } + }, + "nym:SM": { + "edges": { + "from": [ + "country:SM" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "SM" + } + }, + "nym:SN": { + "edges": { + "from": [ + "country:SN" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "SN" + } + }, + "nym:SO": { + "edges": { + "from": [ + "country:SO" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "SO" + } + }, + "nym:SR": { + "edges": { + "from": [ + "country:SR" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "SR" + } + }, + "nym:SS": { + "edges": { + "from": [ + "country:SS" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "SS" + } + }, + "nym:ST": { + "edges": { + "from": [ + "country:ST" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "ST" + } + }, + "nym:SU": { + "edges": { + "from": [ + "country:SU" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "SU" + } + }, + "nym:SV": { + "edges": { + "from": [ + "country:SV" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "SV" + } + }, + "nym:SX": { + "edges": { + "from": [ + "territory:SX" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "SX" + } + }, + "nym:SY": { + "edges": { + "from": [ + "country:SY" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "SY" + } + }, + "nym:SZ": { + "edges": { + "from": [ + "country:SZ" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "SZ" + } + }, + "nym:Saba": { + "edges": { + "from": [ + "territory:BQ-SA" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Saba" + } + }, + "nym:Saint Barthélemy": { + "edges": { + "from": [ + "territory:BL" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Saint Barthélemy" + } + }, + "nym:Saint Helena": { + "edges": { + "from": [ + "territory:SH-HL" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Saint Helena" + } + }, + "nym:Saint Lucia": { + "edges": { + "from": [ + "country:LC" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Saint Lucia" + } + }, + "nym:Saint Pierre and Miquelon": { + "edges": { + "from": [ + "territory:PM" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Saint Pierre and Miquelon" + } + }, + "nym:Saint Vincent and the Grenadines": { + "edges": { + "from": [ + "country:VC" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Saint Vincent and the Grenadines" + } + }, + "nym:Saint-Pierre et Miquelon": { + "edges": { + "from": [ + "territory:PM" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Saint-Pierre et Miquelon" + } + }, + "nym:Sak'art'velo": { + "edges": { + "from": [ + "country:GE" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Sak'art'velo" + } + }, + "nym:Sakartvelo": { + "edges": { + "from": [ + "country:GE" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Sakartvelo" + } + }, + "nym:Salvador": { + "edges": { + "from": [ + "country:SV" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Salvador" + } + }, + "nym:Samo": { + "edges": { + "from": [ + "country:WS" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Samo" + } + }, + "nym:Samoa i Sisifo": { + "edges": { + "from": [ + "country:WS" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Samoa i Sisifo" + } + }, + "nym:Sao Thome e Principe": { + "edges": { + "from": [ + "country:ST" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Sao Thome e Principe" + } + }, + "nym:Sao Tome e Principe": { + "edges": { + "from": [ + "country:ST" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Sao Tome e Principe" + } + }, + "nym:Sarnam": { + "edges": { + "from": [ + "country:SR" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Sarnam" + } + }, + "nym:Sarnam Sranangron": { + "edges": { + "from": [ + "country:SR" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Sarnam Sranangron" + } + }, + "nym:Schweiz": { + "edges": { + "from": [ + "country:CH" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Schweiz" + } + }, + "nym:Scotland": { + "edges": { + "from": [ + "uk:SCT" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Scotland" + } + }, + "nym:Scottland": { + "edges": { + "from": [ + "country:GB" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Scottland" + } + }, + "nym:Sesel": { + "edges": { + "from": [ + "country:SC" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Sesel" + } + }, + "nym:Shqipëria": { + "edges": { + "from": [ + "country:AL" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Shqipëria" + } + }, + "nym:Siam": { + "edges": { + "from": [ + "country:TH" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Siam" + } + }, + "nym:Singapur": { + "edges": { + "from": [ + "country:SG" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Singapur" + } + }, + "nym:Singapura": { + "edges": { + "from": [ + "country:SG" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Singapura" + } + }, + "nym:Sint Eustatius": { + "edges": { + "from": [ + "territory:BQ-SE" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Sint Eustatius" + } + }, + "nym:Sint Maarten": { + "edges": { + "from": [ + "territory:SX" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Sint Maarten" + } + }, + "nym:Sion": { + "edges": { + "from": [ + "country:IL" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Sion" + } + }, + "nym:Slovak Republic": { + "edges": { + "from": [ + "country:SK" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Slovak Republic" + } + }, + "nym:Slovenija": { + "edges": { + "from": [ + "country:SI" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Slovenija" + } + }, + "nym:Slovensko": { + "edges": { + "from": [ + "country:SK" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Slovensko" + } + }, + "nym:Slovenská": { + "edges": { + "from": [ + "country:SK" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Slovenská" + } + }, + "nym:Socialist Federal Republic of Yugoslavia": { + "edges": { + "from": [ + "country:YU" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Socialist Federal Republic of Yugoslavia" + } + }, + "nym:Socialist People's Libyan Arab": { + "edges": { + "from": [ + "country:LY" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Socialist People's Libyan Arab" + } + }, + "nym:Socialist Republic of Vietnam": { + "edges": { + "from": [ + "country:VN" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Socialist Republic of Vietnam" + } + }, + "nym:Solomon Aelan": { + "edges": { + "from": [ + "country:SB" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Solomon Aelan" + } + }, + "nym:Solomon Islands": { + "edges": { + "from": [ + "country:SB" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Solomon Islands" + } + }, + "nym:Solomons": { + "edges": { + "from": [ + "country:SB" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Solomons" + } + }, + "nym:Soomaaliya": { + "edges": { + "from": [ + "country:SO" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Soomaaliya" + } + }, + "nym:Soudan": { + "edges": { + "from": [ + "country:SD" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Soudan" + } + }, + "nym:South Georgia and the South Sandwich Islands": { + "edges": { + "from": [ + "territory:GS" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "South Georgia and the South Sandwich Islands" + } + }, + "nym:South Korea": { + "edges": { + "from": [ + "country:KR" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "South Korea" + } + }, + "nym:South west africa": { + "edges": { + "from": [ + "country:NA" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "South west africa" + } + }, + "nym:Sovereign Base Areas of Akrotiri and Dhekelia": { + "edges": { + "from": [ + "territory:XXD" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Sovereign Base Areas of Akrotiri and Dhekelia" + } + }, + "nym:Spanish Guinea": { + "edges": { + "from": [ + "country:GQ" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Spanish Guinea" + } + }, + "nym:Sranangron": { + "edges": { + "from": [ + "country:SR" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Sranangron" + } + }, + "nym:Srbija": { + "edges": { + "from": [ + "country:RS" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Srbija" + } + }, + "nym:Sri Lankā": { + "edges": { + "from": [ + "country:LK" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Sri Lankā" + } + }, + "nym:St Barth": { + "edges": { + "from": [ + "territory:BL" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "St Barth" + } + }, + "nym:St. Barthelemy": { + "edges": { + "from": [ + "territory:BL" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "St. Barthelemy" + } + }, + "nym:St. Kitts and Nevis": { + "edges": { + "from": [ + "country:KN" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "St. Kitts and Nevis" + } + }, + "nym:St. Lucia": { + "edges": { + "from": [ + "country:LC" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "St. Lucia" + } + }, + "nym:St. Thomas and Principe": { + "edges": { + "from": [ + "country:ST" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "St. Thomas and Principe" + } + }, + "nym:St. Vincent": { + "edges": { + "from": [ + "country:VC" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "St. Vincent" + } + }, + "nym:State of Bahrain": { + "edges": { + "from": [ + "country:BH" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "State of Bahrain" + } + }, + "nym:State of Eritrea": { + "edges": { + "from": [ + "country:ER" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "State of Eritrea" + } + }, + "nym:State of Israel": { + "edges": { + "from": [ + "country:IL" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "State of Israel" + } + }, + "nym:State of Kuwait": { + "edges": { + "from": [ + "country:KW" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "State of Kuwait" + } + }, + "nym:State of Qatar": { + "edges": { + "from": [ + "country:QA" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "State of Qatar" + } + }, + "nym:Suid-Afrika": { + "edges": { + "from": [ + "country:ZA" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Suid-Afrika" + } + }, + "nym:Suisse": { + "edges": { + "from": [ + "country:CH" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Suisse" + } + }, + "nym:Sultanate of Oman": { + "edges": { + "from": [ + "country:OM" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Sultanate of Oman" + } + }, + "nym:Suomi": { + "edges": { + "from": [ + "country:FI" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Suomi" + } + }, + "nym:Suriyah": { + "edges": { + "from": [ + "country:SY" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Suriyah" + } + }, + "nym:Svalbard and Jan Mayen": { + "edges": { + "from": [ + "territory:SJ" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Svalbard and Jan Mayen" + } + }, + "nym:Sverige": { + "edges": { + "from": [ + "country:SE" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Sverige" + } + }, + "nym:Svizra": { + "edges": { + "from": [ + "country:CH" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Svizra" + } + }, + "nym:Svizzera": { + "edges": { + "from": [ + "country:CH" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Svizzera" + } + }, + "nym:Swatini": { + "edges": { + "from": [ + "country:SZ" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Swatini" + } + }, + "nym:Swiss Confederation": { + "edges": { + "from": [ + "country:CH" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Swiss Confederation" + } + }, + "nym:Switerland": { + "edges": { + "from": [ + "country:CH" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Switerland" + } + }, + "nym:Syria": { + "edges": { + "from": [ + "country:SY" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Syria" + } + }, + "nym:Syrian Arab Republic": { + "edges": { + "from": [ + "country:SY" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Syrian Arab Republic" + } + }, + "nym:São Tomé e Príncipe": { + "edges": { + "from": [ + "country:ST" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "São Tomé e Príncipe" + } + }, + "nym:Sénégal": { + "edges": { + "from": [ + "country:SN" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Sénégal" + } + }, + "nym:TC": { + "edges": { + "from": [ + "territory:TC" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "TC" + } + }, + "nym:TD": { + "edges": { + "from": [ + "country:TD" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "TD" + } + }, + "nym:TF": { + "edges": { + "from": [ + "territory:TF" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "TF" + } + }, + "nym:TG": { + "edges": { + "from": [ + "country:TG" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "TG" + } + }, + "nym:TH": { + "edges": { + "from": [ + "country:TH" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "TH" + } + }, + "nym:TJ": { + "edges": { + "from": [ + "country:TJ" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "TJ" + } + }, + "nym:TK": { + "edges": { + "from": [ + "territory:TK" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "TK" + } + }, + "nym:TL": { + "edges": { + "from": [ + "country:TL" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "TL" + } + }, + "nym:TM": { + "edges": { + "from": [ + "country:TM" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "TM" + } + }, + "nym:TN": { + "edges": { + "from": [ + "country:TN" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "TN" + } + }, + "nym:TO": { + "edges": { + "from": [ + "country:TO" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "TO" + } + }, + "nym:TR": { + "edges": { + "from": [ + "country:TR" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "TR" + } + }, + "nym:TT": { + "edges": { + "from": [ + "country:TT" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "TT" + } + }, + "nym:TV": { + "edges": { + "from": [ + "country:TV" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "TV" + } + }, + "nym:TW": { + "edges": { + "from": [ + "territory:TW" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "TW" + } + }, + "nym:TZ": { + "edges": { + "from": [ + "country:TZ" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "TZ" + } + }, + "nym:Tadzhik": { + "edges": { + "from": [ + "country:TJ" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Tadzhik" + } + }, + "nym:Tadzhikistan": { + "edges": { + "from": [ + "country:TJ" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Tadzhikistan" + } + }, + "nym:Taiwan": { + "edges": { + "from": [ + "territory:TW" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Taiwan" + } + }, + "nym:Tajik": { + "edges": { + "from": [ + "country:TJ" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Tajik" + } + }, + "nym:Tchad": { + "edges": { + "from": [ + "country:TD" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Tchad" + } + }, + "nym:Territory of American Samoa": { + "edges": { + "from": [ + "territory:AS" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Territory of American Samoa" + } + }, + "nym:Territory of Christmas Island": { + "edges": { + "from": [ + "territory:CX" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Territory of Christmas Island" + } + }, + "nym:Territory of Guam": { + "edges": { + "from": [ + "territory:GU" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Territory of Guam" + } + }, + "nym:Territory of Heard Island and McDonald Islands": { + "edges": { + "from": [ + "territory:HM" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Territory of Heard Island and McDonald Islands" + } + }, + "nym:Territory of Norfolk Island": { + "edges": { + "from": [ + "territory:NF" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Territory of Norfolk Island" + } + }, + "nym:Territory of the Cocos (Keeling) Islands": { + "edges": { + "from": [ + "territory:CC" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Territory of the Cocos (Keeling) Islands" + } + }, + "nym:Territory of the Wallis and Futuna Islands": { + "edges": { + "from": [ + "territory:WF" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Territory of the Wallis and Futuna Islands" + } + }, + "nym:The Arab Republic of Egypt": { + "edges": { + "from": [ + "country:EG" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Arab Republic of Egypt" + } + }, + "nym:The Argentine Republic": { + "edges": { + "from": [ + "country:AR" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Argentine Republic" + } + }, + "nym:The Bolivarian Republic of Venezuela": { + "edges": { + "from": [ + "country:VE" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Bolivarian Republic of Venezuela" + } + }, + "nym:The British Indian Ocean Territory": { + "edges": { + "from": [ + "territory:IO" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The British Indian Ocean Territory" + } + }, + "nym:The Central African Republic": { + "edges": { + "from": [ + "country:CF" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Central African Republic" + } + }, + "nym:The Co-operative Republic of Guyana": { + "edges": { + "from": [ + "country:GY" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Co-operative Republic of Guyana" + } + }, + "nym:The Commonwealth of Australia": { + "edges": { + "from": [ + "country:AU" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Commonwealth of Australia" + } + }, + "nym:The Commonwealth of Dominica": { + "edges": { + "from": [ + "country:DM" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Commonwealth of Dominica" + } + }, + "nym:The Commonwealth of The Bahamas": { + "edges": { + "from": [ + "country:BS" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Commonwealth of The Bahamas" + } + }, + "nym:The Czech Republic": { + "edges": { + "from": [ + "country:CZ" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Czech Republic" + } + }, + "nym:The Democratic People's Republic of Korea": { + "edges": { + "from": [ + "country:KP" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Democratic People's Republic of Korea" + } + }, + "nym:The Democratic Republic of Sao Tome and Principe": { + "edges": { + "from": [ + "country:ST" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Democratic Republic of Sao Tome and Principe" + } + }, + "nym:The Democratic Republic of Timor-Leste": { + "edges": { + "from": [ + "country:TL" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Democratic Republic of Timor-Leste" + } + }, + "nym:The Democratic Republic of the Congo": { + "edges": { + "from": [ + "country:CD" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Democratic Republic of the Congo" + } + }, + "nym:The Democratic Socialist Republic of Sri Lanka": { + "edges": { + "from": [ + "country:LK" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Democratic Socialist Republic of Sri Lanka" + } + }, + "nym:The Dominican Republic": { + "edges": { + "from": [ + "country:DO" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Dominican Republic" + } + }, + "nym:The Federal Democratic Republic of Ethiopia": { + "edges": { + "from": [ + "country:ET" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Federal Democratic Republic of Ethiopia" + } + }, + "nym:The Federal Democratic Republic of Nepal": { + "edges": { + "from": [ + "country:NP" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Federal Democratic Republic of Nepal" + } + }, + "nym:The Federal Republic of Germany": { + "edges": { + "from": [ + "country:DE" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Federal Republic of Germany" + } + }, + "nym:The Federal Republic of Nigeria": { + "edges": { + "from": [ + "country:NG" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Federal Republic of Nigeria" + } + }, + "nym:The Federated States of Micronesia": { + "edges": { + "from": [ + "country:FM" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Federated States of Micronesia" + } + }, + "nym:The Federation of Saint Christopher and Nevis": { + "edges": { + "from": [ + "country:KN" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Federation of Saint Christopher and Nevis" + } + }, + "nym:The Federative Republic of Brazil": { + "edges": { + "from": [ + "country:BR" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Federative Republic of Brazil" + } + }, + "nym:The French Republic": { + "edges": { + "from": [ + "country:FR" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The French Republic" + } + }, + "nym:The Gabonese Republic": { + "edges": { + "from": [ + "country:GA" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Gabonese Republic" + } + }, + "nym:The Grand Duchy of Luxembourg": { + "edges": { + "from": [ + "country:LU" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Grand Duchy of Luxembourg" + } + }, + "nym:The Hashemite Kingdom of Jordan": { + "edges": { + "from": [ + "country:JO" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Hashemite Kingdom of Jordan" + } + }, + "nym:The Hellenic Republic": { + "edges": { + "from": [ + "country:GR" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Hellenic Republic" + } + }, + "nym:The Independent State of Papua New Guinea": { + "edges": { + "from": [ + "country:PG" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Independent State of Papua New Guinea" + } + }, + "nym:The Independent State of Samoa": { + "edges": { + "from": [ + "country:WS" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Independent State of Samoa" + } + }, + "nym:The Islamic Republic of Afghanistan": { + "edges": { + "from": [ + "country:AF" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Islamic Republic of Afghanistan" + } + }, + "nym:The Islamic Republic of Iran": { + "edges": { + "from": [ + "country:IR" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Islamic Republic of Iran" + } + }, + "nym:The Islamic Republic of Mauritania": { + "edges": { + "from": [ + "country:MR" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Islamic Republic of Mauritania" + } + }, + "nym:The Islamic Republic of Pakistan": { + "edges": { + "from": [ + "country:PK" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Islamic Republic of Pakistan" + } + }, + "nym:The Italian Republic": { + "edges": { + "from": [ + "country:IT" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Italian Republic" + } + }, + "nym:The Kingdom of Bahrain": { + "edges": { + "from": [ + "country:BH" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Kingdom of Bahrain" + } + }, + "nym:The Kingdom of Belgium": { + "edges": { + "from": [ + "country:BE" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Kingdom of Belgium" + } + }, + "nym:The Kingdom of Bhutan": { + "edges": { + "from": [ + "country:BT" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Kingdom of Bhutan" + } + }, + "nym:The Kingdom of Cambodia": { + "edges": { + "from": [ + "country:KH" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Kingdom of Cambodia" + } + }, + "nym:The Kingdom of Denmark": { + "edges": { + "from": [ + "country:DK" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Kingdom of Denmark" + } + }, + "nym:The Kingdom of Lesotho": { + "edges": { + "from": [ + "country:LS" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Kingdom of Lesotho" + } + }, + "nym:The Kingdom of Morocco": { + "edges": { + "from": [ + "country:MA" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Kingdom of Morocco" + } + }, + "nym:The Kingdom of Norway": { + "edges": { + "from": [ + "country:NO" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Kingdom of Norway" + } + }, + "nym:The Kingdom of Saudi Arabia": { + "edges": { + "from": [ + "country:SA" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Kingdom of Saudi Arabia" + } + }, + "nym:The Kingdom of Spain": { + "edges": { + "from": [ + "country:ES" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Kingdom of Spain" + } + }, + "nym:The Kingdom of Swaziland": { + "edges": { + "from": [ + "country:SZ" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Kingdom of Swaziland" + } + }, + "nym:The Kingdom of Sweden": { + "edges": { + "from": [ + "country:SE" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Kingdom of Sweden" + } + }, + "nym:The Kingdom of Thailand": { + "edges": { + "from": [ + "country:TH" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Kingdom of Thailand" + } + }, + "nym:The Kingdom of Tonga": { + "edges": { + "from": [ + "country:TO" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Kingdom of Tonga" + } + }, + "nym:The Kingdom of the Netherlands": { + "edges": { + "from": [ + "country:NL" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Kingdom of the Netherlands" + } + }, + "nym:The Kyrgyz Republic": { + "edges": { + "from": [ + "country:KG" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Kyrgyz Republic" + } + }, + "nym:The Lao People's Democratic Republic": { + "edges": { + "from": [ + "country:LA" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Lao People's Democratic Republic" + } + }, + "nym:The Lebanese Republic": { + "edges": { + "from": [ + "country:LB" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Lebanese Republic" + } + }, + "nym:The Occupied Palestinian Territories": { + "edges": { + "from": [ + "territory:PS" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Occupied Palestinian Territories" + } + }, + "nym:The Oriental Republic of Uruguay": { + "edges": { + "from": [ + "country:UY" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Oriental Republic of Uruguay" + } + }, + "nym:The People's Democratic Republic of Algeria": { + "edges": { + "from": [ + "country:DZ" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The People's Democratic Republic of Algeria" + } + }, + "nym:The People's Republic of Bangladesh": { + "edges": { + "from": [ + "country:BD" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The People's Republic of Bangladesh" + } + }, + "nym:The People's Republic of China": { + "edges": { + "from": [ + "country:CN" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The People's Republic of China" + } + }, + "nym:The Plurinational State of Bolivia": { + "edges": { + "from": [ + "country:BO" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Plurinational State of Bolivia" + } + }, + "nym:The Portuguese Republic": { + "edges": { + "from": [ + "country:PT" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Portuguese Republic" + } + }, + "nym:The Principality of Andorra": { + "edges": { + "from": [ + "country:AD" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Principality of Andorra" + } + }, + "nym:The Principality of Liechtenstein": { + "edges": { + "from": [ + "country:LI" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Principality of Liechtenstein" + } + }, + "nym:The Principality of Monaco": { + "edges": { + "from": [ + "country:MC" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Principality of Monaco" + } + }, + "nym:The Republic of Albania": { + "edges": { + "from": [ + "country:AL" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Republic of Albania" + } + }, + "nym:The Republic of Angola": { + "edges": { + "from": [ + "country:AO" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Republic of Angola" + } + }, + "nym:The Republic of Armenia": { + "edges": { + "from": [ + "country:AM" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Republic of Armenia" + } + }, + "nym:The Republic of Austria": { + "edges": { + "from": [ + "country:AT" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Republic of Austria" + } + }, + "nym:The Republic of Azerbaijan": { + "edges": { + "from": [ + "country:AZ" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Republic of Azerbaijan" + } + }, + "nym:The Republic of Belarus": { + "edges": { + "from": [ + "country:BY" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Republic of Belarus" + } + }, + "nym:The Republic of Benin": { + "edges": { + "from": [ + "country:BJ" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Republic of Benin" + } + }, + "nym:The Republic of Botswana": { + "edges": { + "from": [ + "country:BW" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Republic of Botswana" + } + }, + "nym:The Republic of Bulgaria": { + "edges": { + "from": [ + "country:BG" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Republic of Bulgaria" + } + }, + "nym:The Republic of Burundi": { + "edges": { + "from": [ + "country:BI" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Republic of Burundi" + } + }, + "nym:The Republic of Cabo Verde": { + "edges": { + "from": [ + "country:CV" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Republic of Cabo Verde" + } + }, + "nym:The Republic of Cameroon": { + "edges": { + "from": [ + "country:CM" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Republic of Cameroon" + } + }, + "nym:The Republic of Chad": { + "edges": { + "from": [ + "country:TD" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Republic of Chad" + } + }, + "nym:The Republic of Chile": { + "edges": { + "from": [ + "country:CL" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Republic of Chile" + } + }, + "nym:The Republic of Colombia": { + "edges": { + "from": [ + "country:CO" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Republic of Colombia" + } + }, + "nym:The Republic of Costa Rica": { + "edges": { + "from": [ + "country:CR" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Republic of Costa Rica" + } + }, + "nym:The Republic of Cote D'Ivoire": { + "edges": { + "from": [ + "country:CI" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Republic of Cote D'Ivoire" + } + }, + "nym:The Republic of Croatia": { + "edges": { + "from": [ + "country:HR" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Republic of Croatia" + } + }, + "nym:The Republic of Cuba": { + "edges": { + "from": [ + "country:CU" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Republic of Cuba" + } + }, + "nym:The Republic of Cyprus": { + "edges": { + "from": [ + "country:CY" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Republic of Cyprus" + } + }, + "nym:The Republic of Djibouti": { + "edges": { + "from": [ + "country:DJ" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Republic of Djibouti" + } + }, + "nym:The Republic of Ecuador": { + "edges": { + "from": [ + "country:EC" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Republic of Ecuador" + } + }, + "nym:The Republic of El Salvador": { + "edges": { + "from": [ + "country:SV" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Republic of El Salvador" + } + }, + "nym:The Republic of Equatorial Guinea": { + "edges": { + "from": [ + "country:GQ" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Republic of Equatorial Guinea" + } + }, + "nym:The Republic of Estonia": { + "edges": { + "from": [ + "country:EE" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Republic of Estonia" + } + }, + "nym:The Republic of Fiji": { + "edges": { + "from": [ + "country:FJ" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Republic of Fiji" + } + }, + "nym:The Republic of Finland": { + "edges": { + "from": [ + "country:FI" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Republic of Finland" + } + }, + "nym:The Republic of Ghana": { + "edges": { + "from": [ + "country:GH" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Republic of Ghana" + } + }, + "nym:The Republic of Guatemala": { + "edges": { + "from": [ + "country:GT" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Republic of Guatemala" + } + }, + "nym:The Republic of Guinea": { + "edges": { + "from": [ + "country:GN" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Republic of Guinea" + } + }, + "nym:The Republic of Guinea-Bissau": { + "edges": { + "from": [ + "country:GW" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Republic of Guinea-Bissau" + } + }, + "nym:The Republic of Haiti": { + "edges": { + "from": [ + "country:HT" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Republic of Haiti" + } + }, + "nym:The Republic of Honduras": { + "edges": { + "from": [ + "country:HN" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Republic of Honduras" + } + }, + "nym:The Republic of Iceland": { + "edges": { + "from": [ + "country:IS" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Republic of Iceland" + } + }, + "nym:The Republic of India": { + "edges": { + "from": [ + "country:IN" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Republic of India" + } + }, + "nym:The Republic of Indonesia": { + "edges": { + "from": [ + "country:ID" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Republic of Indonesia" + } + }, + "nym:The Republic of Iraq": { + "edges": { + "from": [ + "country:IQ" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Republic of Iraq" + } + }, + "nym:The Republic of Kazakhstan": { + "edges": { + "from": [ + "country:KZ" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Republic of Kazakhstan" + } + }, + "nym:The Republic of Kenya": { + "edges": { + "from": [ + "country:KE" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Republic of Kenya" + } + }, + "nym:The Republic of Kiribati": { + "edges": { + "from": [ + "country:KI" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Republic of Kiribati" + } + }, + "nym:The Republic of Korea": { + "edges": { + "from": [ + "country:KR" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Republic of Korea" + } + }, + "nym:The Republic of Kosovo": { + "edges": { + "from": [ + "country:XK" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Republic of Kosovo" + } + }, + "nym:The Republic of Latvia": { + "edges": { + "from": [ + "country:LV" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Republic of Latvia" + } + }, + "nym:The Republic of Liberia": { + "edges": { + "from": [ + "country:LR" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Republic of Liberia" + } + }, + "nym:The Republic of Lithuania": { + "edges": { + "from": [ + "country:LT" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Republic of Lithuania" + } + }, + "nym:The Republic of Macedonia": { + "edges": { + "from": [ + "country:MK" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Republic of Macedonia" + } + }, + "nym:The Republic of Madagascar": { + "edges": { + "from": [ + "country:MG" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Republic of Madagascar" + } + }, + "nym:The Republic of Malawi": { + "edges": { + "from": [ + "country:MW" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Republic of Malawi" + } + }, + "nym:The Republic of Maldives": { + "edges": { + "from": [ + "country:MV" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Republic of Maldives" + } + }, + "nym:The Republic of Mali": { + "edges": { + "from": [ + "country:ML" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Republic of Mali" + } + }, + "nym:The Republic of Malta": { + "edges": { + "from": [ + "country:MT" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Republic of Malta" + } + }, + "nym:The Republic of Mauritius": { + "edges": { + "from": [ + "country:MU" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Republic of Mauritius" + } + }, + "nym:The Republic of Moldova": { + "edges": { + "from": [ + "country:MD" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Republic of Moldova" + } + }, + "nym:The Republic of Mozambique": { + "edges": { + "from": [ + "country:MZ" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Republic of Mozambique" + } + }, + "nym:The Republic of Namibia": { + "edges": { + "from": [ + "country:NA" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Republic of Namibia" + } + }, + "nym:The Republic of Nauru": { + "edges": { + "from": [ + "country:NR" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Republic of Nauru" + } + }, + "nym:The Republic of Nicaragua": { + "edges": { + "from": [ + "country:NI" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Republic of Nicaragua" + } + }, + "nym:The Republic of Niger": { + "edges": { + "from": [ + "country:NE" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Republic of Niger" + } + }, + "nym:The Republic of Palau": { + "edges": { + "from": [ + "country:PW" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Republic of Palau" + } + }, + "nym:The Republic of Panama": { + "edges": { + "from": [ + "country:PA" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Republic of Panama" + } + }, + "nym:The Republic of Paraguay": { + "edges": { + "from": [ + "country:PY" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Republic of Paraguay" + } + }, + "nym:The Republic of Peru": { + "edges": { + "from": [ + "country:PE" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Republic of Peru" + } + }, + "nym:The Republic of Poland": { + "edges": { + "from": [ + "country:PL" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Republic of Poland" + } + }, + "nym:The Republic of Rwanda": { + "edges": { + "from": [ + "country:RW" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Republic of Rwanda" + } + }, + "nym:The Republic of San Marino": { + "edges": { + "from": [ + "country:SM" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Republic of San Marino" + } + }, + "nym:The Republic of Senegal": { + "edges": { + "from": [ + "country:SN" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Republic of Senegal" + } + }, + "nym:The Republic of Serbia": { + "edges": { + "from": [ + "country:RS" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Republic of Serbia" + } + }, + "nym:The Republic of Seychelles": { + "edges": { + "from": [ + "country:SC" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Republic of Seychelles" + } + }, + "nym:The Republic of Sierra Leone": { + "edges": { + "from": [ + "country:SL" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Republic of Sierra Leone" + } + }, + "nym:The Republic of Singapore": { + "edges": { + "from": [ + "country:SG" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Republic of Singapore" + } + }, + "nym:The Republic of Slovenia": { + "edges": { + "from": [ + "country:SI" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Republic of Slovenia" + } + }, + "nym:The Republic of South Africa": { + "edges": { + "from": [ + "country:ZA" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Republic of South Africa" + } + }, + "nym:The Republic of South Sudan": { + "edges": { + "from": [ + "country:SS" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Republic of South Sudan" + } + }, + "nym:The Republic of Suriname": { + "edges": { + "from": [ + "country:SR" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Republic of Suriname" + } + }, + "nym:The Republic of Tajikistan": { + "edges": { + "from": [ + "country:TJ" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Republic of Tajikistan" + } + }, + "nym:The Republic of The Gambia": { + "edges": { + "from": [ + "country:GM" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Republic of The Gambia" + } + }, + "nym:The Republic of Trinidad and Tobago": { + "edges": { + "from": [ + "country:TT" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Republic of Trinidad and Tobago" + } + }, + "nym:The Republic of Turkey": { + "edges": { + "from": [ + "country:TR" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Republic of Turkey" + } + }, + "nym:The Republic of Uganda": { + "edges": { + "from": [ + "country:UG" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Republic of Uganda" + } + }, + "nym:The Republic of Uzbekistan": { + "edges": { + "from": [ + "country:UZ" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Republic of Uzbekistan" + } + }, + "nym:The Republic of Vanuatu": { + "edges": { + "from": [ + "country:VU" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Republic of Vanuatu" + } + }, + "nym:The Republic of Yemen": { + "edges": { + "from": [ + "country:YE" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Republic of Yemen" + } + }, + "nym:The Republic of Zambia": { + "edges": { + "from": [ + "country:ZM" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Republic of Zambia" + } + }, + "nym:The Republic of Zimbabwe": { + "edges": { + "from": [ + "country:ZW" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Republic of Zimbabwe" + } + }, + "nym:The Republic of the Congo": { + "edges": { + "from": [ + "country:CG" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Republic of the Congo" + } + }, + "nym:The Republic of the Marshall Islands": { + "edges": { + "from": [ + "country:MH" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Republic of the Marshall Islands" + } + }, + "nym:The Republic of the Philippines": { + "edges": { + "from": [ + "country:PH" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Republic of the Philippines" + } + }, + "nym:The Republic of the Sudan": { + "edges": { + "from": [ + "country:SD" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Republic of the Sudan" + } + }, + "nym:The Republic of the Union of Myanmar": { + "edges": { + "from": [ + "country:MM" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Republic of the Union of Myanmar" + } + }, + "nym:The Russian Federation": { + "edges": { + "from": [ + "country:RU" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Russian Federation" + } + }, + "nym:The Slovak Republic": { + "edges": { + "from": [ + "country:SK" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Slovak Republic" + } + }, + "nym:The Socialist Republic of Vietnam": { + "edges": { + "from": [ + "country:VN" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Socialist Republic of Vietnam" + } + }, + "nym:The State of Eritrea": { + "edges": { + "from": [ + "country:ER" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The State of Eritrea" + } + }, + "nym:The State of Israel": { + "edges": { + "from": [ + "country:IL" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The State of Israel" + } + }, + "nym:The State of Kuwait": { + "edges": { + "from": [ + "country:KW" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The State of Kuwait" + } + }, + "nym:The State of Qatar": { + "edges": { + "from": [ + "country:QA" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The State of Qatar" + } + }, + "nym:The States": { + "edges": { + "from": [ + "country:US" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "The States" + } + }, + "nym:The Sultanate of Oman": { + "edges": { + "from": [ + "country:OM" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Sultanate of Oman" + } + }, + "nym:The Swiss Confederation": { + "edges": { + "from": [ + "country:CH" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Swiss Confederation" + } + }, + "nym:The Syrian Arab Republic": { + "edges": { + "from": [ + "country:SY" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Syrian Arab Republic" + } + }, + "nym:The Togolese Republic": { + "edges": { + "from": [ + "country:TG" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Togolese Republic" + } + }, + "nym:The Tunisian Republic": { + "edges": { + "from": [ + "country:TN" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Tunisian Republic" + } + }, + "nym:The Union of the Comoros": { + "edges": { + "from": [ + "country:KM" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Union of the Comoros" + } + }, + "nym:The United Arab Emirates": { + "edges": { + "from": [ + "country:AE" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The United Arab Emirates" + } + }, + "nym:The United Kingdom of Great Britain and Northern Ireland": { + "edges": { + "from": [ + "country:GB" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The United Kingdom of Great Britain and Northern Ireland" + } + }, + "nym:The United Mexican States": { + "edges": { + "from": [ + "country:MX" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The United Mexican States" + } + }, + "nym:The United Republic of Tanzania": { + "edges": { + "from": [ + "country:TZ" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The United Republic of Tanzania" + } + }, + "nym:The United States of America": { + "edges": { + "from": [ + "country:US" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The United States of America" + } + }, + "nym:The Virgin Islands": { + "edges": { + "from": [ + "territory:VG" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "The Virgin Islands" + } + }, + "nym:Timor Lorosa'e": { + "edges": { + "from": [ + "country:TL" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Timor Lorosa'e" + } + }, + "nym:Timor-Leste": { + "edges": { + "from": [ + "country:TL" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Timor-Leste" + } + }, + "nym:Togolese": { + "edges": { + "from": [ + "country:TG" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Togolese" + } + }, + "nym:Togolese Republic": { + "edges": { + "from": [ + "country:TG" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Togolese Republic" + } + }, + "nym:Tojikistan": { + "edges": { + "from": [ + "country:TJ" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Tojikistan" + } + }, + "nym:Tokelau": { + "edges": { + "from": [ + "territory:TK" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Tokelau" + } + }, + "nym:Toçikiston": { + "edges": { + "from": [ + "country:TJ" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Toçikiston" + } + }, + "nym:Tristan da Cunha": { + "edges": { + "from": [ + "territory:SH-TA" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Tristan da Cunha" + } + }, + "nym:Tunes": { + "edges": { + "from": [ + "country:TN" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Tunes" + } + }, + "nym:Tunisian Republic": { + "edges": { + "from": [ + "country:TN" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Tunisian Republic" + } + }, + "nym:Turkiye": { + "edges": { + "from": [ + "country:TR" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Turkiye" + } + }, + "nym:Turkmen": { + "edges": { + "from": [ + "country:TM" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Turkmen" + } + }, + "nym:Turkmenia": { + "edges": { + "from": [ + "country:TM" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Turkmenia" + } + }, + "nym:Turkmenistan": { + "edges": { + "from": [ + "country:TM" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Turkmenistan" + } + }, + "nym:Turkomen": { + "edges": { + "from": [ + "country:TM" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Turkomen" + } + }, + "nym:Turks and Caicos Islands": { + "edges": { + "from": [ + "territory:TC" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Turks and Caicos Islands" + } + }, + "nym:Tuvalu": { + "edges": { + "from": [ + "country:TV" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Tuvalu" + } + }, + "nym:Táiwān": { + "edges": { + "from": [ + "territory:TW" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Táiwān" + } + }, + "nym:Türkiye": { + "edges": { + "from": [ + "country:TR" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Türkiye" + } + }, + "nym:Türkmenistan": { + "edges": { + "from": [ + "country:TM" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Türkmenistan" + } + }, + "nym:Tšād": { + "edges": { + "from": [ + "country:TD" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Tšād" + } + }, + "nym:Tūns": { + "edges": { + "from": [ + "country:TN" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Tūns" + } + }, + "nym:U.K.": { + "edges": { + "from": [ + "country:GB" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "U.K." + } + }, + "nym:U.S.": { + "edges": { + "from": [ + "country:US" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "U.S." + } + }, + "nym:U.S.A.": { + "edges": { + "from": [ + "country:US" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "U.S.A." + } + }, + "nym:UA": { + "edges": { + "from": [ + "country:UA" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "UA" + } + }, + "nym:UAE": { + "edges": { + "from": [ + "country:AE" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "UAE" + } + }, + "nym:UG": { + "edges": { + "from": [ + "country:UG" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "UG" + } + }, + "nym:UK": { + "edges": { + "from": [ + "country:GB" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "UK" + } + }, + "nym:UM-67": { + "edges": { + "from": [ + "territory:UM-67" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "UM-67" + } + }, + "nym:UM-71": { + "edges": { + "from": [ + "territory:UM-71" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "UM-71" + } + }, + "nym:UM-76": { + "edges": { + "from": [ + "territory:UM-76" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "UM-76" + } + }, + "nym:UM-81": { + "edges": { + "from": [ + "territory:UM-81" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "UM-81" + } + }, + "nym:UM-84": { + "edges": { + "from": [ + "territory:UM-84" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "UM-84" + } + }, + "nym:UM-86": { + "edges": { + "from": [ + "territory:UM-86" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "UM-86" + } + }, + "nym:UM-89": { + "edges": { + "from": [ + "territory:UM-89" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "UM-89" + } + }, + "nym:UM-95": { + "edges": { + "from": [ + "territory:UM-95" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "UM-95" + } + }, + "nym:US": { + "edges": { + "from": [ + "country:US" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "US" + } + }, + "nym:USA": { + "edges": { + "from": [ + "country:US" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "USA" + } + }, + "nym:UY": { + "edges": { + "from": [ + "country:UY" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "UY" + } + }, + "nym:UZ": { + "edges": { + "from": [ + "country:UZ" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "UZ" + } + }, + "nym:Ukraine": { + "edges": { + "from": [ + "country:UA" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Ukraine" + } + }, + "nym:Ukrayina": { + "edges": { + "from": [ + "country:UA" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Ukrayina" + } + }, + "nym:Ukraїna": { + "edges": { + "from": [ + "country:UA" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Ukraїna" + } + }, + "nym:Umbuso weSwatini": { + "edges": { + "from": [ + "country:SZ" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Umbuso weSwatini" + } + }, + "nym:Union of Soviet Socialist Republics": { + "edges": { + "from": [ + "country:SU" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Union of Soviet Socialist Republics" + } + }, + "nym:Union of the Comoros": { + "edges": { + "from": [ + "country:KM" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Union of the Comoros" + } + }, + "nym:Unit States": { + "edges": { + "from": [ + "country:US" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Unit States" + } + }, + "nym:Unite States": { + "edges": { + "from": [ + "country:US" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Unite States" + } + }, + "nym:United Arab Emirates": { + "edges": { + "from": [ + "country:AE" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "United Arab Emirates" + } + }, + "nym:United Arab Republic": { + "edges": { + "from": [ + "country:EG" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "United Arab Republic" + } + }, + "nym:United Kingdom of Great Britain and Northern Ireland": { + "edges": { + "from": [ + "country:GB" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "United Kingdom of Great Britain and Northern Ireland" + } + }, + "nym:United Mexican States": { + "edges": { + "from": [ + "country:MX" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "United Mexican States" + } + }, + "nym:United Republic of Tanzania": { + "edges": { + "from": [ + "country:TZ" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "United Republic of Tanzania" + } + }, + "nym:United Sat": { + "edges": { + "from": [ + "country:US" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "United Sat" + } + }, + "nym:United Staes": { + "edges": { + "from": [ + "country:US" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "United Staes" + } + }, + "nym:United Stated": { + "edges": { + "from": [ + "country:US" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "United Stated" + } + }, + "nym:United States of America": { + "edges": { + "from": [ + "country:US" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "United States of America" + } + }, + "nym:United Stats": { + "edges": { + "from": [ + "country:US" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "United Stats" + } + }, + "nym:United Sttes": { + "edges": { + "from": [ + "country:US" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "United Sttes" + } + }, + "nym:Unites States": { + "edges": { + "from": [ + "country:US" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Unites States" + } + }, + "nym:Unitit Kinrick": { + "edges": { + "from": [ + "country:GB" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Unitit Kinrick" + } + }, + "nym:Untied State": { + "edges": { + "from": [ + "country:US" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Untied State" + } + }, + "nym:Uvea mo Futuna": { + "edges": { + "from": [ + "territory:WF" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Uvea mo Futuna" + } + }, + "nym:Uzbek": { + "edges": { + "from": [ + "country:UZ" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Uzbek" + } + }, + "nym:VA": { + "edges": { + "from": [ + "country:VA" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "VA" + } + }, + "nym:VC": { + "edges": { + "from": [ + "country:VC" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "VC" + } + }, + "nym:VE": { + "edges": { + "from": [ + "country:VE" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "VE" + } + }, + "nym:VG": { + "edges": { + "from": [ + "territory:VG" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "VG" + } + }, + "nym:VI": { + "edges": { + "from": [ + "territory:VI" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "VI" + } + }, + "nym:VN": { + "edges": { + "from": [ + "country:VN" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "VN" + } + }, + "nym:VU": { + "edges": { + "from": [ + "country:VU" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "VU" + } + }, + "nym:Vatican City State": { + "edges": { + "from": [ + "country:VA" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Vatican City State" + } + }, + "nym:Veitnam": { + "edges": { + "from": [ + "country:VN" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Veitnam" + } + }, + "nym:Venezuela": { + "edges": { + "from": [ + "country:VE" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Venezuela" + } + }, + "nym:Venezula": { + "edges": { + "from": [ + "country:VE" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Venezula" + } + }, + "nym:Vietman": { + "edges": { + "from": [ + "country:VN" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Vietman" + } + }, + "nym:Virgin Islands": { + "edges": { + "from": [ + "territory:VG" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Virgin Islands" + } + }, + "nym:Virgin Islands of the United States": { + "edges": { + "from": [ + "territory:VI" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Virgin Islands of the United States" + } + }, + "nym:Virgina Islands": { + "edges": { + "from": [ + "territory:VG" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Virgina Islands" + } + }, + "nym:Viti": { + "edges": { + "from": [ + "country:FJ" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Viti" + } + }, + "nym:Việt Nam": { + "edges": { + "from": [ + "country:VN" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Việt Nam" + } + }, + "nym:Volta": { + "edges": { + "from": [ + "country:BF" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Volta" + } + }, + "nym:Volívia": { + "edges": { + "from": [ + "country:BO" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Volívia" + } + }, + "nym:WF": { + "edges": { + "from": [ + "territory:WF" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "WF" + } + }, + "nym:WLS": { + "edges": { + "from": [ + "uk:WLS" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "WLS" + } + }, + "nym:WS": { + "edges": { + "from": [ + "country:WS" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "WS" + } + }, + "nym:Wales": { + "edges": { + "from": [ + "uk:WLS" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Wales" + } + }, + "nym:Wallis-et-Futuna": { + "edges": { + "from": [ + "territory:WF" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Wallis-et-Futuna" + } + }, + "nym:West Pakistan": { + "edges": { + "from": [ + "country:PK" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "West Pakistan" + } + }, + "nym:Western Sahara": { + "edges": { + "from": [ + "territory:EH" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Western Sahara" + } + }, + "nym:Western samoa": { + "edges": { + "from": [ + "country:WS" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Western samoa" + } + }, + "nym:White Russia": { + "edges": { + "from": [ + "country:BY" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "White Russia" + } + }, + "nym:Wuliwya": { + "edges": { + "from": [ + "country:BO" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Wuliwya" + } + }, + "nym:XK": { + "edges": { + "from": [ + "country:XK" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "XK" + } + }, + "nym:XQZ": { + "edges": { + "from": [ + "territory:XQZ" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "XQZ" + } + }, + "nym:XXD": { + "edges": { + "from": [ + "territory:XXD" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "XXD" + } + }, + "nym:Xīnjiāpō": { + "edges": { + "from": [ + "country:SG" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Xīnjiāpō" + } + }, + "nym:Y Deyrnas Unedig": { + "edges": { + "from": [ + "country:GB" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Y Deyrnas Unedig" + } + }, + "nym:YE": { + "edges": { + "from": [ + "country:YE" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "YE" + } + }, + "nym:YT": { + "edges": { + "from": [ + "territory:YT" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "YT" + } + }, + "nym:YU": { + "edges": { + "from": [ + "country:YU" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "YU" + } + }, + "nym:Yaltopya": { + "edges": { + "from": [ + "country:ET" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Yaltopya" + } + }, + "nym:Yisra'el": { + "edges": { + "from": [ + "country:IL" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Yisra'el" + } + }, + "nym:Yisrael": { + "edges": { + "from": [ + "country:IL" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Yisrael" + } + }, + "nym:Yugosav": { + "edges": { + "from": [ + "country:YU" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Yugosav" + } + }, + "nym:ZA": { + "edges": { + "from": [ + "country:ZA" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "ZA" + } + }, + "nym:ZM": { + "edges": { + "from": [ + "country:ZM" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "ZM" + } + }, + "nym:ZW": { + "edges": { + "from": [ + "country:ZW" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "ZW" + } + }, + "nym:Zealnd": { + "edges": { + "from": [ + "country:NZ" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Zealnd" + } + }, + "nym:Zeland": { + "edges": { + "from": [ + "country:NZ" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Zeland" + } + }, + "nym:Zhongguo": { + "edges": { + "from": [ + "country:CN" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Zhongguo" + } + }, + "nym:Zhonghua": { + "edges": { + "from": [ + "country:CN" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Zhonghua" + } + }, + "nym:Zhonghua Peoples Republic": { + "edges": { + "from": [ + "country:CN" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Zhonghua Peoples Republic" + } + }, + "nym:Zhōngguó": { + "edges": { + "from": [ + "country:CN" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Zhōngguó" + } + }, + "nym:Zhōnghuá Mínguó": { + "edges": { + "from": [ + "territory:TW" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Zhōnghuá Mínguó" + } + }, + "nym:Zion": { + "edges": { + "from": [ + "country:IL" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Zion" + } + }, + "nym:Ztate of Katar": { + "edges": { + "from": [ + "country:QA" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Ztate of Katar" + } + }, + "nym:afganastan": { + "edges": { + "from": [ + "country:AF" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "afganastan" + } + }, + "nym:afganestan": { + "edges": { + "from": [ + "country:AF" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "afganestan" + } + }, + "nym:afganhistan": { + "edges": { + "from": [ + "country:AF" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "afganhistan" + } + }, + "nym:afganistan": { + "edges": { + "from": [ + "country:AF" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "afganistan" + } + }, + "nym:afghanistan": { + "edges": { + "from": [ + "country:AF" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "afghanistan" + } + }, + "nym:afghanlstan": { + "edges": { + "from": [ + "country:AF" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "afghanlstan" + } + }, + "nym:afghistan": { + "edges": { + "from": [ + "country:AF" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "afghistan" + } + }, + "nym:aigeria": { + "edges": { + "from": [ + "country:DZ" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "aigeria" + } + }, + "nym:alabnia": { + "edges": { + "from": [ + "country:AL" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "alabnia" + } + }, + "nym:albana": { + "edges": { + "from": [ + "country:AL" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "albana" + } + }, + "nym:albania": { + "edges": { + "from": [ + "country:AL" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "albania" + } + }, + "nym:albanian": { + "edges": { + "from": [ + "country:AL" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "albanian" + } + }, + "nym:albanija": { + "edges": { + "from": [ + "country:AL" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "albanija" + } + }, + "nym:albenia": { + "edges": { + "from": [ + "country:AL" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "albenia" + } + }, + "nym:albiana": { + "edges": { + "from": [ + "country:AL" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "albiana" + } + }, + "nym:alegeria": { + "edges": { + "from": [ + "country:DZ" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "alegeria" + } + }, + "nym:algeir": { + "edges": { + "from": [ + "country:DZ" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "algeir" + } + }, + "nym:algeirs": { + "edges": { + "from": [ + "country:DZ" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "algeirs" + } + }, + "nym:algeria": { + "edges": { + "from": [ + "country:DZ" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "algeria" + } + }, + "nym:algers": { + "edges": { + "from": [ + "country:DZ" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "algers" + } + }, + "nym:algieria": { + "edges": { + "from": [ + "country:DZ" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "algieria" + } + }, + "nym:algiers": { + "edges": { + "from": [ + "country:DZ" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "algiers" + } + }, + "nym:alibania": { + "edges": { + "from": [ + "country:AL" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "alibania" + } + }, + "nym:americia": { + "edges": { + "from": [ + "country:US" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "americia" + } + }, + "nym:angolo": { + "edges": { + "from": [ + "country:AO" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "angolo" + } + }, + "nym:arab emir ates": { + "edges": { + "from": [ + "country:AE" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "arab emir ates" + } + }, + "nym:arab emirates": { + "edges": { + "from": [ + "country:AE" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "arab emirates" + } + }, + "nym:argentiha": { + "edges": { + "from": [ + "country:AR" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "argentiha" + } + }, + "nym:argentina": { + "edges": { + "from": [ + "country:AR" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "argentina" + } + }, + "nym:argentine": { + "edges": { + "from": [ + "country:AR" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "argentine" + } + }, + "nym:argentna": { + "edges": { + "from": [ + "country:AR" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "argentna" + } + }, + "nym:arima": { + "edges": { + "from": [ + "country:AM" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "arima" + } + }, + "nym:armenia": { + "edges": { + "from": [ + "country:AM" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "armenia" + } + }, + "nym:arminia": { + "edges": { + "from": [ + "country:AM" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "arminia" + } + }, + "nym:ausralia": { + "edges": { + "from": [ + "country:AU" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "ausralia" + } + }, + "nym:austalia": { + "edges": { + "from": [ + "country:AU" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "austalia" + } + }, + "nym:austraila": { + "edges": { + "from": [ + "country:AU" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "austraila" + } + }, + "nym:austrailia": { + "edges": { + "from": [ + "country:AU" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "austrailia" + } + }, + "nym:australa": { + "edges": { + "from": [ + "country:AU" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "australa" + } + }, + "nym:australla": { + "edges": { + "from": [ + "country:AU" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "australla" + } + }, + "nym:austrilia": { + "edges": { + "from": [ + "country:AU" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "austrilia" + } + }, + "nym:austrlia": { + "edges": { + "from": [ + "country:AU" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "austrlia" + } + }, + "nym:autralia": { + "edges": { + "from": [ + "country:AU" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "autralia" + } + }, + "nym:avstralia": { + "edges": { + "from": [ + "country:AU" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "avstralia" + } + }, + "nym:avstria": { + "edges": { + "from": [ + "country:AU" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "avstria" + } + }, + "nym:azebaijan": { + "edges": { + "from": [ + "country:AZ" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "azebaijan" + } + }, + "nym:azeraijan": { + "edges": { + "from": [ + "country:AZ" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "azeraijan" + } + }, + "nym:azerbaijan": { + "edges": { + "from": [ + "country:AZ" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "azerbaijan" + } + }, + "nym:azerbaijann": { + "edges": { + "from": [ + "country:AZ" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "azerbaijann" + } + }, + "nym:azerbaisan": { + "edges": { + "from": [ + "country:AZ" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "azerbaisan" + } + }, + "nym:azerbaizan": { + "edges": { + "from": [ + "country:AZ" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "azerbaizan" + } + }, + "nym:azerbajan": { + "edges": { + "from": [ + "country:AZ" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "azerbajan" + } + }, + "nym:azerbaycan": { + "edges": { + "from": [ + "country:AZ" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "azerbaycan" + } + }, + "nym:azerbayjan": { + "edges": { + "from": [ + "country:AZ" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "azerbayjan" + } + }, + "nym:azerbeyjan": { + "edges": { + "from": [ + "country:AZ" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "azerbeyjan" + } + }, + "nym:azerbpijan": { + "edges": { + "from": [ + "country:AZ" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "azerbpijan" + } + }, + "nym:aş-Şūmāl": { + "edges": { + "from": [ + "country:SO" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "aş-Şūmāl" + } + }, + "nym:iNingizimu Afrika": { + "edges": { + "from": [ + "country:ZA" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "iNingizimu Afrika" + } + }, + "nym:iSewula Afrika": { + "edges": { + "from": [ + "country:ZA" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "iSewula Afrika" + } + }, + "nym:il-ikwet": { + "edges": { + "from": [ + "country:KW" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "il-ikwet" + } + }, + "nym:kaNgwane": { + "edges": { + "from": [ + "country:SZ" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "kaNgwane" + } + }, + "nym:uMzantsi Afrika": { + "edges": { + "from": [ + "country:ZA" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "uMzantsi Afrika" + } + }, + "nym:weSwatini": { + "edges": { + "from": [ + "country:SZ" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "weSwatini" + } + }, + "nym:weSwatini Swatini Ngwane": { + "edges": { + "from": [ + "country:SZ" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "weSwatini Swatini Ngwane" + } + }, + "nym:Åland": { + "edges": { + "from": [ + "territory:AX" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Åland" + } + }, + "nym:Åland Islands": { + "edges": { + "from": [ + "territory:AX" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Åland Islands" + } + }, + "nym:Éire": { + "edges": { + "from": [ + "country:IE" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Éire" + } + }, + "nym:États-Unis": { + "edges": { + "from": [ + "country:US" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "États-Unis" + } + }, + "nym:Ísland": { + "edges": { + "from": [ + "country:IS" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Ísland" + } + }, + "nym:Îraq": { + "edges": { + "from": [ + "country:IQ" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Îraq" + } + }, + "nym:Österreich": { + "edges": { + "from": [ + "country:AT" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Österreich" + } + }, + "nym:Česko": { + "edges": { + "from": [ + "country:CZ" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Česko" + } + }, + "nym:Česká": { + "edges": { + "from": [ + "country:CZ" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Česká" + } + }, + "nym:Česká republika": { + "edges": { + "from": [ + "country:CZ" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Česká republika" + } + }, + "nym:Īrān": { + "edges": { + "from": [ + "country:IR" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Īrān" + } + }, + "nym:Ελλάδα": { + "edges": { + "from": [ + "country:GR" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Ελλάδα" + } + }, + "nym:Ελλάς": { + "edges": { + "from": [ + "country:GR" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Ελλάς" + } + }, + "nym:Κύπρος": { + "edges": { + "from": [ + "country:CY" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Κύπρος" + } + }, + "nym:Ўзбекистон": { + "edges": { + "from": [ + "country:UZ" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Ўзбекистон" + } + }, + "nym:Беларусь": { + "edges": { + "from": [ + "country:BY" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Беларусь" + } + }, + "nym:Босна и Херцеговина": { + "edges": { + "from": [ + "country:BA" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Босна и Херцеговина" + } + }, + "nym:България": { + "edges": { + "from": [ + "country:BG" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "България" + } + }, + "nym:Казахстан": { + "edges": { + "from": [ + "country:KZ" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Казахстан" + } + }, + "nym:Киргизия": { + "edges": { + "from": [ + "country:KG" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Киргизия" + } + }, + "nym:Косово": { + "edges": { + "from": [ + "country:XK" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Косово" + } + }, + "nym:Кыргызстан": { + "edges": { + "from": [ + "country:KG" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Кыргызстан" + } + }, + "nym:Македонија": { + "edges": { + "from": [ + "country:MK" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Македонија" + } + }, + "nym:Монгол Улс": { + "edges": { + "from": [ + "country:MN" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Монгол Улс" + } + }, + "nym:Монгол улс": { + "edges": { + "from": [ + "country:MN" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Монгол улс" + } + }, + "nym:Российская": { + "edges": { + "from": [ + "country:RU" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Российская" + } + }, + "nym:Россия": { + "edges": { + "from": [ + "country:RU" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Россия" + } + }, + "nym:Россия1": { + "edges": { + "from": [ + "country:RU" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Россия1" + } + }, + "nym:Союз Советских Социалистических Республик": { + "edges": { + "from": [ + "country:SU" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Союз Советских Социалистических Республик" + } + }, + "nym:Србија": { + "edges": { + "from": [ + "country:RS" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Србија" + } + }, + "nym:Србија Srbija": { + "edges": { + "from": [ + "country:RS" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Србија Srbija" + } + }, + "nym:Тоҷикистон": { + "edges": { + "from": [ + "country:TJ" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Тоҷикистон" + } + }, + "nym:Україна": { + "edges": { + "from": [ + "country:UA" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Україна" + } + }, + "nym:Црна Гора": { + "edges": { + "from": [ + "country:ME" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Црна Гора" + } + }, + "nym:Қазақстан": { + "edges": { + "from": [ + "country:KZ" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Қазақстан" + } + }, + "nym:Հայաստան": { + "edges": { + "from": [ + "country:AM" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "Հայաստան" + } + }, + "nym:ישראל": { + "edges": { + "from": [ + "country:IL" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "ישראל" + } + }, + "nym:إرتريا": { + "edges": { + "from": [ + "country:ER" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "إرتريا" + } + }, + "nym:إسرائيل": { + "edges": { + "from": [ + "country:IL" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "إسرائيل" + } + }, + "nym:إسرائيل ישראל": { + "edges": { + "from": [ + "country:IL" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "إسرائيل ישראל" + } + }, + "nym:افغانستان": { + "edges": { + "from": [ + "country:AF" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "افغانستان" + } + }, + "nym:الأردن": { + "edges": { + "from": [ + "country:JO" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "الأردن" + } + }, + "nym:الإمارات": { + "edges": { + "from": [ + "country:AE" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "الإمارات" + } + }, + "nym:الإمارات العربيّة المتّحدة": { + "edges": { + "from": [ + "country:AE" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "الإمارات العربيّة المتّحدة" + } + }, + "nym:البحرين": { + "edges": { + "from": [ + "country:BH" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "البحرين" + } + }, + "nym:الجزائر": { + "edges": { + "from": [ + "country:DZ" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "الجزائر" + } + }, + "nym:السعودية": { + "edges": { + "from": [ + "country:SA" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "السعودية" + } + }, + "nym:السودان": { + "edges": { + "from": [ + "country:SD" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "السودان" + } + }, + "nym:الصومال": { + "edges": { + "from": [ + "country:SO" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "الصومال" + } + }, + "nym:العراق": { + "edges": { + "from": [ + "country:IQ" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "العراق" + } + }, + "nym:العراق‎": { + "edges": { + "from": [ + "country:IQ" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "العراق‎" + } + }, + "nym:الكويت": { + "edges": { + "from": [ + "country:KW" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "الكويت" + } + }, + "nym:المغرب": { + "edges": { + "from": [ + "country:MA" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "المغرب" + } + }, + "nym:المملكة العربية السعودية": { + "edges": { + "from": [ + "country:SA" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "المملكة العربية السعودية" + } + }, + "nym:الموريتانية": { + "edges": { + "from": [ + "country:MR" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "الموريتانية" + } + }, + "nym:اليمن": { + "edges": { + "from": [ + "country:YE" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "اليمن" + } + }, + "nym:ایران": { + "edges": { + "from": [ + "country:IR" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "ایران" + } + }, + "nym:بروني": { + "edges": { + "from": [ + "country:BN" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "بروني" + } + }, + "nym:تشاد": { + "edges": { + "from": [ + "country:TD" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "تشاد" + } + }, + "nym:تشاد‎": { + "edges": { + "from": [ + "country:TD" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "تشاد‎" + } + }, + "nym:تونس": { + "edges": { + "from": [ + "country:TN" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "تونس" + } + }, + "nym:جز القمر": { + "edges": { + "from": [ + "country:KM" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "جز القمر" + } + }, + "nym:جزر القمر": { + "edges": { + "from": [ + "country:KM" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "جزر القمر" + } + }, + "nym:جيبوتي": { + "edges": { + "from": [ + "country:DJ" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "جيبوتي" + } + }, + "nym:جيبوتي‎": { + "edges": { + "from": [ + "country:DJ" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "جيبوتي‎" + } + }, + "nym:دولة الكويت": { + "edges": { + "from": [ + "country:KW" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "دولة الكويت" + } + }, + "nym:سورية": { + "edges": { + "from": [ + "country:SY" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "سورية" + } + }, + "nym:عمان": { + "edges": { + "from": [ + "country:OM" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "عمان" + } + }, + "nym:عُمان": { + "edges": { + "from": [ + "country:OM" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "عُمان" + } + }, + "nym:فلسطين": { + "edges": { + "from": [ + "territory:PS" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "فلسطين" + } + }, + "nym:قازاقستان": { + "edges": { + "from": [ + "country:KZ" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "قازاقستان" + } + }, + "nym:قطر": { + "edges": { + "from": [ + "country:QA" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "قطر" + } + }, + "nym:لبنان": { + "edges": { + "from": [ + "country:LB" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "لبنان" + } + }, + "nym:لصحراء الغربية": { + "edges": { + "from": [ + "territory:EH" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "لصحراء الغربية" + } + }, + "nym:ليبيا": { + "edges": { + "from": [ + "country:LY" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "ليبيا" + } + }, + "nym:مصر": { + "edges": { + "from": [ + "country:EG" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "مصر" + } + }, + "nym:موريتانيا": { + "edges": { + "from": [ + "country:MR" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "موريتانيا" + } + }, + "nym:پاکستان": { + "edges": { + "from": [ + "country:PK" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "پاکستان" + } + }, + "nym:नेपाल": { + "edges": { + "from": [ + "country:NP" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "नेपाल" + } + }, + "nym:फ़िजी": { + "edges": { + "from": [ + "country:FJ" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "फ़िजी" + } + }, + "nym:भारत": { + "edges": { + "from": [ + "country:IN" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "भारत" + } + }, + "nym:भारत गणराज्य": { + "edges": { + "from": [ + "country:IN" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "भारत गणराज्य" + } + }, + "nym:भारतम्": { + "edges": { + "from": [ + "country:IN" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "भारतम्" + } + }, + "nym:भूटान": { + "edges": { + "from": [ + "country:BT" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "भूटान" + } + }, + "nym:शर्नम्": { + "edges": { + "from": [ + "country:SR" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "शर्नम्" + } + }, + "nym:বাংলাদেশ": { + "edges": { + "from": [ + "country:BD" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "বাংলাদেশ" + } + }, + "nym:ভারত": { + "edges": { + "from": [ + "country:IN" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "ভারত" + } + }, + "nym:ভাৰত": { + "edges": { + "from": [ + "country:IN" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "ভাৰত" + } + }, + "nym:ਭਾਰਤ": { + "edges": { + "from": [ + "country:IN" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "ਭਾਰਤ" + } + }, + "nym:ભારત": { + "edges": { + "from": [ + "country:IN" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "ભારત" + } + }, + "nym:ଭାରତ": { + "edges": { + "from": [ + "country:IN" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "ଭାରତ" + } + }, + "nym:இலங்கை": { + "edges": { + "from": [ + "country:LK" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "இலங்கை" + } + }, + "nym:சிங்கப்பூர்": { + "edges": { + "from": [ + "country:SG" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "சிங்கப்பூர்" + } + }, + "nym:சிங்கப்பூர் குடியரசு": { + "edges": { + "from": [ + "country:SG" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "சிங்கப்பூர் குடியரசு" + } + }, + "nym:சிங்கப்பூர்கு": { + "edges": { + "from": [ + "country:SG" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "சிங்கப்பூர்கு" + } + }, + "nym:டியரசு": { + "edges": { + "from": [ + "country:SG" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "டியரசு" + } + }, + "nym:பாரதம்": { + "edges": { + "from": [ + "country:IN" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "பாரதம்" + } + }, + "nym:மலேசியா": { + "edges": { + "from": [ + "country:MY" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "மலேசியா" + } + }, + "nym:భారత దేశం": { + "edges": { + "from": [ + "country:IN" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "భారత దేశం" + } + }, + "nym:ಭಾರತ": { + "edges": { + "from": [ + "country:IN" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "ಭಾರತ" + } + }, + "nym:ഭാരതം": { + "edges": { + "from": [ + "country:IN" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "ഭാരതം" + } + }, + "nym:ශ්‍රී ලංකා": { + "edges": { + "from": [ + "country:LK" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "ශ්‍රී ලංකා" + } + }, + "nym:ශ්‍රී ලංකා இலங்கை": { + "edges": { + "from": [ + "country:LK" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "ශ්‍රී ලංකා இலங்கை" + } + }, + "nym:ශ්‍රී ලංකාව": { + "edges": { + "from": [ + "country:LK" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "ශ්‍රී ලංකාව" + } + }, + "nym:ประเทศไทย": { + "edges": { + "from": [ + "country:TH" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "ประเทศไทย" + } + }, + "nym:ราชอาณาจักรไทย": { + "edges": { + "from": [ + "country:TH" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "ราชอาณาจักรไทย" + } + }, + "nym:เมืองไทย": { + "edges": { + "from": [ + "country:TH" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "เมืองไทย" + } + }, + "nym:ປະເທດລາວ": { + "edges": { + "from": [ + "country:LA" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "ປະເທດລາວ" + } + }, + "nym:འབྲུག་ཡུལ": { + "edges": { + "from": [ + "country:BT" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "འབྲུག་ཡུལ" + } + }, + "nym:မြန်မာ": { + "edges": { + "from": [ + "country:MM" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "မြန်မာ" + } + }, + "nym:საქართველო": { + "edges": { + "from": [ + "country:GE" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "საქართველო" + } + }, + "nym:ኢትዮጵያ": { + "edges": { + "from": [ + "country:ET" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "ኢትዮጵያ" + } + }, + "nym:ኤርትራ": { + "edges": { + "from": [ + "country:ER" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "ኤርትራ" + } + }, + "nym:កម្ពុជា": { + "edges": { + "from": [ + "country:KH" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "កម្ពុជា" + } + }, + "nym:ᠮᠤᠩᠭᠤᠯ ᠤᠯᠤᠰ": { + "edges": { + "from": [ + "country:MN" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "ᠮᠤᠩᠭᠤᠯ ᠤᠯᠤᠰ" + } + }, + "nym:‎": { + "edges": { + "from": [ + "country:IQ" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "‎" + } + }, + "nym:‘Umān": { + "edges": { + "from": [ + "country:OM" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "‘Umān" + } + }, + "nym:ⴰⴳⴰⵡⵛ": { + "edges": { + "from": [ + "country:MR" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "ⴰⴳⴰⵡⵛ" + } + }, + "nym:ⴰⵎⵔⵔⵓⴽ": { + "edges": { + "from": [ + "country:MA" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "ⴰⵎⵔⵔⵓⴽ" + } + }, + "nym:ⴷⵣⴰⵢⴻⵔ": { + "edges": { + "from": [ + "country:DZ" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "ⴷⵣⴰⵢⴻⵔ" + } + }, + "nym:ⵍⵉⴱⵢⴰ": { + "edges": { + "from": [ + "country:LY" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "ⵍⵉⴱⵢⴰ" + } + }, + "nym:ⵍⵎⵖⵔⵉⴱ": { + "edges": { + "from": [ + "country:MA" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "ⵍⵎⵖⵔⵉⴱ" + } + }, + "nym:ⵎⵓⵔⵉⵜⴰⵏ": { + "edges": { + "from": [ + "country:MR" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "ⵎⵓⵔⵉⵜⴰⵏ" + } + }, + "nym:ⵜⵓⵏⵙ": { + "edges": { + "from": [ + "country:TN" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "ⵜⵓⵏⵙ" + } + }, + "nym:中华": { + "edges": { + "from": [ + "country:CN" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "中华" + } + }, + "nym:中国": { + "edges": { + "from": [ + "country:CN" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "中国" + } + }, + "nym:中国/中华": { + "edges": { + "from": [ + "country:CN" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "中国/中华" + } + }, + "nym:中華民國": { + "edges": { + "from": [ + "territory:TW" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "中華民國" + } + }, + "nym:台灣": { + "edges": { + "from": [ + "territory:TW" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "台灣" + } + }, + "nym:新加坡": { + "edges": { + "from": [ + "country:SG" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "新加坡" + } + }, + "nym:新加坡共和国": { + "edges": { + "from": [ + "country:SG" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "新加坡共和国" + } + }, + "nym:日本": { + "edges": { + "from": [ + "country:JP" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "日本" + } + }, + "nym:臺灣": { + "edges": { + "from": [ + "territory:TW" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "臺灣" + } + }, + "nym:臺灣/台灣": { + "edges": { + "from": [ + "territory:TW" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "臺灣/台灣" + } + }, + "nym:香港": { + "edges": { + "from": [ + "territory:HK" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "香港" + } + }, + "nym:马来西亚": { + "edges": { + "from": [ + "country:MY" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "马来西亚" + } + }, + "nym:남한": { + "edges": { + "from": [ + "country:KR" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "남한" + } + }, + "nym:북조선": { + "edges": { + "from": [ + "country:KP" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "북조선" + } + }, + "nym:조선 / 朝鮮": { + "edges": { + "from": [ + "country:KP" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "조선 / 朝鮮" + } + }, + "nym:한국 / 韓國": { + "edges": { + "from": [ + "country:KR" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": false, + "stable-name": false + }, + "names": { + "cy": false, + "en-GB": "한국 / 韓國" + } + }, + "territory:AE-AJ": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Ajman" + } + }, + "territory:AE-AZ": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Abu Dhabi" + } + }, + "territory:AE-DU": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Dubai" + } + }, + "territory:AE-FU": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Fujairah" + } + }, + "territory:AE-RK": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Ras al-Khaimah" + } + }, + "territory:AE-SH": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Sharjah" + } + }, + "territory:AE-UQ": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Umm al-Quwain" + } + }, + "territory:AI": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Anguilla" + } + }, + "territory:AQ": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Antarctica" + } + }, + "territory:AS": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "American Samoa" + } + }, + "territory:AW": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Aruba" + } + }, + "territory:AX": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Åland Islands" + } + }, + "territory:BAT": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "British Antarctic Territory" + } + }, + "territory:BL": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Saint Barthélemy" + } + }, + "territory:BM": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Bermuda" + } + }, + "territory:BQ-BO": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Bonaire" + } + }, + "territory:BQ-SA": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Saba" + } + }, + "territory:BQ-SE": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Sint Eustatius" + } + }, + "territory:BV": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Bouvet Island" + } + }, + "territory:CC": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Cocos (Keeling) Islands" + } + }, + "territory:CK": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Cook Islands" + } + }, + "territory:CW": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Curaçao" + } + }, + "territory:CX": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Christmas Island" + } + }, + "territory:EH": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Western Sahara" + } + }, + "territory:ES-CE": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Ceuta" + } + }, + "territory:ES-ML": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Melilla" + } + }, + "territory:FK": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Falkland Islands" + } + }, + "territory:FO": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Faroe Islands" + } + }, + "territory:GF": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "French Guiana" + } + }, + "territory:GG": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Guernsey" + } + }, + "territory:GI": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Gibraltar" + } + }, + "territory:GL": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Greenland" + } + }, + "territory:GP": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Guadeloupe" + } + }, + "territory:GS": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "South Georgia and South Sandwich Islands" + } + }, + "territory:GU": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Guam" + } + }, + "territory:HK": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Hong Kong" + } + }, + "territory:HM": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Heard Island and McDonald Islands" + } + }, + "territory:IM": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Isle of Man" + } + }, + "territory:IO": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "British Indian Ocean Territory" + } + }, + "territory:JE": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Jersey" + } + }, + "territory:KY": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Cayman Islands" + } + }, + "territory:MF": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Saint-Martin (French part)" + } + }, + "territory:MO": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Macao" + } + }, + "territory:MP": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Northern Mariana Islands" + } + }, + "territory:MQ": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Martinique" + } + }, + "territory:MS": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Montserrat" + } + }, + "territory:NC": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "New Caledonia" + } + }, + "territory:NF": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Norfolk Island" + } + }, + "territory:NU": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Niue" + } + }, + "territory:PF": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "French Polynesia" + } + }, + "territory:PM": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Saint Pierre and Miquelon" + } + }, + "territory:PN": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Pitcairn, Henderson, Ducie and Oeno Islands" + } + }, + "territory:PR": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Puerto Rico" + } + }, + "territory:PS": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Occupied Palestinian Territories" + } + }, + "territory:RE": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Réunion" + } + }, + "territory:SH-AC": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Ascension" + } + }, + "territory:SH-HL": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Saint Helena" + } + }, + "territory:SH-TA": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Tristan da Cunha" + } + }, + "territory:SJ": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Svalbard and Jan Mayen" + } + }, + "territory:SX": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Sint Maarten (Dutch part)" + } + }, + "territory:TC": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Turks and Caicos Islands" + } + }, + "territory:TF": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "French Southern Territories" + } + }, + "territory:TK": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Tokelau" + } + }, + "territory:TW": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Taiwan" + } + }, + "territory:UM-67": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Johnston Atoll" + } + }, + "territory:UM-71": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Midway Islands" + } + }, + "territory:UM-76": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Navassa Island" + } + }, + "territory:UM-79": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Wake Island" + } + }, + "territory:UM-81": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Baker Island" + } + }, + "territory:UM-84": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Howland Island" + } + }, + "territory:UM-86": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Jarvis Island" + } + }, + "territory:UM-89": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Kingman Reef" + } + }, + "territory:UM-95": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Palmyra Atoll" + } + }, + "territory:VG": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "British Virgin Islands" + } + }, + "territory:VI": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "United States Virgin Islands" + } + }, + "territory:WF": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Wallis and Futuna" + } + }, + "territory:XQZ": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Akrotiri" + } + }, + "territory:XXD": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Dhekelia" + } + }, + "territory:YT": { + "edges": { + "from": [ + ] + }, + "meta": { + "canonical": true, + "canonical-mask": 1, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Mayotte" + } + }, + "uk:ENG": { + "edges": { + "from": [ + "country:GB" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "England" + } + }, + "uk:GBN": { + "edges": { + "from": [ + "country:GB" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Great Britain" + } + }, + "uk:NIR": { + "edges": { + "from": [ + "country:GB" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Northern Ireland" + } + }, + "uk:SCT": { + "edges": { + "from": [ + "country:GB" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Scotland" + } + }, + "uk:WLS": { + "edges": { + "from": [ + "country:GB" + ] + }, + "meta": { + "canonical": false, + "canonical-mask": 0, + "display-name": true, + "stable-name": true + }, + "names": { + "cy": false, + "en-GB": "Wales" + } + } +} diff --git a/notifications_utils/countries/_data/synonyms.json b/notifications_utils/countries/_data/synonyms.json new file mode 100644 index 000000000..69c712f2a --- /dev/null +++ b/notifications_utils/countries/_data/synonyms.json @@ -0,0 +1,58 @@ +{ + "England": "United Kingdom", + "Northern Ireland": "United Kingdom", + "Scotland": "United Kingdom", + "Wales": "United Kingdom", + "ROI": "Ireland", + "Irish Republic": "Ireland", + "Rep of Ireland": "Ireland", + "South Ireland": "Ireland", + "Southern Ireland": "Ireland", + "N Ireland": "United Kingdom", + "North Ireland": "United Kingdom", + "GBR": "United Kingdom", + "United States America": "United States", + "America": "United States", + "Macedonia": "North Macedonia", + "Autonomous Region of the Azores": "Azores", + "Islas Canarias": "Canary Islands", + "Canaries": "Canary Islands", + "Autonomous Region of Madeira": "Madeira", + "Região Autónoma da Madeira": "Madeira", + "Islas Baleares": "Balearic Islands", + "Illes Balears": "Balearic Islands", + "Corse": "Corsica", + "Burma": "Myanmar (Burma)", + "Czechoslovakia": "Czechia", + "East Germany": "Germany", + "Easter Island": "Easter Island", + "Falkland": "Falkland Islands", + "The Falklands": "Falkland Islands", + "The Falkland Islands": "Falkland Islands", + "Hawaii": "United States", + "Khazakhstan": "Kazakhstan", + "Korea": "South Korea", + "Macau": "Macao", + "Myanmar": "Myanmar (Burma)", + "New Zeeland": "New Zealand", + "NI": "United Kingdom", + "Pitcairn Island": "Pitcairn, Henderson, Ducie and Oeno Islands", + "Henderson Island": "Pitcairn, Henderson, Ducie and Oeno Islands", + "Ducie Island": "Pitcairn, Henderson, Ducie and Oeno Islands", + "Oeno Island": "Pitcairn, Henderson, Ducie and Oeno Islands", + "Republic of China": "Taiwan", + "Republik Österreich": "Austria", + "République Islamique de Mauritanie": "Mauritania", + "Saint Helena": "Saint Helena", + "St Helena": "Saint Helena", + "Swaziland": "Eswatini", + "the south sandwich islands": "South Georgia and the South Sandwich Islands", + "the sandwich islands": "South Georgia and the South Sandwich Islands", + "South Georgia": "South Georgia and the South Sandwich Islands", + "Tristan": "Tristan da Cunha", + "Vatican": "Vatican City", + "West Germany": "Germany", + "Saint Kitts and Nevis": "St Kitts and Nevis", + "Saint Kitts": "St Kitts and Nevis", + "St Kitts": "St Kitts and Nevis" +} diff --git a/notifications_utils/countries/_data/uk-islands.txt b/notifications_utils/countries/_data/uk-islands.txt new file mode 100644 index 000000000..d678109e1 --- /dev/null +++ b/notifications_utils/countries/_data/uk-islands.txt @@ -0,0 +1,8 @@ +Alderney +Brecqhou +Guernsey +Herm +Isle of Man +Jersey +Jethou +Sark diff --git a/notifications_utils/countries/_data/welsh-names.json b/notifications_utils/countries/_data/welsh-names.json new file mode 100644 index 000000000..edfbf2ac3 --- /dev/null +++ b/notifications_utils/countries/_data/welsh-names.json @@ -0,0 +1,103 @@ +{ + "Affganistan": "Afghanistan", + "Antigwa a Barbiwda": "Antigua and Barbuda", + "Yr Ariannin": "Argentina", + "Awstralia": "Australia", + "Awstria": "Austria", + "Aserbaijan": "Azerbaijan", + "Y Bahamas": "The Bahamas", + "Belarws": "Belarus", + "Gwlad Belg": "Belgium", + "Bhwtan": "Bhutan", + "Bolifia": "Bolivia", + "Bosnia a Hercegovina": "Bosnia and Herzegovina", + "Brasil": "Brazil", + "Bwlgaria": "Bulgaria", + "Bwrwndi": "Burundi", + "Camerŵn": "Cameroon", + "Cabo Verde": "Cape Verde", + "Gweriniaeth Canolbarth Affrica": "Central African Republic", + "Tchad": "Chad", + "Tsieina": "China", + "Y Comoros": "Comoros", + "Ciwba": "Cuba", + "Y Weriniaeth Tsiec": "Czechia", + "Gweriniaeth Ddemocrataidd Congo": "Congo (Democratic Republic)", + "Denmarc": "Denmark", + "Gweriniaeth Dominica": "Dominican Republic", + "Dwyrain Timor": "East Timor", + "Ecwador": "Ecuador", + "Yr Aifft": "Egypt", + "Gini Gyhydeddol": "Equatorial Guinea", + "Ffiji": "Fiji", + "Y Ffindir": "Finland", + "Ffrainc": "France", + "Y Gambia": "The Gambia", + "Yr Alban": "United Kingdom", + "Yr Almaen": "Germany", + "Gwlad Groeg": "Greece", + "Gini": "Guinea", + "Guiné-Bissau": "Guinea-Bissau", + "Gaiana": "Guyana", + "Hondwras": "Honduras", + "Hwngari": "Hungary", + "Gwlad yr Iâ": "Iceland", + "Irac": "Iraq", + "Iwerddon": "Ireland", + "Yr Eidal": "Italy", + "Iorddonen": "Jordan", + "Kazakstan": "Kazakhstan", + "Latfia": "Latvia", + "Libanus": "Lebanon", + "Libia": "Libya", + "Lithwania": "Lithuania", + "Lwcsembwrg": "Luxembourg", + "Madagasgar": "Madagascar", + "Ynysoedd Marshall": "Marshall Islands", + "Mecsico": "Mexico", + "Moldofa": "Moldova", + "Moroco": "Morocco", + "Mosambic": "Mozambique", + "Yr Iseldiroedd": "Netherlands", + "Seland Newydd": "New Zealand", + "Nicaragwa": "Nicaragua", + "Gogledd Corea": "North Korea", + "Norwy": "Norway", + "Papua Guinea Newydd": "Papua New Guinea", + "Paragwâi": "Paraguay", + "Periw": "Peru", + "Pilipinas": "Philippines", + "Gwlad Pwyl": "Poland", + "Portiwgal": "Portugal", + "Gweriniaeth y Congo": "Congo", + "Gweriniaeth Macedonia": "North Macedonia", + "Gogledd Macedonia": "North Macedonia", + "Rwmania": "Romania", + "Rwsia": "Russia", + "Saint Kitts a Nevis": "St Kitts and Nevis", + "St Kitts a Nevis": "St Kitts and Nevis", + "Saint Vincent a’r Grenadines": "St Vincent", + "São Tomé a Príncipe": "Sao Tome and Principe", + "Sénégal": "Senegal", + "Slofacia": "Slovakia", + "Slofenia": "Slovenia", + "Ynysoedd Solomon": "Solomon Islands", + "De Affrica": "South Africa", + "De Corea": "South Korea", + "De Sudan": "South Sudan", + "Sbaen": "Spain", + "Swrinam": "Suriname", + "Gwlad Swazi": "Eswatini", + "Y Swistir": "Switzerland", + "Gwlad Thai": "Thailand", + "Trinidad a Thobago": "Trinidad and Tobago", + "Twrci": "Turkey", + "Twfalw": "Tuvalu", + "Wcráin": "Ukraine", + "Yr Emiradau Arabaidd Unedig": "United Arab Emirates", + "Y Deyrnas Unedig": "United Kingdom", + "Unol Daleithiau America": "United States", + "Wrwgwái": "Uruguay", + "Feneswela": "Venezuela", + "Fietnam": "Vietnam" +} diff --git a/notifications_utils/countries/data.py b/notifications_utils/countries/data.py new file mode 100644 index 000000000..0acabb22d --- /dev/null +++ b/notifications_utils/countries/data.py @@ -0,0 +1,67 @@ +import json +import os + + +def _load_data(filename): + with open(os.path.join(os.path.dirname(__file__), "_data", filename)) as contents: + if filename.endswith(".json"): + return json.load(contents) + return [line.strip() for line in contents.readlines()] + + +def find_canonical(item, graph, key): + if item["meta"]["canonical"]: + return key, item["names"]["en-GB"] + return find_canonical( + graph[item["edges"]["from"][0]], + graph, + key, + ) + + +# Copied from +# https://github.com/alphagov/govuk-country-and-territory-autocomplete +# /blob/b61091a502983fd2a77b3cdb5f94a604412eb093 +# /dist/location-autocomplete-graph.json +_graph = _load_data("location-autocomplete-graph.json") + +UK = "United Kingdom" + +ENDED_COUNTRIES = _load_data("ended-countries.json") +ADDITIONAL_SYNONYMS = list(_load_data("synonyms.json").items()) +WELSH_NAMES = list(_load_data("welsh-names.json").items()) +_UK_ISLANDS_LIST = _load_data("uk-islands.txt") +_EUROPEAN_ISLANDS_LIST = _load_data("european-islands.txt") + +CURRENT_AND_ENDED_COUNTRIES_AND_TERRITORIES = [ + find_canonical(item, _graph, item["names"]["en-GB"]) for item in _graph.values() +] + +COUNTRIES_AND_TERRITORIES = [] + +for synonym, canonical in CURRENT_AND_ENDED_COUNTRIES_AND_TERRITORIES: + if canonical in _UK_ISLANDS_LIST: + COUNTRIES_AND_TERRITORIES.append((synonym, UK)) + elif canonical in ENDED_COUNTRIES: + succeeding_country = ENDED_COUNTRIES[canonical] + if succeeding_country: + COUNTRIES_AND_TERRITORIES.append((synonym, succeeding_country)) + COUNTRIES_AND_TERRITORIES.append((canonical, succeeding_country)) + else: + COUNTRIES_AND_TERRITORIES.append((synonym, canonical)) + +UK_ISLANDS = [(synonym, UK) for synonym in _UK_ISLANDS_LIST] + +EUROPEAN_ISLANDS = [(synonym, synonym) for synonym in _EUROPEAN_ISLANDS_LIST] + +# Copied from https://www.royalmail.com/international-zones#europe +# Modified to use the canonical names for countries where incorrect +ROYAL_MAIL_EUROPEAN = _load_data("europe.txt") + + +class Postage: + UK = "united-kingdom" + FIRST = "first" + SECOND = "second" + EUROPE = "europe" + REST_OF_WORLD = "rest-of-world" diff --git a/notifications_utils/field.py b/notifications_utils/field.py new file mode 100644 index 000000000..c0ea50216 --- /dev/null +++ b/notifications_utils/field.py @@ -0,0 +1,208 @@ +import re + +from markupsafe import Markup +from ordered_set import OrderedSet + +from notifications_utils.formatters import ( + escape_html, + strip_and_remove_obscure_whitespace, + strip_html, + unescaped_formatted_list, +) +from notifications_utils.insensitive_dict import InsensitiveDict + + +class Placeholder: + def __init__(self, body): + # body shouldn’t include leading/trailing brackets, like (( and )) + self.body = body.lstrip("(").rstrip(")") + + @classmethod + def from_match(cls, match): + return cls(match.group(0)) + + def is_conditional(self): + return "??" in self.body + + @property + def name(self): + # for non conditionals, name equals body + return self.body.split("??")[0] + + @property + def conditional_text(self): + if self.is_conditional(): + # ((a?? b??c)) returns " b??c" + return "??".join(self.body.split("??")[1:]) + else: + raise ValueError("{} not conditional".format(self)) + + def get_conditional_body(self, show_conditional): + # note: unsanitised/converted + if self.is_conditional(): + return self.conditional_text if str2bool(show_conditional) else "" + else: + raise ValueError("{} not conditional".format(self)) + + def __repr__(self): + return "Placeholder({})".format(self.body) + + +class Field: + """ + An instance of Field represents a string of text which may contain + placeholders. + + If values are provided the field replaces the placeholders with the + corresponding values. If a value for a placeholder is missing then + the field will highlight the placeholder by wrapping it in some HTML. + + A template can have several fields, for example an email template + has a field for the body and a field for the subject. + """ + + placeholder_pattern = re.compile( + r"\({2}" # opening (( + r"([^()]+)" # body of placeholder - potentially standard or conditional. + r"\){2}" # closing )) + ) + placeholder_tag = "(({}))" + conditional_placeholder_tag = ( + "(({}??{}))" + ) + placeholder_tag_no_brackets = "{}" + placeholder_tag_redacted = "hidden" + + def __init__( + self, + content, + values=None, + with_brackets=True, + html="strip", + markdown_lists=False, + redact_missing_personalisation=False, + ): + self.content = content + self.values = values + self.markdown_lists = markdown_lists + if not with_brackets: + self.placeholder_tag = self.placeholder_tag_no_brackets + self.sanitizer = { + "strip": strip_html, + "escape": escape_html, + "passthrough": str, + }[html] + self.redact_missing_personalisation = redact_missing_personalisation + + def __str__(self): + if self.values: + return self.replaced + return self.formatted + + def __repr__(self): + return '{}("{}", {})'.format( + self.__class__.__name__, self.content, self.values + ) # TODO: more real + + def splitlines(self): + return str(self).splitlines() + + @property + def values(self): + return self._values + + @values.setter + def values(self, value): + self._values = InsensitiveDict(value) if value else {} + + def format_match(self, match): + placeholder = Placeholder.from_match(match) + + if self.redact_missing_personalisation: + return self.placeholder_tag_redacted + + if placeholder.is_conditional(): + return self.conditional_placeholder_tag.format( + placeholder.name, placeholder.conditional_text + ) + + return self.placeholder_tag.format(placeholder.name) + + def replace_match(self, match): + placeholder = Placeholder.from_match(match) + replacement = self.values.get(placeholder.name) + + if placeholder.is_conditional() and replacement is not None: + return placeholder.get_conditional_body(replacement) + + replaced_value = self.get_replacement(placeholder) + if replaced_value is not None: + return self.get_replacement(placeholder) + + return self.format_match(match) + + def get_replacement(self, placeholder): + replacement = self.values.get(placeholder.name) + if replacement is None: + return None + + if isinstance(replacement, list): + vals = ( + strip_and_remove_obscure_whitespace(str(val)) + for val in replacement + if val is not None + ) + vals = list(filter(None, vals)) + if not vals: + return "" + return self.sanitizer(self.get_replacement_as_list(vals)) + + return self.sanitizer(str(replacement)) + + def get_replacement_as_list(self, replacement): + if self.markdown_lists: + return "\n\n" + "\n".join("* {}".format(item) for item in replacement) + return unescaped_formatted_list(replacement, before_each="", after_each="") + + @property + def _raw_formatted(self): + return re.sub( + self.placeholder_pattern, self.format_match, self.sanitizer(self.content) + ) + + @property + def formatted(self): + return Markup(self._raw_formatted) + + @property + def placeholders(self): + if not getattr(self, "content", ""): + return set() + return OrderedSet( + Placeholder(body).name + for body in re.findall(self.placeholder_pattern, self.content) + ) + + @property + def replaced(self): + return re.sub( + self.placeholder_pattern, self.replace_match, self.sanitizer(self.content) + ) + + +class PlainTextField(Field): + """ + Use this where no HTML should be rendered in the outputted content, + even when no values have been passed in + """ + + placeholder_tag = "(({}))" + conditional_placeholder_tag = "(({}??{}))" + placeholder_tag_no_brackets = "{}" + placeholder_tag_redacted = "[hidden]" + + +def str2bool(value): + if not value: + return False + return str(value).lower() in ("yes", "y", "true", "t", "1", "include", "show") diff --git a/notifications_utils/formatters.py b/notifications_utils/formatters.py new file mode 100644 index 000000000..a2cb7c3d7 --- /dev/null +++ b/notifications_utils/formatters.py @@ -0,0 +1,349 @@ +import re +import string +import urllib +from html import _replace_charref, escape + +import bleach +import smartypants +from markupsafe import Markup + +from notifications_utils.sanitise_text import SanitiseSMS + +from . import email_with_smart_quotes_regex + +OBSCURE_ZERO_WIDTH_WHITESPACE = ( + "\u180E" # Mongolian vowel separator + "\u200B" # zero width space + "\u200C" # zero width non-joiner + "\u200D" # zero width joiner + "\u2060" # word joiner + "\uFEFF" # zero width non-breaking space +) + +OBSCURE_FULL_WIDTH_WHITESPACE = "\u00A0" # non breaking space + +ALL_WHITESPACE = ( + string.whitespace + OBSCURE_ZERO_WIDTH_WHITESPACE + OBSCURE_FULL_WIDTH_WHITESPACE +) + +govuk_not_a_link = re.compile(r"(^|\s)(#|\*|\^)?(GOV)\.(UK)(?!\/|\?|#)", re.IGNORECASE) + +smartypants.tags_to_skip = smartypants.tags_to_skip + ["a"] + +whitespace_before_punctuation = re.compile(r"[ \t]+([,\.])") + +hyphens_surrounded_by_spaces = re.compile( + r"\s+[-–—]{1,3}\s+" +) # check three different unicode hyphens + +multiple_newlines = re.compile(r"((\n)\2{2,})") + +HTML_ENTITY_MAPPING = ( + (" ", "👾🐦🥴"), + ("&", "➕🐦🥴"), + ("(", "◀️🐦🥴"), + (")", "▶️🐦🥴"), +) + +url = re.compile( + r"(?i)" # case insensitive + r"\b(?", value.strip()) + + +def add_prefix(body, prefix=None): + if prefix: + return "{}: {}".format(prefix.strip(), body) + return body + + +def make_link_from_url(linked_part, *, classes=""): + """ + Takes something which looks like a URL, works out which trailing characters shouldn’t + be considered part of the link and returns an HTML tag + + input: `http://example.com/foo_(bar)).` + output: `http://example.com/foo_(bar)).` + """ + CORRESPONDING_OPENING_CHARACTER_MAP = { + ")": "(", + "]": "[", + ".": None, + ",": None, + ":": None, + } + + trailing_characters = "" + + while ( + last_character := linked_part[-1] + ) in CORRESPONDING_OPENING_CHARACTER_MAP.keys(): + corresponding_opening_character = CORRESPONDING_OPENING_CHARACTER_MAP[ + last_character + ] + + if corresponding_opening_character: + count_opening_characters = linked_part.count( + corresponding_opening_character + ) + count_closing_characters = linked_part.count(last_character) + if count_opening_characters >= count_closing_characters: + break + + trailing_characters = linked_part[-1] + trailing_characters + linked_part = linked_part[:-1] + + return f"{create_sanitised_html_for_url(linked_part, classes=classes)}{trailing_characters}" + + +def autolink_urls(value, *, classes=""): + return Markup( + url.sub( + lambda match: make_link_from_url( + match.group(0), + classes=classes, + ), + value, + ) + ) + + +def create_sanitised_html_for_url(link, *, classes="", style=""): + """ + takes a link and returns an a tag to that link. does the quote/unquote dance to ensure that " quotes are escaped + correctly to prevent xss + + input: `http://foo.com/"bar"?x=1#2` + output: `http://foo.com/"bar"?x=1#2` + """ + link_text = link + + if not link.lower().startswith("http"): + link = f"http://{link}" + + class_attribute = f'class="{classes}" ' if classes else "" + style_attribute = f'style="{style}" ' if style else "" + + return ('{}').format( + class_attribute, + style_attribute, + urllib.parse.quote(urllib.parse.unquote(link), safe=":/?#=&;"), + link_text, + ) + + +def prepend_subject(body, subject): + return "# {}\n\n{}".format(subject, body) + + +def sms_encode(content): + return SanitiseSMS.encode(content) + + +def strip_html(value): + return bleach.clean(value, tags=[], strip=True) + + +""" +Re-implements html._charref but makes trailing semicolons non-optional +""" +_charref = re.compile(r"&(#[0-9]+;" r"|#[xX][0-9a-fA-F]+;" r"|[^\t\n\f <&#;]{1,32};)") + + +def unescape_strict(s): + """ + Re-implements html.unescape to use our own definition of `_charref` + """ + if "&" not in s: + return s + return _charref.sub(_replace_charref, s) + + +def escape_html(value): + if not value: + return value + value = str(value) + + for entity, temporary_replacement in HTML_ENTITY_MAPPING: + value = value.replace(entity, temporary_replacement) + + value = escape(unescape_strict(value), quote=False) + + for entity, temporary_replacement in HTML_ENTITY_MAPPING: + value = value.replace(temporary_replacement, entity) + + return value + + +def url_encode_full_stops(value): + return value.replace(".", "%2E") + + +def unescaped_formatted_list( + items, + conjunction="and", + before_each="‘", + after_each="’", + separator=", ", + prefix="", + prefix_plural="", +): + if prefix: + prefix += " " + if prefix_plural: + prefix_plural += " " + + if len(items) == 1: + return "{prefix}{before_each}{items[0]}{after_each}".format(**locals()) + elif items: + formatted_items = [ + "{}{}{}".format(before_each, item, after_each) for item in items + ] + + first_items = separator.join(formatted_items[:-1]) + last_item = formatted_items[-1] + return ("{prefix_plural}{first_items} {conjunction} {last_item}").format( + **locals() + ) + + +def formatted_list( + items, + conjunction="and", + before_each="‘", + after_each="’", + separator=", ", + prefix="", + prefix_plural="", +): + return Markup( + unescaped_formatted_list( + [escape_html(x) for x in items], + conjunction, + before_each, + after_each, + separator, + prefix, + prefix_plural, + ) + ) + + +def remove_whitespace_before_punctuation(value): + return re.sub(whitespace_before_punctuation, lambda match: match.group(1), value) + + +def make_quotes_smart(value): + return smartypants.smartypants(value, smartypants.Attr.q | smartypants.Attr.u) + + +def replace_hyphens_with_en_dashes(value): + return re.sub( + hyphens_surrounded_by_spaces, + (" " "\u2013" " "), # space # en dash # space + value, + ) + + +def replace_hyphens_with_non_breaking_hyphens(value): + return value.replace( + "-", + "\u2011", # non-breaking hyphen + ) + + +def normalise_whitespace_and_newlines(value): + return "\n".join(get_lines_with_normalised_whitespace(value)) + + +def get_lines_with_normalised_whitespace(value): + return [normalise_whitespace(line) for line in value.splitlines()] + + +def normalise_whitespace(value): + # leading and trailing whitespace removed + # inner whitespace with width becomes a single space + # inner whitespace with zero width is removed + # multiple space characters next to each other become just a single space character + for character in OBSCURE_FULL_WIDTH_WHITESPACE: + value = value.replace(character, " ") + + for character in OBSCURE_ZERO_WIDTH_WHITESPACE: + value = value.replace(character, "") + + return " ".join(value.split()) + + +def normalise_multiple_newlines(value): + return more_than_two_newlines_in_a_row.sub("\n\n", value) + + +def strip_leading_whitespace(value): + return value.lstrip() + + +def add_trailing_newline(value): + return "{}\n".format(value) + + +def remove_smart_quotes_from_email_addresses(value): + def remove_smart_quotes(match): + value = match.group(0) + for character in "‘’": + value = value.replace(character, "'") + return value + + return email_with_smart_quotes_regex.sub( + remove_smart_quotes, + value, + ) + + +def strip_all_whitespace(value, extra_characters=""): + # Removes from the beginning and end of the string all whitespace characters and `extra_characters` + if value is not None and hasattr(value, "strip"): + return value.strip(ALL_WHITESPACE + extra_characters) + return value + + +def strip_and_remove_obscure_whitespace(value): + if value == "": + # Return early to avoid making multiple, slow calls to + # str.replace on an empty string + return "" + + for character in OBSCURE_ZERO_WIDTH_WHITESPACE + OBSCURE_FULL_WIDTH_WHITESPACE: + value = value.replace(character, "") + + return value.strip(string.whitespace) + + +def remove_whitespace(value): + # Removes ALL whitespace, not just the obscure characters we normaly remove + for character in ALL_WHITESPACE: + value = value.replace(character, "") + + return value + + +def strip_unsupported_characters(value): + return value.replace("\u2028", "") diff --git a/notifications_utils/insensitive_dict.py b/notifications_utils/insensitive_dict.py new file mode 100644 index 000000000..7b239b884 --- /dev/null +++ b/notifications_utils/insensitive_dict.py @@ -0,0 +1,59 @@ +from functools import lru_cache + +from ordered_set import OrderedSet + + +class InsensitiveDict(dict): + """ + `InsensitiveDict` behaves like an ordered dictionary, except it normalises + case, whitespace, hypens and underscores in keys. + + In other words, + InsensitiveDict({'FIRST_NAME': 'example'}) == InsensitiveDict({'first name': 'example'}) + >>> True + """ + + KEY_TRANSLATION_TABLE = {ord(c): None for c in " _-"} + + def __init__(self, row_dict): + for key, value in row_dict.items(): + self[key] = value + + @classmethod + def from_keys(cls, keys): + """ + This behaves like `dict.from_keys`, except: + - it normalises the keys to ignore case, whitespace, hypens and + underscores + - it stores the original, unnormalised key as the value of the + item so it can be retrieved later + """ + return cls({key: key for key in keys}) + + def keys(self): + return OrderedSet(super().keys()) + + def __getitem__(self, key): + return super().__getitem__(self.make_key(key)) + + def __setitem__(self, key, value): + super().__setitem__(self.make_key(key), value) + + def __contains__(self, key): + return super().__contains__(self.make_key(key)) + + def get(self, key, default=None): + return self[key] if key in self else default + + def copy(self): + return self.__class__(super().copy()) + + def as_dict_with_keys(self, keys): + return {key: self.get(key) for key in keys} + + @staticmethod + @lru_cache(maxsize=32, typed=False) + def make_key(original_key): + if original_key is None: + return None + return original_key.translate(InsensitiveDict.KEY_TRANSLATION_TABLE).lower() diff --git a/notifications_utils/international_billing_rates.py b/notifications_utils/international_billing_rates.py new file mode 100644 index 000000000..f725834f4 --- /dev/null +++ b/notifications_utils/international_billing_rates.py @@ -0,0 +1,31 @@ +""" +Format of the yaml file looks like: + +1: + attributes: + alpha: 'NO' + comment: null + dlr: Carrier DLR + generic_sender: '' + numeric: LIMITED + sc: 'NO' + sender_and_registration_info: All senders CONVERTED into random long numeric senders + text_restrictions: Bulk/marketing traffic NOT allowed + billable_units: 1 + names: + - Canada + - United States + - Dominican Republic +""" + +import os + +import yaml + +dir_path = os.path.dirname(os.path.realpath(__file__)) + +with open("{}/international_billing_rates.yml".format(dir_path)) as f: + INTERNATIONAL_BILLING_RATES = yaml.safe_load(f) + COUNTRY_PREFIXES = list( + reversed(sorted(INTERNATIONAL_BILLING_RATES.keys(), key=len)) + ) diff --git a/notifications_utils/international_billing_rates.yml b/notifications_utils/international_billing_rates.yml new file mode 100644 index 000000000..dfa87d490 --- /dev/null +++ b/notifications_utils/international_billing_rates.yml @@ -0,0 +1,2891 @@ +################################################# +# +# DO NOT MODIFY THE "attributes" IN THIS FILE +# +# This file was generated from an external source, +# so its content should be kept as-is to avoid any +# ambiguity if we regenerate it in future. If you +# find something is incorrect, add a comment. +# +# It's OK to modify the "billable_units", as these +# get used for our actual billing calculations. +# +################################################# +# +# Key for entries: +# +# *in all cases, "null" means "don't know" or "n/a" +# +# alpha: +# possible values: 'REG' | 'YES' | 'NO' +# description: whether alphanumeric sender names are supported, potentially by registration only ('REG') +# comment: +# possible values: +# description: additional usage info e.g. "OPT-OUT option in the message is required" +# dlr: +# possible_values: '' | 'YES' | 'Carrier DLR' | 'NO' +# description: whether we get delivery receipts; 'Carrier DLR' also means 'NO'; unclear what '' means +# generic_sender: +# possible_values: '' | +# description: supports unregistered senders by converted to ('' means we don't know what is) +# numeric: +# possible_values: 'NO' | 'YES' | 'LIMITED' +# description: whether numeric sender names are supported, or randomly generated ('LIMITED') +# sc: +# possible_values: 'NO' | 'YES' | 'LIMITED' | 'REG' +# description: whether short codes are supported, potentially by registration only ('REG'), potentially limited in availability ('LIMITED') +# sender_and_registration_info: +# possible_values: +# description: specific operational info e.g. "All senders CONVERTED into random long numeric senders" +# text_restrictions: +# possible_values: +# description: similar to "comment" + +'1': + attributes: + alpha: 'NO' + comment: null + dlr: Carrier DLR + generic_sender: '' + numeric: LIMITED + sc: 'NO' + sender_and_registration_info: All senders CONVERTED into random long numeric senders + text_restrictions: Bulk/marketing traffic NOT allowed + billable_units: 1 + names: + - Canada + - United States + - Dominican Republic +'7': + attributes: + alpha: REG + comment: HIGH FEEs for SPAM + dlr: 'YES' + generic_sender: '' + numeric: 'NO' + sc: 'NO' + sender_and_registration_info: '' + text_restrictions: Transactional traffic ONLY + billable_units: 1 + names: + - South Ossetia + - Kazakhstan + - Abkhazia + - Russian Federation +'20': + attributes: + alpha: REG + comment: null + dlr: 'YES' + generic_sender: null + numeric: REG + sc: REG + sender_and_registration_info: null + text_restrictions: null + billable_units: 3 + names: + - Egypt +'27': + attributes: + alpha: 'NO' + comment: null + dlr: 'YES' + generic_sender: '' + numeric: LIMITED + sc: 'NO' + sender_and_registration_info: All senders CONVERTED into long numeric sender + text_restrictions: null + billable_units: 1 + names: + - South Africa +'30': + attributes: + alpha: 'YES' + comment: null + dlr: 'YES' + generic_sender: '' + numeric: 'YES' + sc: 'NO' + sender_and_registration_info: Senders MUST NOT include "," (comma separator) within, + up to 11 chars in length + text_restrictions: NO unicode nor binary formatted SMS support + billable_units: 2 + names: + - Greece +'31': + attributes: + alpha: 'YES' + comment: null + dlr: 'YES' + generic_sender: null + numeric: 'YES' + sc: 'YES' + sender_and_registration_info: null + text_restrictions: null + billable_units: 3 + names: + - Netherlands +'32': + attributes: + alpha: 'NO' + comment: null + dlr: 'YES' + generic_sender: null + numeric: 'YES' + sc: 'NO' + sender_and_registration_info: null + text_restrictions: null + billable_units: 3 + names: + - Belgium +'33': + attributes: + alpha: 'YES' + comment: STOP and CONTACT for OPT-OUTs required + dlr: 'YES' + generic_sender: '' + numeric: 'NO' + sc: REG + sender_and_registration_info: All numeric senders CONVERTED into limited amount + of registered shared SCs. HIGH one time and monthly FEEs for each additional + SC + text_restrictions: Marketing traffic is on hold on working days from 10PM to 8AM + (UTC/GMT +1 hour), weekend and bank holidays. Transactional traffic can be allowed with + no time limits with approval only. + billable_units: 2 + names: + - France +'34': + attributes: + alpha: 'YES' + comment: null + dlr: 'YES' + generic_sender: '' + numeric: LIMITED + sc: 'YES' + sender_and_registration_info: All long numeric senders not starting with 34 are + CONVERTED to "InfoSMS" + text_restrictions: null + billable_units: 2 + names: + - Spain +'36': + attributes: + alpha: 'NO' + comment: null + dlr: 'YES' + generic_sender: '' + numeric: LIMITED + sc: 'NO' + sender_and_registration_info: All senders CONVERTED to one national long numeric + text_restrictions: null + billable_units: 3 + names: + - Hungary +'39': + attributes: + alpha: 'YES' + comment: null + dlr: 'YES' + generic_sender: '' + numeric: 'YES' + sc: 'YES' + sender_and_registration_info: Numeric national (local) sender IDs allowed only + text_restrictions: null + billable_units: 2 + names: + - Italy +'40': + attributes: + alpha: REG + comment: null + dlr: 'YES' + generic_sender: '' + numeric: 'NO' + sc: REG + sender_and_registration_info: All not registered senders are CONVERTED into one + SC. Monthly time FEE for each SC, one time fee for each Alpha sender, authorization + letter and description required. Unregistered senders are converted to SC 1797 + text_restrictions: null + billable_units: 2 + names: + - Romania +'41': + attributes: + alpha: 'YES' + comment: null + dlr: 'YES' + generic_sender: '' + numeric: LIMITED + sc: 'YES' + sender_and_registration_info: National numeric senders are not allowed + text_restrictions: null + billable_units: 2 + names: + - Switzerland +'43': + attributes: + alpha: REG + comment: OPT-IN REQUIRED for each end user + dlr: 'YES' + generic_sender: '' + numeric: LIMITED + sc: 'NO' + sender_and_registration_info: HIGH monthly FEE for alpha senders. Otherwise ONLY + one long numeric sender allowed + text_restrictions: Bulk traffic NOT allowed. NO political content and other text + restrictions + billable_units: 3 + names: + - Austria +'44': + attributes: + alpha: 'YES' + comment: null + dlr: 'YES' + generic_sender: null + numeric: 'YES' + sc: 'YES' + sender_and_registration_info: null + text_restrictions: null + billable_units: 1 + names: + - Guernsey + - Isle of Man + - Jersey +'45': + attributes: + alpha: 'YES' + comment: null + dlr: 'YES' + generic_sender: '' + numeric: 'YES' + sc: 'YES' + sender_and_registration_info: '' + text_restrictions: Only transactional traffic allowed + billable_units: 1 + names: + - Denmark +'46': + attributes: + alpha: 'YES' + comment: HIGH FEEs for SPAM + dlr: 'YES' + generic_sender: '' + numeric: 'YES' + sc: 'YES' + sender_and_registration_info: '' + text_restrictions: '' + billable_units: 2 + names: + - Sweden +'47': + attributes: + alpha: 'YES' + comment: null + dlr: 'YES' + generic_sender: null + numeric: 'YES' + sc: 'YES' + sender_and_registration_info: null + text_restrictions: null + billable_units: 3 + names: + - Norway +'48': + attributes: + alpha: 'YES' + comment: Extremely HIGH penalties for marketing messages without OPT-Ins + dlr: 'YES' + generic_sender: '' + numeric: REG + sc: 'NO' + sender_and_registration_info: '' + text_restrictions: '' + billable_units: 1 + names: + - Poland +'49': + attributes: + alpha: 'YES' + comment: null + dlr: 'YES' + generic_sender: null + numeric: 'YES' + sc: 'NO' + sender_and_registration_info: null + text_restrictions: null + billable_units: 3 + names: + - Germany +'51': + attributes: + alpha: 'NO' + comment: null + dlr: 'YES' + generic_sender: '' + numeric: 'NO' + sc: LIMITED + sender_and_registration_info: All senders CONVERTED into one SC + text_restrictions: null + billable_units: 2 + names: + - Peru +'52': + attributes: + alpha: 'NO' + comment: null + dlr: Carrier DLR + generic_sender: '' + numeric: LIMITED + sc: 'NO' + sender_and_registration_info: All senders CONVERTED into random long numeric senders + text_restrictions: Bulk/marketing traffic NOT allowed + billable_units: 2 + names: + - Mexico +'53': + attributes: + alpha: 'NO' + comment: null + dlr: 'YES' + generic_sender: null + numeric: 'YES' + sc: 'NO' + sender_and_registration_info: null + text_restrictions: null + billable_units: 2 + names: + - Cuba +'54': + attributes: + alpha: 'NO' + comment: OPT-IN REQUIRED for each end user + dlr: 'NO' + generic_sender: '' + numeric: 'NO' + sc: LIMITED + sender_and_registration_info: All senders CONVERTED into one available shared + SC + text_restrictions: Political content NOT allowed + billable_units: 3 + names: + - Argentina +'55': + attributes: + alpha: 'NO' + comment: null + dlr: 'YES' + generic_sender: '' + numeric: 'NO' + sc: LIMITED + sender_and_registration_info: LIMITED amount of SCs available + text_restrictions: NO marketing traffic. NO special characters. 160 chars per + message available + billable_units: 1 + names: + - Brazil +'56': + attributes: + alpha: 'NO' + comment: null + dlr: Carrier DLR + generic_sender: '' + numeric: LIMITED + sc: 'NO' + sender_and_registration_info: All senders CONVERTED into random long numeric senders + text_restrictions: Bulk/marketing traffic NOT allowed + billable_units: 2 + names: + - Chile +'57': + attributes: + alpha: 'NO' + comment: null + dlr: 'NO' + generic_sender: '' + numeric: 'NO' + sc: LIMITED + sender_and_registration_info: LIMITED amount of SCs available + text_restrictions: null + billable_units: 1 + names: + - Colombia +'58': + attributes: + alpha: 'NO' + comment: null + dlr: 'YES' + generic_sender: null + numeric: 'NO' + sc: 'YES' + sender_and_registration_info: null + text_restrictions: null + billable_units: 1 + names: + - Venezuela +'60': + attributes: + alpha: 'NO' + comment: null + dlr: 'YES' + generic_sender: '' + numeric: 'NO' + sc: REG + sender_and_registration_info: LIMITED amount of registered shared SCs available. + HIGH one time and monthly FEEs for each additional dedicated SC + text_restrictions: null + billable_units: 1 + names: + - Malaysia +'61': + attributes: + alpha: 'YES' + comment: null + dlr: 'YES' + generic_sender: '' + numeric: 'YES' + sc: 'NO' + sender_and_registration_info: Long numeric MUST NOT begin with "0" (zero) + text_restrictions: null + billable_units: 2 + names: + - Australia +'62': + attributes: + alpha: REG + comment: Local clients NOT allowed. International clients ONLY + dlr: 'YES' + generic_sender: globalsms/InfoSMS + numeric: 'NO' + sc: 'NO' + sender_and_registration_info: '' + text_restrictions: '' + billable_units: 1 + names: + - Indonesia +'63': + attributes: + alpha: REG + comment: null + dlr: 'YES' + generic_sender: INFO / Globalsms + numeric: 'NO' + sc: 'NO' + sender_and_registration_info: All numeric senders are converted to InfoText + text_restrictions: Adult, alcohol, drugs, gambling, election and tobacco contents + are strictly forbidden + billable_units: 1 + names: + - Philippines +'64': + attributes: + alpha: 'NO' + comment: null + dlr: 'YES' + generic_sender: '' + numeric: 'YES' + sc: 'YES' + sender_and_registration_info: All alpha senders converted to a random UK longnumber + text_restrictions: null + billable_units: 3 + names: + - New Zealand +'65': + attributes: + alpha: REG + comment: Where SMS exceeds 160 characters, it shall be broken into two or more + messages and transmitted separately + dlr: 'YES' + generic_sender: InfoSMS + numeric: LIMITED + sc: 'NO' + sender_and_registration_info: All not registered senders are CONVERTED into "InfoSMS". + LIMITED amount of free senders, MONTHLY FEE for additional Alpha and numeric + senders. Only national long numeric senders ara available + text_restrictions: '' + billable_units: 1 + names: + - Singapore +'66': + attributes: + alpha: REG + comment: Maximum long message lenght is 459 for GSM7 or 201 for Unicode alphabet. + Special registration procedure for sending to DND numbers + dlr: 'YES' + generic_sender: SMS + numeric: REG + sc: REG + sender_and_registration_info: Alpha sender up to 11 characters in length. NO " + " (space) support in the sender name. NO special character at the beginning + of the sender. Numeric sender up to 11 digits in length. Dynamic sender available + over Offnet connection. + text_restrictions: NO political nor erotic content and other text restrictions + billable_units: 1 + names: + - Thailand +'81': + attributes: + alpha: 'NO' + comment: null + dlr: 'YES' + generic_sender: '' + numeric: 'NO' + sc: 'YES' + sender_and_registration_info: 11-digit long short code. + text_restrictions: null + billable_units: 3 + names: + - Japan +'82': + attributes: + alpha: 'NO' + comment: Message length - 140 characters + dlr: 'YES' + generic_sender: '' + numeric: 'YES' + sc: 'YES' + sender_and_registration_info: 'All Alpha senders CONVERTED to one local long numeric. + "00" is ADDED in front of international long numeric sender ids. ' + text_restrictions: "[\uAD6D\uC81C\uBC1C\uC2E0] is added in front of SMS text for\ + \ every inbound P2P and A2P coming from overseas countries." + billable_units: 2 + names: + - Korea, Republic of +'84': + attributes: + alpha: REG + comment: null + dlr: Carrier DLR + generic_sender: InfoSMS + numeric: 'NO' + sc: REG + sender_and_registration_info: One time and monthly FEEs for each sender. All not + registered senders CONVERTED into "InfoSMS" sender + text_restrictions: null + billable_units: 2 + names: + - Vietnam +'86': + attributes: + alpha: 'NO' + comment: Extremenly HIGH penalties for any traffic other than transactional + dlr: Carrier DLR + generic_sender: '' + numeric: LIMITED + sc: 'NO' + sender_and_registration_info: All senders CONVERTED into one available national + numeric sender + text_restrictions: Content template MUST be approved by the MNO. Transactional + traffic ONLY. Sufix added in the message text + billable_units: 1 + names: + - China +'90': + attributes: + alpha: REG + comment: ONLY for Turkish clients. International clients NOT allowed, unless approved + dlr: 'YES' + generic_sender: '' + numeric: LIMITED + sc: LIMITED + sender_and_registration_info: LIMITED amount of numeric senders are allowed (just + some ranges) + text_restrictions: NO lottery, gambling nor erotic content and other text restrictions + billable_units: 1 + names: + - Turkey + - Northern Cyprus +'91': + attributes: + alpha: REG + comment: null + dlr: 'YES' + generic_sender: '' + numeric: 'NO' + sc: 'NO' + sender_and_registration_info: Alpha senders with exactly 6 characters ONLY + text_restrictions: Transactional traffic ONLY + billable_units: 1 + names: + - India +'92': + attributes: + alpha: 'YES' + comment: null + dlr: 'YES' + generic_sender: null + numeric: 'NO' + sc: 'YES' + sender_and_registration_info: null + text_restrictions: null + billable_units: 1 + names: + - Pakistan +'93': + attributes: + alpha: REG + comment: null + dlr: 'YES' + generic_sender: SMS-Info + numeric: 'NO' + sc: 'NO' + sender_and_registration_info: 'All not registered senders CONVERTED to ''SMS-Info''. + Sender and text example required prior to registration. Registration ETA: up + to 10 days.' + text_restrictions: null + billable_units: 3 + names: + - Afghanistan +'94': + attributes: + alpha: 'YES' + comment: null + dlr: 'YES' + generic_sender: null + numeric: 'YES' + sc: 'YES' + sender_and_registration_info: null + text_restrictions: null + billable_units: 1 + names: + - Sri Lanka +'95': + attributes: + alpha: REG + comment: null + dlr: 'YES' + generic_sender: '' + numeric: 'NO' + sc: 'NO' + sender_and_registration_info: There is monthly FEE for renting a SC. No generic + senders available. WEB page and description for each sender needed. + text_restrictions: null + billable_units: 2 + names: + - Myanmar +'98': + attributes: + alpha: 'NO' + comment: null + dlr: 'YES' + generic_sender: null + numeric: 'YES' + sc: 'NO' + sender_and_registration_info: null + text_restrictions: null + billable_units: 3 + names: + - Iran +'211': + attributes: + alpha: null + comment: null + dlr: null + generic_sender: null + numeric: null + sc: null + sender_and_registration_info: null + text_restrictions: null + billable_units: 1 + names: + - South Sudan +'212': + attributes: + alpha: REG + comment: null + dlr: 'YES' + generic_sender: Globalsms + numeric: 'NO' + sc: 'NO' + sender_and_registration_info: Case sensitive senders + text_restrictions: null + billable_units: 2 + names: + - Morocco +'213': + attributes: + alpha: REG + comment: null + dlr: 'YES' + generic_sender: InfoSMS/SMS + numeric: 'NO' + sc: 'NO' + sender_and_registration_info: null + text_restrictions: null + billable_units: 3 + names: + - Algeria +'216': + attributes: + alpha: 'YES' + comment: null + dlr: 'YES' + generic_sender: null + numeric: 'YES' + sc: 'YES' + sender_and_registration_info: null + text_restrictions: null + billable_units: 3 + names: + - Tunisia +'218': + attributes: + alpha: 'NO' + comment: null + dlr: 'YES' + generic_sender: null + numeric: 'YES' + sc: 'NO' + sender_and_registration_info: null + text_restrictions: null + billable_units: 1 + names: + - Libya +'220': + attributes: + alpha: 'YES' + comment: null + dlr: 'YES' + generic_sender: null + numeric: 'YES' + sc: 'YES' + sender_and_registration_info: null + text_restrictions: null + billable_units: 3 + names: + - Gambia +'221': + attributes: + alpha: 'YES' + comment: null + dlr: 'YES' + generic_sender: null + numeric: 'YES' + sc: 'YES' + sender_and_registration_info: null + text_restrictions: null + billable_units: 3 + names: + - Senegal +'222': + attributes: + alpha: null + comment: null + dlr: null + generic_sender: null + numeric: null + sc: null + sender_and_registration_info: null + text_restrictions: null + billable_units: 2 + names: + - Mauritania +'223': + attributes: + alpha: 'YES' + comment: null + dlr: 'YES' + generic_sender: null + numeric: 'YES' + sc: 'YES' + sender_and_registration_info: null + text_restrictions: null + billable_units: 3 + names: + - Mali +'224': + attributes: + alpha: 'YES' + comment: null + dlr: 'YES' + generic_sender: '' + numeric: 'YES' + sc: 'YES' + sender_and_registration_info: 00 added in front of destination + text_restrictions: null + billable_units: 3 + names: + - Guinea +'225': + attributes: + alpha: 'YES' + comment: null + dlr: 'YES' + generic_sender: null + numeric: 'YES' + sc: 'YES' + sender_and_registration_info: null + text_restrictions: null + billable_units: 2 + names: + - Cote d'Ivoire +'226': + attributes: + alpha: 'YES' + comment: null + dlr: 'YES' + generic_sender: null + numeric: 'YES' + sc: 'YES' + sender_and_registration_info: null + text_restrictions: null + billable_units: 1 + names: + - Burkina Faso +'227': + attributes: + alpha: 'YES' + comment: null + dlr: 'YES' + generic_sender: null + numeric: 'YES' + sc: 'YES' + sender_and_registration_info: null + text_restrictions: null + billable_units: 1 + names: + - Niger +'228': + attributes: + alpha: null + comment: null + dlr: null + generic_sender: null + numeric: null + sc: null + sender_and_registration_info: null + text_restrictions: null + billable_units: 1 + names: + - Togo +'229': + attributes: + alpha: null + comment: null + dlr: null + generic_sender: null + numeric: null + sc: null + sender_and_registration_info: null + text_restrictions: null + billable_units: 2 + names: + - Benin +'230': + attributes: + alpha: 'YES' + comment: null + dlr: 'YES' + generic_sender: null + numeric: 'YES' + sc: 'YES' + sender_and_registration_info: null + text_restrictions: null + billable_units: 2 + names: + - Mauritius +'231': + attributes: + alpha: null + comment: null + dlr: null + generic_sender: null + numeric: null + sc: null + sender_and_registration_info: null + text_restrictions: null + billable_units: 2 + names: + - Liberia +'232': + attributes: + alpha: 'YES' + comment: null + dlr: 'YES' + generic_sender: null + numeric: 'YES' + sc: 'YES' + sender_and_registration_info: null + text_restrictions: null + billable_units: 1 + names: + - Sierra Leone +'233': + attributes: + alpha: 'YES' + comment: null + dlr: 'YES' + generic_sender: '' + numeric: 'YES' + sc: REG + sender_and_registration_info: One time and monthly FEEs for each SC + text_restrictions: null + billable_units: 1 + names: + - Ghana +'234': + attributes: + alpha: REG + comment: null + dlr: 'YES' + generic_sender: SMS + numeric: 'NO' + sc: 'NO' + sender_and_registration_info: null + text_restrictions: null + billable_units: 1 + names: + - Nigeria +'235': + attributes: + alpha: 'YES' + comment: null + dlr: 'YES' + generic_sender: null + numeric: 'YES' + sc: 'YES' + sender_and_registration_info: null + text_restrictions: null + billable_units: 2 + names: + - Chad +'236': + attributes: + alpha: null + comment: null + dlr: null + generic_sender: null + numeric: null + sc: null + sender_and_registration_info: null + text_restrictions: null + billable_units: 2 + names: + - Central African Republic +'237': + attributes: + alpha: 'YES' + comment: null + dlr: 'YES' + generic_sender: null + numeric: 'YES' + sc: 'YES' + sender_and_registration_info: null + text_restrictions: null + billable_units: 2 + names: + - Cameroon +'238': + attributes: + alpha: null + comment: null + dlr: null + generic_sender: null + numeric: null + sc: null + sender_and_registration_info: null + text_restrictions: null + billable_units: 3 + names: + - Cape Verde +'239': + attributes: + alpha: null + comment: null + dlr: null + generic_sender: null + numeric: null + sc: null + sender_and_registration_info: null + text_restrictions: null + billable_units: 2 + names: + - Sao Tome and Principe +'240': + attributes: + alpha: null + comment: null + dlr: null + generic_sender: null + numeric: null + sc: null + sender_and_registration_info: null + text_restrictions: null + billable_units: 2 + names: + - Equatorial Guinea +'241': + attributes: + alpha: 'YES' + comment: null + dlr: 'YES' + generic_sender: null + numeric: 'YES' + sc: 'YES' + sender_and_registration_info: null + text_restrictions: null + billable_units: 2 + names: + - Gabon +'242': + attributes: + alpha: 'YES' + comment: null + dlr: 'YES' + generic_sender: null + numeric: 'YES' + sc: 'YES' + sender_and_registration_info: null + text_restrictions: null + billable_units: 3 + names: + - Congo +'243': + attributes: + alpha: 'YES' + comment: null + dlr: 'YES' + generic_sender: null + numeric: 'YES' + sc: 'YES' + sender_and_registration_info: null + text_restrictions: null + billable_units: 2 + names: + - Congo, Democratic Republic of +'244': + attributes: + alpha: 'YES' + comment: null + dlr: 'YES' + generic_sender: Info + numeric: 'NO' + sc: 'NO' + sender_and_registration_info: null + text_restrictions: null + billable_units: 1 + names: + - Angola +'245': + attributes: + alpha: null + comment: null + dlr: null + generic_sender: null + numeric: null + sc: null + sender_and_registration_info: null + text_restrictions: null + billable_units: 2 + names: + - Guinea-Bissau +'246': + attributes: + alpha: null + comment: null + dlr: null + generic_sender: null + numeric: null + sc: null + sender_and_registration_info: null + text_restrictions: null + billable_units: 1 + names: + - British Indian Ocean Territory +'248': + attributes: + alpha: 'YES' + comment: null + dlr: 'YES' + generic_sender: null + numeric: 'YES' + sc: 'YES' + sender_and_registration_info: null + text_restrictions: null + billable_units: 2 + names: + - Seychelles +'249': + attributes: + alpha: null + comment: null + dlr: null + generic_sender: null + numeric: null + sc: null + sender_and_registration_info: null + text_restrictions: null + billable_units: 2 + names: + - Sudan +'250': + attributes: + alpha: 'YES' + comment: null + dlr: 'YES' + generic_sender: null + numeric: 'YES' + sc: 'YES' + sender_and_registration_info: null + text_restrictions: null + billable_units: 3 + names: + - Rwanda, Republic of +'251': + attributes: + alpha: 'YES' + comment: null + dlr: 'YES' + generic_sender: null + numeric: 'YES' + sc: 'YES' + sender_and_registration_info: null + text_restrictions: null + billable_units: 2 + names: + - Ethiopia +'252': + attributes: + alpha: null + comment: null + dlr: null + generic_sender: null + numeric: null + sc: null + sender_and_registration_info: null + text_restrictions: null + billable_units: 2 + names: + - Somalia +'253': + attributes: + alpha: null + comment: null + dlr: null + generic_sender: null + numeric: null + sc: null + sender_and_registration_info: null + text_restrictions: null + billable_units: 3 + names: + - Djibouti, Republic of +'254': + attributes: + alpha: REG + comment: null + dlr: 'YES' + generic_sender: '' + numeric: 'NO' + sc: 'YES' + sender_and_registration_info: ONLY one shared SC available. HIGH one time and + monthly FEEs for each additional sender. Only local entities can register senders + for Safaricom. + text_restrictions: null + billable_units: 1 + names: + - Kenya +'255': + attributes: + alpha: 'YES' + comment: null + dlr: 'YES' + generic_sender: null + numeric: 'YES' + sc: 'YES' + sender_and_registration_info: null + text_restrictions: null + billable_units: 1 + names: + - Tanzania +'256': + attributes: + alpha: REG + comment: null + dlr: 'YES' + generic_sender: INFOSMS + numeric: 'NO' + sc: REG + sender_and_registration_info: All not registered senders CONVERTED to "INFOSMS". + Only local entities can register the sender (requires Authorization letter). + text_restrictions: As per regulation, 'DND*196#' is being added at the end of + each message. + billable_units: 1 + names: + - Uganda +'257': + attributes: + alpha: null + comment: null + dlr: null + generic_sender: null + numeric: null + sc: null + sender_and_registration_info: null + text_restrictions: null + billable_units: 2 + names: + - Burundi +'258': + attributes: + alpha: 'YES' + comment: null + dlr: 'YES' + generic_sender: null + numeric: 'YES' + sc: 'YES' + sender_and_registration_info: null + text_restrictions: null + billable_units: 2 + names: + - Mozambique +'260': + attributes: + alpha: 'YES' + comment: null + dlr: 'YES' + generic_sender: null + numeric: 'YES' + sc: 'YES' + sender_and_registration_info: null + text_restrictions: null + billable_units: 2 + names: + - Zambia +'261': + attributes: + alpha: 'YES' + comment: null + dlr: 'YES' + generic_sender: null + numeric: 'YES' + sc: 'YES' + sender_and_registration_info: null + text_restrictions: null + billable_units: 2 + names: + - Madagascar +'262': + attributes: + alpha: null + comment: null + dlr: null + generic_sender: null + numeric: null + sc: null + sender_and_registration_info: null + text_restrictions: null + billable_units: 3 + names: + - Reunion +'263': + attributes: + alpha: 'YES' + comment: null + dlr: Carrier DLR + generic_sender: null + numeric: 'YES' + sc: 'YES' + sender_and_registration_info: null + text_restrictions: null + billable_units: 1 + names: + - Zimbabwe +'264': + attributes: + alpha: REG + comment: null + dlr: 'YES' + generic_sender: '' + numeric: 'NO' + sc: 'NO' + sender_and_registration_info: Long registration procedure, up to 30 days. Monthly + FEE for each sender + text_restrictions: null + billable_units: 1 + names: + - Namibia +'265': + attributes: + alpha: 'YES' + comment: null + dlr: 'YES' + generic_sender: null + numeric: 'YES' + sc: 'YES' + sender_and_registration_info: null + text_restrictions: null + billable_units: 2 + names: + - Malawi +'266': + attributes: + alpha: null # should be 'REG' + comment: null + dlr: null # should be 'YES' + generic_sender: null # should be "''" + numeric: null # should be 'NO' + sc: null # should be 'NO' + sender_and_registration_info: null # should be "Sender names can only be 3-11 chars, no spaces" + text_restrictions: null + billable_units: 3 + names: + - Lesotho +'267': + attributes: + alpha: 'YES' + comment: null + dlr: 'YES' + generic_sender: null + numeric: 'YES' + sc: 'YES' + sender_and_registration_info: null + text_restrictions: null + billable_units: 3 + names: + - Botswana +'268': + attributes: + alpha: 'YES' + comment: null + dlr: 'YES' + generic_sender: null + numeric: 'YES' + sc: 'YES' + sender_and_registration_info: null + text_restrictions: null + billable_units: 2 + names: + - Swaziland +'269': + attributes: + alpha: null + comment: null + dlr: null + generic_sender: null + numeric: null + sc: null + sender_and_registration_info: null + text_restrictions: null + billable_units: 2 + names: + - Comoros +'297': + attributes: + alpha: null + comment: null + dlr: null + generic_sender: null + numeric: null + sc: null + sender_and_registration_info: null + text_restrictions: null + billable_units: 2 + names: + - Aruba +'298': + attributes: + alpha: null + comment: null + dlr: null + generic_sender: null + numeric: null + sc: null + sender_and_registration_info: null + text_restrictions: null + billable_units: 1 + names: + - Faroe Islands +'299': + attributes: + alpha: null + comment: null + dlr: null + generic_sender: null + numeric: null + sc: null + sender_and_registration_info: null + text_restrictions: null + billable_units: 1 + names: + - Greenland +'350': + attributes: + alpha: 'NO' + comment: null + dlr: 'YES' + generic_sender: null + numeric: 'YES' + sc: 'NO' + sender_and_registration_info: null + text_restrictions: null + billable_units: 2 + names: + - Gibraltar +'351': + attributes: + alpha: 'YES' + comment: null + dlr: 'YES' + generic_sender: null + numeric: 'NO' + sc: 'NO' + sender_and_registration_info: null + text_restrictions: null + billable_units: 2 + names: + - Portugal +'352': + attributes: + alpha: 'YES' + comment: null + dlr: 'YES' + generic_sender: null + numeric: 'YES' + sc: 'YES' + sender_and_registration_info: null + text_restrictions: null + billable_units: 2 + names: + - Luxembourg +'353': + attributes: + alpha: 'YES' + comment: null + dlr: 'YES' + generic_sender: '' + numeric: 'YES' + sc: 'NO' + sender_and_registration_info: Numeric sender up to 12 digits in length. SC available + upon registration + text_restrictions: null + billable_units: 2 + names: + - Ireland +'354': + attributes: + alpha: 'YES' + comment: null + dlr: 'YES' + generic_sender: null + numeric: 'YES' + sc: 'YES' + sender_and_registration_info: null + text_restrictions: null + billable_units: 1 + names: + - Iceland +'355': + attributes: + alpha: 'YES' + comment: null + dlr: 'YES' + generic_sender: null + numeric: 'YES' + sc: 'YES' + sender_and_registration_info: null + text_restrictions: null + billable_units: 1 + names: + - Albania +'356': + attributes: + alpha: 'YES' + comment: null + dlr: 'YES' + generic_sender: null + numeric: 'YES' + sc: 'YES' + sender_and_registration_info: null + text_restrictions: null + billable_units: 1 + names: + - Malta +'357': + attributes: + alpha: REG + comment: null + dlr: 'YES' + generic_sender: InfoSMS/Message + numeric: 'NO' + sc: 'NO' + sender_and_registration_info: Senders up to 11 characters in length. NO special + characters. Generic senders available + text_restrictions: NO violent, offensive, discriminatory or erotic content + billable_units: 1 + names: + - Cyprus +'358': + attributes: + alpha: 'YES' + comment: null + dlr: 'YES' + generic_sender: '' + numeric: 'YES' + sc: 'YES' + sender_and_registration_info: $ INSERTED in front of each Alpha sender, "00" in + front of long numeric senders + text_restrictions: null + billable_units: 2 + names: + - Finland +'359': + attributes: + alpha: 'NO' + comment: null + dlr: 'YES' + generic_sender: '' + numeric: 'NO' + sc: 'YES' + sender_and_registration_info: Sender converts to short code 1917. + text_restrictions: null + billable_units: 3 + names: + - Bulgaria +'370': + attributes: + alpha: 'YES' + comment: null + dlr: 'YES' + generic_sender: null + numeric: 'YES' + sc: 'YES' + sender_and_registration_info: null + text_restrictions: null + billable_units: 1 + names: + - Lithuania +'371': + attributes: + alpha: 'YES' + comment: null + dlr: 'YES' + generic_sender: null + numeric: 'NO' + sc: 'NO' + sender_and_registration_info: null + text_restrictions: null + billable_units: 2 + names: + - Latvia +'372': + attributes: + alpha: 'YES' + comment: OPT-INs required + dlr: 'YES' + generic_sender: '' + numeric: 'YES' + sc: 'YES' + sender_and_registration_info: '' + text_restrictions: NO messages in any way related to premium rate services + billable_units: 2 + names: + - Estonia +'373': + attributes: + alpha: 'NO' + comment: null + dlr: Carrier DLR + generic_sender: '' + numeric: 'NO' + sc: 'YES' + sender_and_registration_info: All senders CONVERTED into one SC + text_restrictions: null + billable_units: 3 + names: + - Moldova +'374': + attributes: + alpha: REG + comment: null + dlr: 'YES' + generic_sender: '' + numeric: REG + sc: REG + sender_and_registration_info: NO long numeric senders + text_restrictions: null + billable_units: 3 + names: + - Armenia +'375': + attributes: + alpha: REG + comment: null + dlr: 'YES' + generic_sender: SMSinfo + numeric: 'NO' + sc: 'NO' + sender_and_registration_info: null + text_restrictions: null + billable_units: 1 + names: + - Belarus +'376': + attributes: + alpha: 'YES' + comment: null + dlr: 'YES' + generic_sender: null + numeric: 'YES' + sc: 'YES' + sender_and_registration_info: null + text_restrictions: null + billable_units: 1 + names: + - Andorra +'377': + attributes: + alpha: 'NO' + comment: null + dlr: 'YES' + generic_sender: null + numeric: 'YES' + sc: 'NO' + sender_and_registration_info: null + text_restrictions: null + billable_units: 3 + names: + - Monaco +'378': + attributes: + alpha: null + comment: null + dlr: null + generic_sender: null + numeric: null + sc: null + sender_and_registration_info: null + text_restrictions: null + billable_units: 1 + names: + - San Marino, Republic of +'380': + attributes: + alpha: REG + comment: null + dlr: 'YES' + generic_sender: Info/INFO/InfoSMS/SMS + numeric: REG + sc: 'NO' + sender_and_registration_info: Alpha senders are case sensitive + text_restrictions: null + billable_units: 3 + names: + - Ukraine +'381': + attributes: + alpha: REG + comment: All traffic bulks MUST be registered at MNO + dlr: 'YES' + generic_sender: '' + numeric: 'NO' + sc: REG + sender_and_registration_info: Monthly FEE for each SC + text_restrictions: Transactional traffic ONLY + billable_units: 1 + names: + - Serbia +'382': + attributes: + alpha: REG + comment: null + dlr: 'YES' + generic_sender: null + numeric: REG + sc: REG + sender_and_registration_info: null + text_restrictions: null + billable_units: 2 + names: + - Montenegro +'385': + attributes: + alpha: REG + comment: null + dlr: 'YES' + generic_sender: InfoSMS + numeric: 'NO' + sc: REG + sender_and_registration_info: One time and monthly FEES for each sender. NO special + chars nor " " (space) support in the sender name. Instead of space "_" (underscore) + is being used + text_restrictions: null + billable_units: 2 + names: + - Croatia +'386': + attributes: + alpha: REG + comment: null + dlr: 'YES' + generic_sender: InfoSMS + numeric: LIMITED + sc: 'NO' + sender_and_registration_info: null + text_restrictions: null + billable_units: 1 + names: + - Slovenia +'387': + attributes: + alpha: REG + comment: null + dlr: 'YES' + generic_sender: INFOSMS + numeric: REG + sc: REG + sender_and_registration_info: Numeric sender available for the FEE upon registration. + SC registration CHARGED - one time and monthly FEEs per each sender. + text_restrictions: null + billable_units: 1 + names: + - Bosnia and Herzegovina +'389': + attributes: + alpha: 'YES' + comment: null + dlr: 'YES' + generic_sender: null + numeric: 'NO' + sc: 'YES' + sender_and_registration_info: null + text_restrictions: null + billable_units: 1 + names: + - Macedonia +'420': + attributes: + alpha: REG + comment: null + dlr: 'YES' + generic_sender: Info + numeric: 'NO' + sc: 'NO' + sender_and_registration_info: All not registered senders CONVERTED into "Info" + sender. Monthly FEE for each additional sender. NO special chars nor " " (space) + support in the sender name + text_restrictions: Traffic allowed ONLY between 8am and 6pm (CET) on working days. + Content promoting lottery, betting, gambling nor consumer loans NOT allowed. + NO political, violent, erotic nor abusive content and other text restrictions + billable_units: 2 + names: + - Czech Republic +'421': + attributes: + alpha: 'YES' + comment: P2P not allowed + dlr: 'YES' + generic_sender: '' + numeric: REG + sc: 'NO' + sender_and_registration_info: SC up to 6 digits in length. One time and monthly + FEEs for numeric senders. All not registered long numeric senders are CONVERTED + into "Info" + text_restrictions: '' + billable_units: 2 + names: + - Slovakia +'423': + attributes: + alpha: 'YES' + comment: null + dlr: 'YES' + generic_sender: null + numeric: LIMITED + sc: 'YES' + sender_and_registration_info: null + text_restrictions: null + billable_units: 1 + names: + - Liechtenstein +'500': + attributes: + alpha: 'YES' + comment: null + dlr: 'YES' + generic_sender: null + numeric: 'YES' + sc: 'YES' + sender_and_registration_info: null + text_restrictions: null + billable_units: 1 + names: + - Falkland Islands +'501': + attributes: + alpha: 'YES' + comment: null + dlr: 'YES' + generic_sender: null + numeric: 'YES' + sc: 'YES' + sender_and_registration_info: null + text_restrictions: null + billable_units: 2 + names: + - Belize +'502': + attributes: + alpha: 'NO' + comment: null + dlr: 'YES' + generic_sender: null + numeric: 'YES' + sc: 'NO' + sender_and_registration_info: null + text_restrictions: null + billable_units: 2 + names: + - Guatemala +'503': + attributes: + alpha: 'YES' + comment: null + dlr: 'YES' + generic_sender: null + numeric: 'YES' + sc: 'YES' + sender_and_registration_info: null + text_restrictions: null + billable_units: 2 + names: + - El Salvador +'504': + attributes: + alpha: null + comment: null + dlr: null + generic_sender: null + numeric: null + sc: null + sender_and_registration_info: null + text_restrictions: null + billable_units: 2 + names: + - Honduras +'505': + attributes: + alpha: null + comment: null + dlr: null + generic_sender: null + numeric: null + sc: null + sender_and_registration_info: null + text_restrictions: null + billable_units: 2 + names: + - Nicaragua +'506': + attributes: + alpha: 'YES' + comment: null + dlr: 'YES' + generic_sender: null + numeric: 'YES' + sc: 'YES' + sender_and_registration_info: null + text_restrictions: null + billable_units: 2 + names: + - Costa Rica +'507': + attributes: + alpha: 'NO' + comment: null + dlr: 'YES' + generic_sender: null + numeric: 'YES' + sc: 'NO' + sender_and_registration_info: null + text_restrictions: null + billable_units: 2 + names: + - Panama +'508': + attributes: + alpha: null + comment: null + dlr: null + generic_sender: null + numeric: null + sc: null + sender_and_registration_info: null + text_restrictions: null + billable_units: 3 + names: + - Saint Pierre and Miquelon +'509': + attributes: + alpha: null + comment: null + dlr: null + generic_sender: null + numeric: null + sc: null + sender_and_registration_info: null + text_restrictions: null + billable_units: 2 + names: + - Haiti +'590': + attributes: + alpha: 'NO' + comment: null + dlr: 'YES' + generic_sender: null + numeric: 'YES' + sc: 'NO' + sender_and_registration_info: null + text_restrictions: null + billable_units: 2 + names: + - Guadeloupe +'591': + attributes: + alpha: 'YES' + comment: null + dlr: 'YES' + generic_sender: null + numeric: 'YES' + sc: 'YES' + sender_and_registration_info: null + text_restrictions: null + billable_units: 1 + names: + - Bolivia +'592': + attributes: + alpha: 'YES' + comment: null + dlr: 'YES' + generic_sender: null + numeric: 'YES' + sc: 'YES' + sender_and_registration_info: null + text_restrictions: null + billable_units: 2 + names: + - Guyana +'593': + attributes: + alpha: 'NO' + comment: null + dlr: '' + generic_sender: null + numeric: 'NO' + sc: 'YES' + sender_and_registration_info: null + text_restrictions: null + billable_units: 1 + names: + - Ecuador +'594': + attributes: + alpha: null + comment: null + dlr: null + generic_sender: null + numeric: null + sc: null + sender_and_registration_info: null + text_restrictions: null + billable_units: 2 + names: + - French Guiana +'595': + attributes: + alpha: null + comment: null + dlr: null + generic_sender: null + numeric: null + sc: null + sender_and_registration_info: null + text_restrictions: null + billable_units: 2 + names: + - Paraguay +'596': + attributes: + alpha: null + comment: null + dlr: null + generic_sender: null + numeric: null + sc: null + sender_and_registration_info: null + text_restrictions: null + billable_units: 2 + names: + - Martinique +'597': + attributes: + alpha: null + comment: null + dlr: null + generic_sender: null + numeric: null + sc: null + sender_and_registration_info: null + text_restrictions: null + billable_units: 2 + names: + - Suriname +'598': + attributes: + alpha: 'NO' + comment: null + dlr: 'YES' + generic_sender: null + numeric: 'YES' + sc: 'NO' + sender_and_registration_info: null + text_restrictions: null + billable_units: 3 + names: + - Uruguay +'599': + attributes: + alpha: 'YES' + comment: null + dlr: 'YES' + generic_sender: null + numeric: 'YES' + sc: 'YES' + sender_and_registration_info: null + text_restrictions: null + billable_units: 2 + names: + - Curacao (former Netherlands Antilles) +'670': + attributes: + alpha: null + comment: null + dlr: null + generic_sender: null + numeric: null + sc: null + sender_and_registration_info: null + text_restrictions: null + billable_units: 1 + names: + - Timor L'este +'672': + attributes: + alpha: null + comment: null + dlr: null + generic_sender: null + numeric: null + sc: null + sender_and_registration_info: null + text_restrictions: null + billable_units: 3 + names: + - Norfolk Island +'673': + attributes: + alpha: 'YES' + comment: null + dlr: 'YES' + generic_sender: null + numeric: 'YES' + sc: 'YES' + sender_and_registration_info: null + text_restrictions: null + billable_units: 1 + names: + - Brunei Darussalam +'674': + attributes: + alpha: null + comment: null + dlr: null + generic_sender: null + numeric: null + sc: null + sender_and_registration_info: null + text_restrictions: null + billable_units: 2 + names: + - Nauru +'675': + attributes: + alpha: 'YES' + comment: null + dlr: 'YES' + generic_sender: null + numeric: 'YES' + sc: 'YES' + sender_and_registration_info: null + text_restrictions: null + billable_units: 2 + names: + - Papua New Guinea +'676': + attributes: + alpha: null + comment: null + dlr: null + generic_sender: null + numeric: null + sc: null + sender_and_registration_info: null + text_restrictions: null + billable_units: 2 + names: + - Tonga +'677': + attributes: + alpha: null + comment: null + dlr: null + generic_sender: null + numeric: null + sc: null + sender_and_registration_info: null + text_restrictions: null + billable_units: 1 + names: + - Solomon Islands +'678': + attributes: + alpha: null + comment: null + dlr: null + generic_sender: null + numeric: null + sc: null + sender_and_registration_info: null + text_restrictions: null + billable_units: 2 + names: + - Vanuatu +'679': + attributes: + alpha: 'YES' + comment: null + dlr: 'YES' + generic_sender: null + numeric: 'YES' + sc: 'YES' + sender_and_registration_info: null + text_restrictions: null + billable_units: 2 + names: + - Fiji +'680': + attributes: + alpha: null + comment: null + dlr: null + generic_sender: null + numeric: null + sc: null + sender_and_registration_info: null + text_restrictions: null + billable_units: 1 + names: + - Palau +'682': + attributes: + alpha: null + comment: null + dlr: null + generic_sender: null + numeric: null + sc: null + sender_and_registration_info: null + text_restrictions: null + billable_units: 1 + names: + - Cook Islands +'685': + attributes: + alpha: null + comment: null + dlr: null + generic_sender: null + numeric: null + sc: null + sender_and_registration_info: null + text_restrictions: null + billable_units: 3 + names: + - Samoa +'687': + attributes: + alpha: 'YES' + comment: null + dlr: 'YES' + generic_sender: null + numeric: 'YES' + sc: 'YES' + sender_and_registration_info: null + text_restrictions: null + billable_units: 1 + names: + - New Caledonia +'689': + attributes: + alpha: 'YES' + comment: null + dlr: 'YES' + generic_sender: null + numeric: 'YES' + sc: 'YES' + sender_and_registration_info: null + text_restrictions: null + billable_units: 2 + names: + - French Polynesia +'691': + attributes: + alpha: null + comment: null + dlr: null + generic_sender: null + numeric: null + sc: null + sender_and_registration_info: null + text_restrictions: null + billable_units: 2 + names: + - Micronesia, Federated States of +'692': + attributes: + alpha: null + comment: null + dlr: null + generic_sender: null + numeric: null + sc: null + sender_and_registration_info: null + text_restrictions: null + billable_units: 1 + names: + - Marshall Islands +'852': + attributes: + alpha: REG + comment: DND register is being used + dlr: 'YES' + generic_sender: '' + numeric: REG + sc: 'NO' + sender_and_registration_info: '' + text_restrictions: OPT-IN required for promotional traffic. NO violent, discriminatory + nor erotic content + billable_units: 3 + names: + - Hong Kong +'853': + attributes: + alpha: 'YES' + comment: null + dlr: 'YES' + generic_sender: null + numeric: 'YES' + sc: 'YES' + sender_and_registration_info: null + text_restrictions: null + billable_units: 1 + names: + - Macau +'855': + attributes: + alpha: 'NO' + comment: null + dlr: 'YES' + generic_sender: '' + numeric: LIMITED + sc: 'NO' + sender_and_registration_info: All senders CONVERTED into random long numeric senders + text_restrictions: null + billable_units: 1 + names: + - Cambodia +'856': + attributes: + alpha: 'NO' + comment: null + dlr: 'YES' + generic_sender: null + numeric: 'YES' + sc: 'NO' + sender_and_registration_info: null + text_restrictions: null + billable_units: 1 + names: + - Laos +'880': + attributes: + alpha: 'YES' + comment: null + dlr: 'YES' + generic_sender: null + numeric: 'YES' + sc: 'YES' + sender_and_registration_info: null + text_restrictions: null + billable_units: 3 + names: + - Bangladesh +'886': + attributes: + alpha: 'NO' + comment: null + dlr: 'NO' + generic_sender: '' + numeric: LIMITED + sc: 'NO' + sender_and_registration_info: ONLY one shared long numeric available + text_restrictions: null + billable_units: 2 + names: + - Taiwan +'960': + attributes: + alpha: null + comment: null + dlr: null + generic_sender: null + numeric: null + sc: null + sender_and_registration_info: null + text_restrictions: null + billable_units: 1 + names: + - Maldives +'961': + attributes: + alpha: 'NO' + comment: null + dlr: 'YES' + generic_sender: null + numeric: 'YES' + sc: 'NO' + sender_and_registration_info: null + text_restrictions: null + billable_units: 1 + names: + - Lebanon +'962': + attributes: + alpha: 'YES' + comment: null + dlr: 'YES' + generic_sender: null + numeric: 'YES' + sc: 'YES' + sender_and_registration_info: null + text_restrictions: null + billable_units: 1 + names: + - Jordan +'963': + attributes: + alpha: 'NO' + comment: null + dlr: 'YES' + generic_sender: null + numeric: 'YES' + sc: 'NO' + sender_and_registration_info: null + text_restrictions: null + billable_units: 2 + names: + - Syria +'964': + attributes: + alpha: 'YES' + comment: null + dlr: 'YES' + generic_sender: null + numeric: 'YES' + sc: 'NO' + sender_and_registration_info: null + text_restrictions: null + billable_units: 3 + names: + - Iraq +'965': + attributes: + alpha: REG + comment: null + dlr: 'YES' + generic_sender: '' + numeric: 'NO' + sc: 'NO' + sender_and_registration_info: No new senders available at the moment. Registration + ETA 1-5 days. + text_restrictions: null + billable_units: 2 + names: + - Kuwait +'966': + attributes: + alpha: 'YES' + comment: null + dlr: 'YES' + generic_sender: null + numeric: 'YES' + sc: 'NO' + sender_and_registration_info: null + text_restrictions: null + billable_units: 1 + names: + - Saudi Arabia +'967': + attributes: + alpha: null + comment: null + dlr: null + generic_sender: null + numeric: null + sc: null + sender_and_registration_info: null + text_restrictions: null + billable_units: 1 + names: + - Yemen +'968': + attributes: + alpha: REG + comment: null + dlr: 'YES' + generic_sender: '' + numeric: 'NO' + sc: 'NO' + sender_and_registration_info: Only local entities can register senders. NOC and + Trace Licence required. + text_restrictions: null + billable_units: 1 + names: + - Oman +'970': + attributes: + alpha: null + comment: null + dlr: null + generic_sender: null + numeric: null + sc: null + sender_and_registration_info: null + text_restrictions: null + billable_units: 1 + names: + - Palestinian Territory +'971': + attributes: + alpha: REG + comment: OPT-OUT option in the message is required + dlr: 'YES' + generic_sender: SMS-Info + numeric: 'NO' + sc: REG + sender_and_registration_info: NOC letter is required to begin the registration + process. One time and monthly FEEs for each additional SC + text_restrictions: 'No marketing traffic between 8pm and 8am (GMT +4). International + traffic is allowed. ' + billable_units: 2 + names: + - United Arab Emirates +'972': + attributes: + alpha: 'YES' + comment: null + dlr: 'YES' + generic_sender: null + numeric: 'NO' + sc: 'NO' + sender_and_registration_info: null + text_restrictions: null + billable_units: 1 + names: + - Israel +'973': + attributes: + alpha: 'YES' + comment: null + dlr: 'YES' + generic_sender: null + numeric: 'YES' + sc: 'YES' + sender_and_registration_info: null + text_restrictions: null + billable_units: 1 + names: + - Bahrain +'974': + attributes: + alpha: REG + comment: null + dlr: 'YES' + generic_sender: INFOSMSI / Message + numeric: 'NO' + sc: 'NO' + sender_and_registration_info: Case sensitive senders. SCs and numeric senders + MUST have more than 5 digits. All not registered senders are CONVERTED to "INFOSMSI" + text_restrictions: null + billable_units: 1 + names: + - Qatar +'975': + attributes: + alpha: null + comment: null + dlr: null + generic_sender: null + numeric: null + sc: null + sender_and_registration_info: null + text_restrictions: null + billable_units: 1 + names: + - Bhutan +'976': + attributes: + alpha: 'NO' + comment: null + dlr: 'YES' + generic_sender: null + numeric: 'YES' + sc: 'NO' + sender_and_registration_info: null + text_restrictions: null + billable_units: 1 + names: + - Mongolia +'977': + attributes: + alpha: 'NO' + comment: null + dlr: 'YES' + generic_sender: null + numeric: 'YES' + sc: 'NO' + sender_and_registration_info: null + text_restrictions: null + billable_units: 2 + names: + - Nepal +'992': + attributes: + alpha: 'YES' + comment: null + dlr: 'YES' + generic_sender: '' + numeric: REG + sc: 'NO' + sender_and_registration_info: Numeric sender available upon registration. Numeric + sender MUST begin with "992" or "0" (zero) + text_restrictions: null + billable_units: 1 + names: + - Tajikistan +'993': + attributes: + alpha: null + comment: null + dlr: null + generic_sender: null + numeric: null + sc: null + sender_and_registration_info: null + text_restrictions: null + billable_units: 1 + names: + - Turkmenistan +'994': + attributes: + alpha: 'YES' + comment: null + dlr: 'YES' + generic_sender: '' + numeric: 'NO' + sc: 'NO' + sender_and_registration_info: '' + text_restrictions: NO political, erotic, religious, alcoholic or tobacco products, + premium services related info. APPROVAL required for food add., medical, non-govern. + organizations or minor individuals related messages + billable_units: 3 + names: + - Azerbaijan +'995': + attributes: + alpha: REG + comment: null + dlr: 'YES' + generic_sender: InfoSMS + numeric: 'NO' + sc: 'NO' + sender_and_registration_info: '' + text_restrictions: Few GSM 7 characters are not supported + billable_units: 1 + names: + - Georgia +'996': + attributes: + alpha: 'YES' + comment: null + dlr: 'YES' + generic_sender: '' + numeric: REG + sc: 'NO' + sender_and_registration_info: ONLY two shared numeric senders available. Long + numeric usage MUST be approved by MNO + text_restrictions: null + billable_units: 1 + names: + - Kyrgyzstan +'998': + attributes: + alpha: REG + comment: All features are supported (DLRs, longSMS, special characters, Unicode) + dlr: 'YES' + generic_sender: InfoSMS + numeric: 'NO' + sc: 'NO' + sender_and_registration_info: Numeric and all non registered senders are converted + to InfoSMS + text_restrictions: '' + billable_units: 1 + names: + - Uzbekistan +'1242': + attributes: + alpha: 'NO' + comment: null + dlr: Carrier DLR + generic_sender: '' + numeric: LIMITED + sc: 'NO' + sender_and_registration_info: All senders CONVERTED into random long numeric senders + text_restrictions: Bulk/marketing traffic NOT allowed + billable_units: 2 + names: + - Bahamas +'1246': + attributes: + alpha: 'NO' + comment: null + dlr: Carrier DLR + generic_sender: '' + numeric: LIMITED + sc: 'NO' + sender_and_registration_info: All senders CONVERTED into random long numeric senders + text_restrictions: Bulk/marketing traffic NOT allowed + billable_units: 2 + names: + - Barbados +'1264': + attributes: + alpha: 'YES' + comment: null + dlr: 'YES' + generic_sender: null + numeric: 'YES' + sc: 'YES' + sender_and_registration_info: null + text_restrictions: null + billable_units: 2 + names: + - Anguilla +'1268': + attributes: + alpha: 'YES' + comment: null + dlr: 'YES' + generic_sender: null + numeric: 'YES' + sc: 'YES' + sender_and_registration_info: null + text_restrictions: null + billable_units: 2 + names: + - Antigua and Barbuda +'1284': + attributes: + alpha: 'YES' + comment: null + dlr: 'YES' + generic_sender: null + numeric: 'YES' + sc: 'YES' + sender_and_registration_info: null + text_restrictions: null + billable_units: 1 + names: + - Virgin Islands, British +'1345': + attributes: + alpha: 'YES' + comment: null + dlr: 'YES' + generic_sender: null + numeric: 'YES' + sc: 'YES' + sender_and_registration_info: null + text_restrictions: null + billable_units: 2 + names: + - Cayman Islands +'1441': + attributes: + alpha: null + comment: null + dlr: null + generic_sender: null + numeric: null + sc: null + sender_and_registration_info: null + text_restrictions: null + billable_units: 2 + names: + - Bermuda +'1473': + attributes: + alpha: 'YES' + comment: null + dlr: 'YES' + generic_sender: null + numeric: 'YES' + sc: 'YES' + sender_and_registration_info: null + text_restrictions: null + billable_units: 2 + names: + - Grenada +'1649': + attributes: + alpha: 'YES' + comment: null + dlr: 'YES' + generic_sender: null + numeric: 'YES' + sc: 'YES' + sender_and_registration_info: null + text_restrictions: null + billable_units: 2 + names: + - Turks and Caicos Islands +'1664': + attributes: + alpha: 'NO' + comment: null + dlr: 'YES' + generic_sender: '' + numeric: LIMITED + sc: 'NO' + sender_and_registration_info: All senders CONVERTED into random long numeric senders + text_restrictions: Bulk/marketing traffic NOT allowed + billable_units: 1 + names: + - Montserrat +'1684': + attributes: + alpha: 'NO' + comment: null + dlr: Carrier DLR + generic_sender: '' + numeric: LIMITED + sc: 'NO' + sender_and_registration_info: All senders CONVERTED into random long numeric senders + text_restrictions: Bulk/marketing traffic NOT allowed + billable_units: 3 + names: + - American Samoa +'1721': + attributes: + alpha: null + comment: null + dlr: null + generic_sender: null + numeric: null + sc: null + sender_and_registration_info: null + text_restrictions: null + billable_units: 1 + names: + - Sint Maarten +'1758': + attributes: + alpha: 'YES' + comment: null + dlr: 'YES' + generic_sender: null + numeric: 'YES' + sc: 'YES' + sender_and_registration_info: null + text_restrictions: null + billable_units: 2 + names: + - Saint Lucia +'1767': + attributes: + alpha: 'YES' + comment: null + dlr: 'YES' + generic_sender: null + numeric: 'YES' + sc: 'YES' + sender_and_registration_info: null + text_restrictions: null + billable_units: 2 + names: + - Dominica, Commonwealth of +'1784': + attributes: + alpha: 'YES' + comment: null + dlr: 'YES' + generic_sender: null + numeric: 'YES' + sc: 'YES' + sender_and_registration_info: null + text_restrictions: null + billable_units: 2 + names: + - Saint Vincent and The Grenadines +'1868': + attributes: + alpha: null + comment: null + dlr: null + generic_sender: null + numeric: null + sc: null + sender_and_registration_info: null + text_restrictions: null + billable_units: 2 + names: + - Trinidad and Tobago +'1869': + attributes: + alpha: 'YES' + comment: null + dlr: 'YES' + generic_sender: null + numeric: 'YES' + sc: 'YES' + sender_and_registration_info: null + text_restrictions: null + billable_units: 2 + names: + - Saint Kitts and Nevis +'1876': + attributes: + alpha: 'YES' + comment: null + dlr: 'YES' + generic_sender: null + numeric: 'YES' + sc: 'YES' + sender_and_registration_info: null + text_restrictions: null + billable_units: 1 + names: + - Jamaica diff --git a/notifications_utils/jinja_templates/broadcast_preview_template.jinja2 b/notifications_utils/jinja_templates/broadcast_preview_template.jinja2 new file mode 100644 index 000000000..bf22045fe --- /dev/null +++ b/notifications_utils/jinja_templates/broadcast_preview_template.jinja2 @@ -0,0 +1,12 @@ +
+

+ + Emergency alert +

+ {{ body }} +
diff --git a/notifications_utils/jinja_templates/email_preview_template.jinja2 b/notifications_utils/jinja_templates/email_preview_template.jinja2 new file mode 100644 index 000000000..68314f203 --- /dev/null +++ b/notifications_utils/jinja_templates/email_preview_template.jinja2 @@ -0,0 +1,39 @@ + diff --git a/notifications_utils/jinja_templates/email_template.jinja2 b/notifications_utils/jinja_templates/email_template.jinja2 new file mode 100644 index 000000000..5ecd58a8d --- /dev/null +++ b/notifications_utils/jinja_templates/email_template.jinja2 @@ -0,0 +1,252 @@ +{% if complete_html %} + + + + + + + + {{ subject }} + + + + + + + + +{% endif %} +{{ preheader }}… +{% if govuk_banner %} + + + + +
+ + + + + +
+ + + + + +
+ Notify.gov +
+
+
+ +
+ + + + + + + +{% endif %} +{% if brand_banner %} + {% set brand_colour = brand_colour if brand_colour else '#0b0c0c' %} + + + + +
+ + + + {% if brand_logo %} + + {% endif %} + {% if brand_text %} + + {% endif %} + +
+ {% if brand_text %}{% else -%}{{ brand_name }}{%- endif %} + + + {{ brand_text }} + +
+ +
+{% endif %} +{% if brand_logo and not brand_banner %} + + + + + + + +{% endif %} + + + + + + + + + + + + + +{% if complete_html %} + + +{% endif %} diff --git a/notifications_utils/jinja_templates/letter_image_template.jinja2 b/notifications_utils/jinja_templates/letter_image_template.jinja2 new file mode 100644 index 000000000..b03428ca1 --- /dev/null +++ b/notifications_utils/jinja_templates/letter_image_template.jinja2 @@ -0,0 +1,37 @@ +{% for page_number in page_numbers %} +
+ {% if loop.first and show_postage %} +

+ Postage: {{ postage_description }} +

+ {% endif %} + +
+{% endfor %} + +
+

+ Recipient address +

+
    + {%- for line in address -%} +
  • {{ line }}
  • + {%- endfor -%} +
+

+ Contact block +

+

+ {{ contact_block }} +

+

+ Content +

+

+ {{ date }} +

+

+ {{ subject }} +

+ {{ message }} +
diff --git a/notifications_utils/jinja_templates/letter_pdf/_body.jinja2 b/notifications_utils/jinja_templates/letter_pdf/_body.jinja2 new file mode 100644 index 000000000..6e16bfe61 --- /dev/null +++ b/notifications_utils/jinja_templates/letter_pdf/_body.jinja2 @@ -0,0 +1,33 @@ + + + +
+
+ 000_000_0000000_000000_0000_00000 +
+
    + {%- for line in address -%} +
  • {{ line }}
  • + {%- endfor -%} +
+
+
+
+
+
+
+ {{ contact_block }} +
+
+

+ {{ date }} +

+

{{ subject }}

+ {{ message }} +
+ + diff --git a/notifications_utils/jinja_templates/letter_pdf/_head.jinja2 b/notifications_utils/jinja_templates/letter_pdf/_head.jinja2 new file mode 100644 index 000000000..064237fa6 --- /dev/null +++ b/notifications_utils/jinja_templates/letter_pdf/_head.jinja2 @@ -0,0 +1,7 @@ + + + + + + Preview – GOV.UK Notify + diff --git a/notifications_utils/jinja_templates/letter_pdf/_main_css.jinja2 b/notifications_utils/jinja_templates/letter_pdf/_main_css.jinja2 new file mode 100644 index 000000000..c73684d32 --- /dev/null +++ b/notifications_utils/jinja_templates/letter_pdf/_main_css.jinja2 @@ -0,0 +1,197 @@ +{% set line_height = '16.0pt' %} + diff --git a/notifications_utils/jinja_templates/letter_pdf/_print_only_css.jinja2 b/notifications_utils/jinja_templates/letter_pdf/_print_only_css.jinja2 new file mode 100644 index 000000000..3d4c2c59b --- /dev/null +++ b/notifications_utils/jinja_templates/letter_pdf/_print_only_css.jinja2 @@ -0,0 +1,17 @@ + diff --git a/notifications_utils/jinja_templates/letter_pdf/preview.jinja2 b/notifications_utils/jinja_templates/letter_pdf/preview.jinja2 new file mode 100644 index 000000000..d1b73fefb --- /dev/null +++ b/notifications_utils/jinja_templates/letter_pdf/preview.jinja2 @@ -0,0 +1,3 @@ +{% include 'letter_pdf/_head.jinja2' %} +{% include 'letter_pdf/_main_css.jinja2' %} +{% include 'letter_pdf/_body.jinja2' %} diff --git a/notifications_utils/jinja_templates/letter_pdf/print.jinja2 b/notifications_utils/jinja_templates/letter_pdf/print.jinja2 new file mode 100644 index 000000000..f56f49746 --- /dev/null +++ b/notifications_utils/jinja_templates/letter_pdf/print.jinja2 @@ -0,0 +1,4 @@ +{% include 'letter_pdf/_head.jinja2' %} +{% include 'letter_pdf/_main_css.jinja2' %} +{% include 'letter_pdf/_print_only_css.jinja2' %} +{% include 'letter_pdf/_body.jinja2' %} diff --git a/notifications_utils/jinja_templates/sms_preview_template.jinja2 b/notifications_utils/jinja_templates/sms_preview_template.jinja2 new file mode 100644 index 000000000..dea33cce5 --- /dev/null +++ b/notifications_utils/jinja_templates/sms_preview_template.jinja2 @@ -0,0 +1,13 @@ +{% if show_sender %} +

+ From: {{ sender }} +

+{% endif %} +{% if show_recipient %} +

+ To: {{ recipient }} +

+{% endif %} +
+ {{ body }} +
diff --git a/notifications_utils/letter_timings.py b/notifications_utils/letter_timings.py new file mode 100644 index 000000000..62abf2c21 --- /dev/null +++ b/notifications_utils/letter_timings.py @@ -0,0 +1,180 @@ +from collections import namedtuple +from datetime import datetime, time, timedelta + +import pytz +from govuk_bank_holidays.bank_holidays import BankHolidays + +from notifications_utils.countries.data import Postage +from notifications_utils.timezones import utc_string_to_aware_gmt_datetime + +LETTER_PROCESSING_DEADLINE = time(17, 30) +CANCELLABLE_JOB_LETTER_STATUSES = [ + "created", + "cancelled", + "virus-scan-failed", + "validation-failed", + "technical-failure", + "pending-virus-check", +] + + +non_working_days_dvla = BankHolidays( + use_cached_holidays=True, + weekend=(5, 6), +) +non_working_days_royal_mail = BankHolidays( + use_cached_holidays=True, + weekend=(6,), # Only Sunday (day 6 of the week) is a non-working day +) + + +def set_gmt_hour(day, hour): + return ( + day.astimezone(pytz.timezone("Europe/London")) + .replace(hour=hour, minute=0) + .astimezone(pytz.utc) + ) + + +def get_next_work_day(date, non_working_days): + next_day = date + timedelta(days=1) + if non_working_days.is_work_day( + date=next_day.date(), + division=BankHolidays.ENGLAND_AND_WALES, + ): + return next_day + return get_next_work_day(next_day, non_working_days) + + +def get_next_dvla_working_day(date): + """ + Printing takes place monday to friday, excluding bank holidays + """ + return get_next_work_day(date, non_working_days=non_working_days_dvla) + + +def get_next_royal_mail_working_day(date): + """ + Royal mail deliver letters on monday to saturday + """ + return get_next_work_day(date, non_working_days=non_working_days_royal_mail) + + +def get_delivery_day(date, *, days_to_deliver): + next_day = get_next_royal_mail_working_day(date) + if days_to_deliver == 1: + return next_day + return get_delivery_day(next_day, days_to_deliver=(days_to_deliver - 1)) + + +def get_min_and_max_days_in_transit(postage): + return { + # first class post is printed earlier in the day, so will + # actually transit on the printing day, and be delivered the next + # day, so effectively spends no full days in transit + "first": (0, 0), + "second": (1, 2), + Postage.EUROPE: (3, 5), + Postage.REST_OF_WORLD: (5, 7), + }[postage] + + +def get_earliest_and_latest_delivery(print_day, postage): + for days_to_transit in get_min_and_max_days_in_transit(postage): + yield get_delivery_day(print_day, days_to_deliver=1 + days_to_transit) + + +def get_letter_timings(upload_time, postage): + LetterTimings = namedtuple( + "LetterTimings", "printed_by, is_printed, earliest_delivery, latest_delivery" + ) + + # shift anything after 5:30pm to the next day + processing_day = utc_string_to_aware_gmt_datetime(upload_time) + timedelta( + hours=6, minutes=30 + ) + print_day = get_next_dvla_working_day(processing_day) + + earliest_delivery, latest_delivery = get_earliest_and_latest_delivery( + print_day, postage + ) + + # print deadline is 3pm BST + printed_by = set_gmt_hour(print_day, hour=15) + now = ( + datetime.utcnow() + .replace(tzinfo=pytz.utc) + .astimezone(pytz.timezone("Europe/London")) + ) + + return LetterTimings( + printed_by=printed_by, + is_printed=(now > printed_by), + earliest_delivery=set_gmt_hour(earliest_delivery, hour=16), + latest_delivery=set_gmt_hour(latest_delivery, hour=16), + ) + + +def letter_can_be_cancelled(notification_status, notification_created_at): + """ + If letter does not have status of created or pending-virus-check + => can't be cancelled (it has already been processed) + + If it's after 5.30pm local time and the notification was created today before 5.30pm local time + => can't be cancelled (it will already be zipped up to be sent) + """ + if notification_status not in ("created", "pending-virus-check"): + return False + + if too_late_to_cancel_letter(notification_created_at): + return False + return True + + +def too_late_to_cancel_letter(notification_created_at): + time_created_at = notification_created_at + day_created_on = time_created_at.date() + + current_time = datetime.utcnow() + current_day = current_time.date() + if ( + _after_letter_processing_deadline() + and _notification_created_before_today_deadline(notification_created_at) + ): + return True + if ( + _notification_created_before_that_day_deadline(notification_created_at) + and day_created_on < current_day + ): + return True + if (current_day - day_created_on).days > 1: + return True + + +def _after_letter_processing_deadline(): + current_utc_datetime = datetime.utcnow() + bst_time = current_utc_datetime.time() + + return bst_time >= LETTER_PROCESSING_DEADLINE + + +def _notification_created_before_today_deadline(notification_created_at): + current_bst_datetime = datetime.utcnow() + todays_deadline = current_bst_datetime.replace( + hour=LETTER_PROCESSING_DEADLINE.hour, + minute=LETTER_PROCESSING_DEADLINE.minute, + ) + + notification_created_at_in_bst = notification_created_at + + return notification_created_at_in_bst <= todays_deadline + + +def _notification_created_before_that_day_deadline(notification_created_at): + notification_created_at_bst_datetime = notification_created_at + created_at_day_deadline = notification_created_at_bst_datetime.replace( + hour=LETTER_PROCESSING_DEADLINE.hour, + minute=LETTER_PROCESSING_DEADLINE.minute, + ) + + return notification_created_at_bst_datetime <= created_at_day_deadline diff --git a/notifications_utils/logging.py b/notifications_utils/logging.py new file mode 100644 index 000000000..6a209cdd3 --- /dev/null +++ b/notifications_utils/logging.py @@ -0,0 +1,133 @@ +import logging +import logging.handlers +import sys +from itertools import product + +from flask import g, request +from flask.ctx import has_app_context, has_request_context +from flask.logging import default_handler +from pythonjsonlogger.jsonlogger import JsonFormatter as BaseJSONFormatter + +LOG_FORMAT = ( + "%(asctime)s %(app_name)s %(name)s %(levelname)s " + '%(request_id)s %(service_id)s "%(message)s" [in %(pathname)s:%(lineno)d]' +) +TIME_FORMAT = "%Y-%m-%dT%H:%M:%S" + +logger = logging.getLogger(__name__) + + +def init_app(app): + app.config.setdefault("NOTIFY_LOG_LEVEL", "INFO") + app.config.setdefault("NOTIFY_APP_NAME", "none") + + app.logger.removeHandler(default_handler) + + handlers = get_handlers(app) + loglevel = logging.getLevelName(app.config["NOTIFY_LOG_LEVEL"]) + loggers = [ + app.logger, + logging.getLogger("utils"), + logging.getLogger("notifications_python_client"), + logging.getLogger("werkzeug"), + ] + for logger_instance, handler in product(loggers, handlers): + logger_instance.addHandler(handler) + logger_instance.setLevel(loglevel) + warning_loggers = [logging.getLogger("boto3"), logging.getLogger("s3transfer")] + for logger_instance, handler in product(warning_loggers, handlers): + logger_instance.addHandler(handler) + logger_instance.setLevel(logging.WARNING) + app.logger.info("Logging configured") + + +def get_handlers(app): + handlers = [] + standard_formatter = logging.Formatter(LOG_FORMAT, TIME_FORMAT) + json_formatter = JSONFormatter(LOG_FORMAT, TIME_FORMAT) + + stream_handler = logging.StreamHandler(sys.stdout) + if not app.debug: + handlers.append(configure_handler(stream_handler, app, json_formatter)) + else: + # turn off 200 OK static logs in development + def is_200_static_log(log): + msg = log.getMessage() + return not ("GET /static/" in msg and " 200 " in msg) + + logging.getLogger("werkzeug").addFilter(is_200_static_log) + + # human readable stdout logs + handlers.append(configure_handler(stream_handler, app, standard_formatter)) + + return handlers + + +def configure_handler(handler, app, formatter): + handler.setLevel(logging.getLevelName(app.config["NOTIFY_LOG_LEVEL"])) + handler.setFormatter(formatter) + handler.addFilter(AppNameFilter(app.config["NOTIFY_APP_NAME"])) + handler.addFilter(RequestIdFilter()) + handler.addFilter(ServiceIdFilter()) + + return handler + + +class AppNameFilter(logging.Filter): + def __init__(self, app_name): + self.app_name = app_name + + def filter(self, record): + record.app_name = self.app_name + + return record + + +class RequestIdFilter(logging.Filter): + @property + def request_id(self): + default = "no-request-id" + if has_request_context() and hasattr(request, "request_id"): + return request.request_id or default + elif has_app_context() and "request_id" in g: + return g.request_id or default + else: + return default + + def filter(self, record): + record.request_id = self.request_id + + return record + + +class ServiceIdFilter(logging.Filter): + @property + def service_id(self): + default = "no-service-id" + if has_app_context() and "service_id" in g: + return g.service_id or default + else: + return default + + def filter(self, record): + record.service_id = self.service_id + + return record + + +class JSONFormatter(BaseJSONFormatter): + def process_log_record(self, log_record): + rename_map = { + "asctime": "time", + "request_id": "requestId", + "app_name": "application", + "service_id": "service_id", + } + for key, newkey in rename_map.items(): + log_record[newkey] = log_record.pop(key) + log_record["logType"] = "application" + try: + log_record["message"] = log_record["message"].format(**log_record) + except (KeyError, IndexError) as e: + logger.exception("failed to format log message: {} not found".format(e)) + return log_record diff --git a/notifications_utils/markdown.py b/notifications_utils/markdown.py new file mode 100644 index 000000000..7d2c719e1 --- /dev/null +++ b/notifications_utils/markdown.py @@ -0,0 +1,308 @@ +import re +from itertools import count + +import mistune +from ordered_set import OrderedSet + +from notifications_utils import MAGIC_SEQUENCE, magic_sequence_regex +from notifications_utils.formatters import create_sanitised_html_for_url + +LINK_STYLE = "word-wrap: break-word; color: #1D70B8;" + +mistune._block_quote_leading_pattern = re.compile(r"^ *\^ ?", flags=re.M) +mistune.BlockGrammar.block_quote = re.compile(r"^( *\^[^\n]+(\n[^\n]+)*\n*)+") +mistune.BlockGrammar.list_block = re.compile( + r"^( *)([•*-]|\d+\.)[\s\S]+?" + r"(?:" + r"\n+(?=\1?(?:[-*_] *){3,}(?:\n+|$))" # hrule + r"|\n+(?=%s)" # def links + r"|\n+(?=%s)" # def footnotes + r"|\n{2,}" + r"(?! )" + r"(?!\1(?:[•*-]|\d+\.) )\n*" + r"|" + r"\s*$)" + % ( + mistune._pure_pattern(mistune.BlockGrammar.def_links), + mistune._pure_pattern(mistune.BlockGrammar.def_footnotes), + ) +) +mistune.BlockGrammar.list_item = re.compile( + r"^(( *)(?:[•*-]|\d+\.)[^\n]*" r"(?:\n(?!\2(?:[•*-]|\d+\.))[^\n]*)*)", flags=re.M +) +mistune.BlockGrammar.list_bullet = re.compile(r"^ *(?:[•*-]|\d+\.)") +mistune.InlineGrammar.url = re.compile(r"""^(https?:\/\/[^\s<]+[^<.,:"')\]\s])""") + +mistune.InlineLexer.default_rules = list( + OrderedSet(mistune.InlineLexer.default_rules) + - set( + ( + "emphasis", + "double_emphasis", + "strikethrough", + "code", + ) + ) +) +mistune.InlineLexer.inline_html_rules = list( + set(mistune.InlineLexer.inline_html_rules) + - set( + ( + "emphasis", + "double_emphasis", + "strikethrough", + "code", + ) + ) +) + + +class NotifyLetterMarkdownPreviewRenderer(mistune.Renderer): + # TODO if we start removing the dead code detected by + # the vulture tool (such as the parameter 'language' here) + # it will break all the tests. Need to do some massive + # cleanup apparently, although it's not clear why vulture + # only recently started detecting this. + def block_code(self, code, language=None): # noqa + return code + + def block_quote(self, text): + return text + + def header(self, text, level, raw=None): # noqa + if level == 1: + return super().header(text, 2) + return self.paragraph(text) + + def hrule(self): + return '
 
' + + def paragraph(self, text): + if text.strip(): + return "

{}

".format(text) + return "" + + def table(self, header, body): + return "" + + def autolink(self, link, is_email=False): + return "{}".format( + link.replace("http://", "").replace("https://", "") + ) + + def image(self, src, title, alt_text): # noqa + return "" + + def linebreak(self): + return "
" + + def newline(self): + return self.linebreak() + + def list_item(self, text): + return "
  • {}
  • \n".format(text.strip()) + + def link(self, link, title, content): + return "{}: {}".format(content, self.autolink(link)) + + def footnote_ref(self, key, index): + return "" + + def footnote_item(self, key, text): + return text + + def footnotes(self, text): + return text + + +class NotifyEmailMarkdownRenderer(NotifyLetterMarkdownPreviewRenderer): + def header(self, text, level, raw=None): # noqa + if level == 1: + return ( + '

    ' + "{}" + "

    " + ).format(text) + return self.paragraph(text) + + def hrule(self): + return '
    ' + + def linebreak(self): + return "
    " + + def list(self, body, ordered=True): + return ( + ( + '' + "" + '" + "" + "
    ' + '
      ' + "{}" + "
    " + "
    " + ).format(body) + if ordered + else ( + '' + "" + '" + "" + "
    ' + '
      ' + "{}" + "
    " + "
    " + ).format(body) + ) + + def list_item(self, text): + return ( + '
  • ' + "{}" + "
  • " + ).format(text.strip()) + + def paragraph(self, text): + if text.strip(): + return ( + '

    {}

    ' + ).format(text) + return "" + + def block_quote(self, text): + return ( + "
    " + "{}" + "
    " + ).format(text) + + def link(self, link, title, content): + return ('{}').format( + LINK_STYLE, + ' href="{}"'.format(link), + ' title="{}"'.format(title) if title else "", + content, + ) + + def autolink(self, link, is_email=False): + if is_email: + return link + return create_sanitised_html_for_url(link, style=LINK_STYLE) + + +class NotifyPlainTextEmailMarkdownRenderer(NotifyEmailMarkdownRenderer): + COLUMN_WIDTH = 65 + + def header(self, text, level, raw=None): # noqa + if level == 1: + return "".join( + ( + self.linebreak() * 3, + text, + self.linebreak(), + "-" * self.COLUMN_WIDTH, + ) + ) + return self.paragraph(text) + + def hrule(self): + return self.paragraph("=" * self.COLUMN_WIDTH) + + def linebreak(self): + return "\n" + + def list(self, body, ordered=True): + def _get_list_marker(): + decimal = count(1) + return lambda _: "{}.".format(next(decimal)) if ordered else "•" + + return "".join( + ( + self.linebreak(), + re.sub( + magic_sequence_regex, + _get_list_marker(), + body, + ), + ) + ) + + def list_item(self, text): + return "".join( + ( + self.linebreak(), + MAGIC_SEQUENCE, + " ", + text.strip(), + ) + ) + + def paragraph(self, text): + if text.strip(): + return "".join( + ( + self.linebreak() * 2, + text, + ) + ) + return "" + + def block_quote(self, text): + return text + + def link(self, link, title, content): + return "".join( + ( + content, + " ({})".format(title) if title else "", + ": ", + link, + ) + ) + + def autolink(self, link, is_email=False): # noqa + return link + + +class NotifyEmailPreheaderMarkdownRenderer(NotifyPlainTextEmailMarkdownRenderer): + def header(self, text, level, raw=None): # noqa + return self.paragraph(text) + + def hrule(self): + return "" + + def link(self, link, title, content): + return "".join( + ( + content, + " ({})".format(title) if title else "", + ) + ) + + +notify_email_markdown = mistune.Markdown( + renderer=NotifyEmailMarkdownRenderer(), + hard_wrap=True, + use_xhtml=False, +) +notify_plain_text_email_markdown = mistune.Markdown( + renderer=NotifyPlainTextEmailMarkdownRenderer(), + hard_wrap=True, +) +notify_email_preheader_markdown = mistune.Markdown( + renderer=NotifyEmailPreheaderMarkdownRenderer(), + hard_wrap=True, +) +notify_letter_preview_markdown = mistune.Markdown( + renderer=NotifyLetterMarkdownPreviewRenderer(), + hard_wrap=True, + use_xhtml=False, +) diff --git a/notifications_utils/postal_address.py b/notifications_utils/postal_address.py new file mode 100644 index 000000000..9e0a008bd --- /dev/null +++ b/notifications_utils/postal_address.py @@ -0,0 +1,185 @@ +import re +from functools import lru_cache + +from notifications_utils.countries import UK, Country, CountryNotFoundError +from notifications_utils.countries.data import Postage +from notifications_utils.formatters import ( + get_lines_with_normalised_whitespace, + remove_whitespace, + remove_whitespace_before_punctuation, +) + +address_lines_1_to_6_keys = [ + # The API only accepts snake_case placeholders + "address_line_1", + "address_line_2", + "address_line_3", + "address_line_4", + "address_line_5", + "address_line_6", +] +address_lines_1_to_6_and_postcode_keys = address_lines_1_to_6_keys + ["postcode"] +address_line_7_key = "address_line_7" +address_lines_1_to_7_keys = address_lines_1_to_6_keys + [address_line_7_key] +country_UK = Country(UK) + + +class PostalAddress: + MIN_LINES = 3 + MAX_LINES = 7 + INVALID_CHARACTERS_AT_START_OF_ADDRESS_LINE = r'[\/()@]<>",=~' + + def __init__(self, raw_address, allow_international_letters=False): + self.raw_address = raw_address + self.allow_international_letters = allow_international_letters + + self._lines = [ + remove_whitespace_before_punctuation(line.rstrip(" ,")) + for line in get_lines_with_normalised_whitespace(self.raw_address) + if line.rstrip(" ,") + ] or [""] + + try: + self.country = Country(self._lines[-1]) + self._lines_without_country = self._lines[:-1] + except CountryNotFoundError: + self._lines_without_country = self._lines + self.country = country_UK + + def __bool__(self): + return bool(self.normalised) + + def __repr__(self): + return f"{self.__class__.__name__}({repr(self.raw_address)})" + + @classmethod + def from_personalisation( + cls, personalisation_dict, allow_international_letters=False + ): + if address_line_7_key in personalisation_dict: + keys = address_lines_1_to_6_keys + [address_line_7_key] + else: + keys = address_lines_1_to_6_and_postcode_keys + return cls( + "\n".join(str(personalisation_dict.get(key) or "") for key in keys), + allow_international_letters=allow_international_letters, + ) + + @property + def as_personalisation(self): + lines = dict.fromkeys(address_lines_1_to_6_keys, "") + lines.update( + { + f"address_line_{index}": value + for index, value in enumerate(self.normalised_lines[:-1], start=1) + if index < 7 + } + ) + lines["postcode"] = lines["address_line_7"] = self.normalised_lines[-1] + return lines + + @property + def as_single_line(self): + return ", ".join(self.normalised_lines) + + @property + def line_count(self): + return len(self.normalised.splitlines()) + + @property + def has_enough_lines(self): + return self.line_count >= self.MIN_LINES + + @property + def has_too_many_lines(self): + return self.line_count > self.MAX_LINES + + @property + def has_valid_postcode(self): + return self.postcode is not None + + @property + def has_valid_last_line(self): + return ( + self.allow_international_letters and self.international + ) or self.has_valid_postcode + + @property + def has_invalid_characters(self): + return any( + line.startswith(tuple(self.INVALID_CHARACTERS_AT_START_OF_ADDRESS_LINE)) + for line in self.normalised_lines + ) + + @property + def international(self): + return self.postage != Postage.UK + + @property + def normalised(self): + return "\n".join(self.normalised_lines) + + @property + def normalised_lines(self): + if self.international: + return self._lines_without_country + [self.country.canonical_name] + + if self.postcode: + return self._lines_without_country[:-1] + [self.postcode] + + return self._lines_without_country + + @property + def postage(self): + return self.country.postage_zone + + @property + def postcode(self): + if self.international: + return None + return format_postcode_or_none(self._lines_without_country[-1]) + + @property + def valid(self): + return ( + self.has_valid_last_line + and self.has_enough_lines + and not self.has_too_many_lines + and not self.has_invalid_characters + ) + + +def normalise_postcode(postcode): + return remove_whitespace(postcode).upper() + + +def is_a_real_uk_postcode(postcode): + standard = r"([A-Z]{1,2}[0-9][0-9A-Z]?[0-9][A-BD-HJLNP-UW-Z]{2})" + bfpo = r"(BFPO?(C\/O)?[0-9]{1,4})" + girobank = r"(GIR0AA)" + pattern = r"{}|{}|{}".format(standard, bfpo, girobank) + + return bool(re.fullmatch(pattern, normalise_postcode(postcode))) + + +def format_postcode_for_printing(postcode): + """ + This function formats the postcode so that it is ready for automatic sorting by Royal Mail. + :param String postcode: A postcode that's already been validated by is_a_real_uk_postcode + """ + postcode = normalise_postcode(postcode) + if "BFPOC/O" in postcode: + return postcode[:4] + " C/O " + postcode[7:] + elif "BFPO" in postcode: + return postcode[:4] + " " + postcode[4:] + return postcode[:-3] + " " + postcode[-3:] + + +# When processing an address we look at the postcode twice when +# normalising it, and once when validating it. So 8 is chosen because +# it’s 3, doubled to give some headroom then rounded up to the nearest +# power of 2 +@lru_cache(maxsize=8) +def format_postcode_or_none(postcode): + if is_a_real_uk_postcode(postcode): + return format_postcode_for_printing(postcode) diff --git a/notifications_utils/recipients.py b/notifications_utils/recipients.py new file mode 100644 index 000000000..1e8aeff6d --- /dev/null +++ b/notifications_utils/recipients.py @@ -0,0 +1,739 @@ +import csv +import re +import sys +from collections import namedtuple +from contextlib import suppress +from functools import lru_cache +from io import StringIO +from itertools import islice + +import phonenumbers +from flask import current_app +from ordered_set import OrderedSet +from phonenumbers.phonenumberutil import NumberParseException + +from notifications_utils.formatters import ( + strip_all_whitespace, + strip_and_remove_obscure_whitespace, +) +from notifications_utils.insensitive_dict import InsensitiveDict +from notifications_utils.international_billing_rates import INTERNATIONAL_BILLING_RATES +from notifications_utils.postal_address import ( + address_line_7_key, + address_lines_1_to_6_and_postcode_keys, + address_lines_1_to_7_keys, +) +from notifications_utils.template import Template + +from . import EMAIL_REGEX_PATTERN, hostname_part, tld_part + +us_prefix = "1" + +first_column_headings = { + "email": ["email address"], + "sms": ["phone number"], + "letter": [ + line.replace("_", " ") + for line in address_lines_1_to_6_and_postcode_keys + [address_line_7_key] + ], +} + +address_columns = InsensitiveDict.from_keys(first_column_headings["letter"]) + + +class RecipientCSV: + max_rows = 100_000 + + def __init__( + self, + file_data, + template, + max_errors_shown=20, + max_initial_rows_shown=10, + guestlist=None, + remaining_messages=sys.maxsize, + allow_international_sms=False, + allow_international_letters=False, + should_validate=True, + ): + self.file_data = strip_all_whitespace(file_data, extra_characters=",") + self.max_errors_shown = max_errors_shown + self.max_initial_rows_shown = max_initial_rows_shown + self.guestlist = guestlist + self.template = template + self.allow_international_sms = allow_international_sms + self.allow_international_letters = allow_international_letters + self.remaining_messages = remaining_messages + self.rows_as_list = None + self.should_validate = should_validate + + def __len__(self): + if not hasattr(self, "_len"): + self._len = len(self.rows) + return self._len + + def __getitem__(self, requested_index): + return self.rows[requested_index] + + @property + def guestlist(self): + return self._guestlist + + @guestlist.setter + def guestlist(self, value): + try: + self._guestlist = list(value) + except TypeError: + self._guestlist = [] + + @property + def template(self): + return self._template + + @template.setter + def template(self, value): + if not isinstance(value, Template): + raise TypeError( + "template must be an instance of " + "notifications_utils.template.Template" + ) + self._template = value + self.template_type = self._template.template_type + self.recipient_column_headers = first_column_headings[self.template_type] + self.placeholders = self._template.placeholders + + @property + def placeholders(self): + return self._placeholders + + @placeholders.setter + def placeholders(self, value): + try: + self._placeholders = list(value) + self.recipient_column_headers + except TypeError: + self._placeholders = self.recipient_column_headers + self.placeholders_as_column_keys = [ + InsensitiveDict.make_key(placeholder) for placeholder in self._placeholders + ] + self.recipient_column_headers_as_column_keys = [ + InsensitiveDict.make_key(placeholder) + for placeholder in self.recipient_column_headers + ] + + @property + def has_errors(self): + return bool( + self.missing_column_headers + or self.duplicate_recipient_column_headers + or self.more_rows_than_can_send + or self.too_many_rows + or (not self.allowed_to_send_to) + or any(self.rows_with_errors) + ) # `or` is 3x faster than using `any()` here + + @property + def allowed_to_send_to(self): + if self.template_type == "letter": + return True + if not self.guestlist: + return True + return all( + allowed_to_send_to(row.recipient, self.guestlist) for row in self.rows + ) + + @property + def rows(self): + if self.rows_as_list is None: + self.rows_as_list = list(self.get_rows()) + return self.rows_as_list + + @property + def _rows(self): + return csv.reader( + StringIO(self.file_data.strip()), + quoting=csv.QUOTE_MINIMAL, + skipinitialspace=True, + ) + + def get_rows(self): + column_headers = self._raw_column_headers # this is for caching + length_of_column_headers = len(column_headers) + + rows_as_lists_of_columns = self._rows + + next(rows_as_lists_of_columns, None) # skip the header row + + for index, row in enumerate(rows_as_lists_of_columns): + if index >= self.max_rows: + yield None + continue + + output_dict = {} + + for column_name, column_value in zip(column_headers, row): + column_value = strip_and_remove_obscure_whitespace(column_value) + + if ( + InsensitiveDict.make_key(column_name) + in self.recipient_column_headers_as_column_keys + ): + output_dict[column_name] = column_value or None + else: + insert_or_append_to_dict( + output_dict, column_name, column_value or None + ) + + length_of_row = len(row) + + if length_of_column_headers < length_of_row: + output_dict[None] = row[length_of_column_headers:] + elif length_of_column_headers > length_of_row: + for key in column_headers[length_of_row:]: + insert_or_append_to_dict(output_dict, key, None) + + yield Row( + output_dict, + index=index, + error_fn=self._get_error_for_field, + recipient_column_headers=self.recipient_column_headers, + placeholders=self.placeholders_as_column_keys, + template=self.template, + allow_international_letters=self.allow_international_letters, + validate_row=self.should_validate, + ) + + @property + def more_rows_than_can_send(self): + return len(self) > self.remaining_messages + + @property + def too_many_rows(self): + return len(self) > self.max_rows + + @property + def initial_rows(self): + return islice(self.rows, self.max_initial_rows_shown) + + @property + def displayed_rows(self): + if any(self.rows_with_errors) and not self.missing_column_headers: + return self.initial_rows_with_errors + return self.initial_rows + + def _filter_rows(self, attr): + return (row for row in self.rows if row and getattr(row, attr)) + + @property + def rows_with_errors(self): + return self._filter_rows("has_error") + + @property + def rows_with_bad_recipients(self): + return self._filter_rows("has_bad_recipient") + + @property + def rows_with_missing_data(self): + return self._filter_rows("has_missing_data") + + @property + def rows_with_message_too_long(self): + return self._filter_rows("message_too_long") + + @property + def rows_with_empty_message(self): + return self._filter_rows("message_empty") + + @property + def initial_rows_with_errors(self): + return islice(self.rows_with_errors, self.max_errors_shown) + + @property + def _raw_column_headers(self): + for row in self._rows: + return row + return [] + + @property + def column_headers(self): + return list(OrderedSet(self._raw_column_headers)) + + @property + def column_headers_as_column_keys(self): + return InsensitiveDict.from_keys(self.column_headers).keys() + + @property + def missing_column_headers(self): + return set( + key + for key in self.placeholders + if ( + InsensitiveDict.make_key(key) not in self.column_headers_as_column_keys + and not self.is_address_column(key) + ) + ) + + @property + def duplicate_recipient_column_headers(self): + raw_recipient_column_headers = [ + InsensitiveDict.make_key(column_header) + for column_header in self._raw_column_headers + if InsensitiveDict.make_key(column_header) + in self.recipient_column_headers_as_column_keys + ] + + return OrderedSet( + ( + column_header + for column_header in self._raw_column_headers + if raw_recipient_column_headers.count( + InsensitiveDict.make_key(column_header) + ) + > 1 + ) + ) + + def is_address_column(self, key): + return self.template_type == "letter" and key in address_columns + + @property + def count_of_required_recipient_columns(self): + return 3 if self.template_type == "letter" else 1 + + @property + def has_recipient_columns(self): + if self.template_type == "letter": + sets_to_check = [ + InsensitiveDict.from_keys( + address_lines_1_to_6_and_postcode_keys + ).keys(), + InsensitiveDict.from_keys(address_lines_1_to_7_keys).keys(), + ] + else: + sets_to_check = [ + self.recipient_column_headers_as_column_keys, + ] + + for set_to_check in sets_to_check: + if ( + len( + # Work out which columns are shared between the possible + # letter address columns and the columns in the user’s + # spreadsheet (`&` means set intersection) + set_to_check + & self.column_headers_as_column_keys + ) + >= self.count_of_required_recipient_columns + ): + return True + + return False + + def _get_error_for_field(self, key, value): # noqa: C901 + if self.is_address_column(key): + return + + if ( + InsensitiveDict.make_key(key) + in self.recipient_column_headers_as_column_keys + ): + if value in [None, ""] or isinstance(value, list): + if self.duplicate_recipient_column_headers: + return None + else: + return Cell.missing_field_error + + try: + if self.template_type == "email": + validate_email_address(value) + if self.template_type == "sms": + validate_phone_number( + value, international=self.allow_international_sms + ) + except (InvalidEmailError, InvalidPhoneError) as error: + return str(error) + + if InsensitiveDict.make_key(key) not in self.placeholders_as_column_keys: + return + + if value in [None, ""]: + return Cell.missing_field_error + + +class Row(InsensitiveDict): + message_too_long = False + message_empty = False + + def __init__( + self, + row_dict, + *, + index, + error_fn, + recipient_column_headers, + placeholders, + template, + allow_international_letters, + validate_row=True, + ): + # If we don't need to validate, then: + # by not setting template we avoid the template level validation (used to check message length) + # by not setting error_fn, we avoid the Cell.__init__ validation (used to check phone nums are valid, + # placeholders are present, etc) + if not validate_row: + template = None + error_fn = None + + self.index = index + self.recipient_column_headers = recipient_column_headers + self.placeholders = placeholders + self.allow_international_letters = allow_international_letters + + if template: + template.values = row_dict + self.template_type = template.template_type + # we do not validate email size for CSVs to avoid performance issues + if self.template_type == "email": + self.message_too_long = False + else: + self.message_too_long = template.is_message_too_long() + self.message_empty = template.is_message_empty() + + super().__init__( + { + key: Cell(key, value, error_fn, self.placeholders) + for key, value in row_dict.items() + } + ) + + def __getitem__(self, key): + return super().__getitem__(key) if key in self else Cell() + + def get(self, key, default=None): + if key not in self and default is not None: + return default + return self[key] + + @property + def has_error(self): + return self.has_error_spanning_multiple_cells or any( + cell.error for cell in self.values() + ) + + @property + def has_bad_recipient(self): + if self.template_type == "letter": + return self.has_bad_postal_address + return self.get(self.recipient_column_headers[0]).recipient_error + + @property + def has_bad_postal_address(self): + return self.template_type == "letter" and not self.as_postal_address.valid + + @property + def has_error_spanning_multiple_cells(self): + return ( + self.message_too_long or self.message_empty or self.has_bad_postal_address + ) + + @property + def has_missing_data(self): + return any(cell.error == Cell.missing_field_error for cell in self.values()) + + @property + def recipient(self): + columns = [self.get(column).data for column in self.recipient_column_headers] + return columns[0] if len(columns) == 1 else columns + + @property + def as_postal_address(self): + from notifications_utils.postal_address import PostalAddress + + return PostalAddress.from_personalisation( + self.recipient_and_personalisation, + allow_international_letters=self.allow_international_letters, + ) + + @property + def personalisation(self): + return InsensitiveDict( + {key: cell.data for key, cell in self.items() if key in self.placeholders} + ) + + @property + def recipient_and_personalisation(self): + return InsensitiveDict({key: cell.data for key, cell in self.items()}) + + +class Cell: + missing_field_error = "Missing" + + def __init__(self, key=None, value=None, error_fn=None, placeholders=None): + self.data = value + self.error = error_fn(key, value) if error_fn else None + self.ignore = InsensitiveDict.make_key(key) not in (placeholders or []) + + def __eq__(self, other): + if not other.__class__ == self.__class__: + return False + return all( + ( + self.data == other.data, + self.error == other.error, + self.ignore == other.ignore, + ) + ) + + @property + def recipient_error(self): + return self.error not in {None, self.missing_field_error} + + +class InvalidEmailError(Exception): + def __init__(self, message=None): + super().__init__(message or "Not a valid email address") + + +class InvalidPhoneError(InvalidEmailError): + pass + + +class InvalidAddressError(InvalidEmailError): + pass + + +def normalize_phone_number(phonenumber): + if isinstance(phonenumber, str): + phonenumber = phonenumbers.parse(phonenumber, "US") + return phonenumbers.format_number(phonenumber, phonenumbers.PhoneNumberFormat.E164) + + +def is_us_phone_number(number): + try: + return _get_country_code(number) == us_prefix + except NumberParseException: + return False + + +international_phone_info = namedtuple( + "PhoneNumber", + [ + "international", + "country_prefix", + "billable_units", + ], +) + + +def get_international_phone_info(number): + number = validate_phone_number(number, international=True) + prefix = _get_country_code(number) + + return international_phone_info( + international=(prefix != us_prefix), + country_prefix=prefix, + billable_units=get_billable_units_for_prefix(prefix), + ) + + +# NANP_COUNTRY_AREA_CODES are the list of area codes in the North American Numbering Plan +# that have their own entry in international_billing_rates.yml. +# Source: https://en.wikipedia.org/wiki/List_of_North_American_Numbering_Plan_area_codes +_NANP_COUNTRY_AREA_CODES = [ + "684", + "242", + "246", + "264", + "268", + "284", + "345", + "441", + "473", + "649", + "876", + "664", + "721", + "758", + "767", + "784", + "868", + "869", +] + + +def _get_country_code(number): + parsed = phonenumbers.parse(number, "US") + country_code = str(parsed.country_code) + if country_code == us_prefix: + area_code = str(parsed.national_number)[:3] + if area_code in _NANP_COUNTRY_AREA_CODES: + return f"{country_code}{area_code}" + return country_code + + +def get_billable_units_for_prefix(prefix): + """Return the billable units for prefix. Hard-coded to 1 for now""" + return 1 + # return INTERNATIONAL_BILLING_RATES[prefix]['billable_units'] + + +def use_numeric_sender(number): + prefix = _get_country_code(number) + return ( + INTERNATIONAL_BILLING_RATES[(prefix or us_prefix)]["attributes"]["alpha"] + == "NO" + ) + + +def validate_us_phone_number(number): + try: + parsed = phonenumbers.parse(number, "US") + if not is_us_phone_number(number): + raise InvalidPhoneError("Not a US number") + if phonenumbers.is_valid_number(parsed): + return normalize_phone_number(parsed) + if len(str(parsed.national_number)) > 10: + raise InvalidPhoneError("Too many digits") + if len(str(parsed.national_number)) < 10: + raise InvalidPhoneError("Not enough digits") + if phonenumbers.is_possible_number(parsed): + raise InvalidPhoneError("Phone number range is not in use") + raise InvalidPhoneError("Phone number is not possible") + except NumberParseException as exc: + raise InvalidPhoneError(exc._msg) from exc + + +def validate_phone_number(number, international=False): + if (not international) or is_us_phone_number(number): + return validate_us_phone_number(number) + + try: + parsed = phonenumbers.parse(number, None) + number = f"{parsed.country_code}{parsed.national_number}" + if len(number) < 8: + raise InvalidPhoneError("Not enough digits") + if len(number) > 15: + raise InvalidPhoneError("Too many digits") + return normalize_phone_number(parsed) + except NumberParseException as exc: + if exc._msg == "Could not interpret numbers after plus-sign.": + raise InvalidPhoneError("Not a valid country prefix") from exc + raise InvalidPhoneError(exc._msg) from exc + + +validate_and_format_phone_number = validate_phone_number + + +def try_validate_and_format_phone_number(number, international=None, log_msg=None): + """ + For use in places where you shouldn't error if the phone number is invalid - for example if firetext pass us + something in + """ + try: + return validate_and_format_phone_number(number, international) + except InvalidPhoneError as exc: + if log_msg: + current_app.logger.warning("{}: {}".format(log_msg, exc)) + return number + + +def _do_simple_email_checks(match, email_address): + # not an email + if not match: + raise InvalidEmailError + + if len(email_address) > 320: + raise InvalidEmailError + + # don't allow consecutive periods in either part + if ".." in email_address: + raise InvalidEmailError + + +def validate_email_address(email_address): # noqa (C901 too complex) + # almost exactly the same as by https://github.com/wtforms/wtforms/blob/master/wtforms/validators.py, + # with minor tweaks for SES compatibility - to avoid complications we are a lot stricter with the local part + # than neccessary - we don't allow any double quotes or semicolons to prevent SES Technical Failures + email_address = strip_and_remove_obscure_whitespace(email_address) + match = re.match(EMAIL_REGEX_PATTERN, email_address) + + _do_simple_email_checks(match, email_address) + + hostname = match.group(1) + + # idna = "Internationalized domain name" - this encode/decode cycle converts unicode into its accurate ascii + # representation as the web uses. '例え.テスト'.encode('idna') == b'xn--r8jz45g.xn--zckzah' + try: + hostname = hostname.encode("idna").decode("ascii") + except UnicodeError: + raise InvalidEmailError + + parts = hostname.split(".") + + if len(hostname) > 253 or len(parts) < 2: + raise InvalidEmailError + + for part in parts: + if not part or len(part) > 63 or not hostname_part.match(part): + raise InvalidEmailError + + # if the part after the last . is not a valid TLD then bail out + if not tld_part.match(parts[-1]): + raise InvalidEmailError + + return email_address + + +def format_email_address(email_address): + return strip_and_remove_obscure_whitespace(email_address.lower()) + + +def validate_and_format_email_address(email_address): + return format_email_address(validate_email_address(email_address)) + + +@lru_cache(maxsize=32, typed=False) +def format_recipient(recipient): + if not isinstance(recipient, str): + return "" + with suppress(InvalidPhoneError): + return validate_and_format_phone_number(recipient, international=True) + with suppress(InvalidEmailError): + return validate_and_format_email_address(recipient) + return recipient + + +def format_phone_number_human_readable(phone_number): + try: + phone_number = validate_phone_number(phone_number, international=True) + except InvalidPhoneError: + # if there was a validation error, we want to shortcut out here, but still display the number on the front end + return phone_number + international_phone_info = get_international_phone_info(phone_number) + + return phonenumbers.format_number( + phonenumbers.parse(phone_number, None), + ( + phonenumbers.PhoneNumberFormat.INTERNATIONAL + if international_phone_info.international + else phonenumbers.PhoneNumberFormat.NATIONAL + ), + ) + + +def allowed_to_send_to(recipient, allowlist): + return format_recipient(recipient) in {format_recipient(x) for x in allowlist} + + +def insert_or_append_to_dict(dict_, key, value): + if not (key or value): + # We don’t care about completely empty values so it’s faster to + # ignore them rather than working out how to store them + return + + if dict_.get(key): + if isinstance(dict_[key], list): + dict_[key].append(value) + else: + dict_[key] = [dict_[key], value] + else: + dict_.update({key: value}) diff --git a/notifications_utils/request_helper.py b/notifications_utils/request_helper.py new file mode 100644 index 000000000..6c9edc8a7 --- /dev/null +++ b/notifications_utils/request_helper.py @@ -0,0 +1,123 @@ +from flask import abort, current_app, request +from flask.wrappers import Request + +TRACE_ID_HEADER = "X-B3-TraceId" +SPAN_ID_HEADER = "X-B3-SpanId" +PARENT_SPAN_ID_HEADER = "X-B3-ParentSpanId" + + +class NotifyRequest(Request): + """ + A custom Request class, implementing extraction of zipkin headers used to trace request through cloudfoundry + as described here: https://docs.cloudfoundry.org/concepts/http-routing.html#zipkin-headers + """ + + @property + def request_id(self): + return self.trace_id + + @property + def trace_id(self): + """ + The "trace id" (in zipkin terms) assigned to this request, if present (None otherwise) + """ + if not hasattr(self, "_trace_id"): + self._trace_id = self._get_header_value(TRACE_ID_HEADER) + return self._trace_id + + @property + def span_id(self): + """ + The "span id" (in zipkin terms) set in this request's header, if present (None otherwise) + """ + if not hasattr(self, "_span_id"): + # note how we don't generate an id of our own. not being supplied a span id implies that we are running in + # an environment with no span-id-aware request router, and thus would have no intermediary to prevent the + # propagation of our span id all the way through all our onwards requests much like trace id. and the point + # of span id is to assign identifiers to each individual request. + self._span_id = self._get_header_value(SPAN_ID_HEADER) + return self._span_id + + @property + def parent_span_id(self): + """ + The "parent span id" (in zipkin terms) set in this request's header, if present (None otherwise) + """ + if not hasattr(self, "_parent_span_id"): + self._parent_span_id = self._get_header_value(PARENT_SPAN_ID_HEADER) + return self._parent_span_id + + def _get_header_value(self, header_name): + """ + Returns value of the given header + """ + if header_name in self.headers and self.headers[header_name]: + return self.headers[header_name] + + return None + + +class ResponseHeaderMiddleware(object): + def __init__(self, app): + self._app = app + + def __call__(self, environ, start_response): + req = NotifyRequest(environ) + + def rewrite_response_headers(status, headers, exc_info=None): + lower_existing_header_names = frozenset( + name.lower() for name, value in headers + ) + + if TRACE_ID_HEADER.lower() not in lower_existing_header_names: + headers.append((TRACE_ID_HEADER, str(req.trace_id))) + + if SPAN_ID_HEADER.lower() not in lower_existing_header_names: + headers.append((SPAN_ID_HEADER, str(req.span_id))) + + return start_response(status, headers, exc_info) + + return self._app(environ, rewrite_response_headers) + + +def init_app(app): + app.request_class = NotifyRequest + app.wsgi_app = ResponseHeaderMiddleware(app.wsgi_app) + + +def check_proxy_header_before_request(): + keys = [ + current_app.config.get("ROUTE_SECRET_KEY_1"), + current_app.config.get("ROUTE_SECRET_KEY_2"), + ] + result, msg = _check_proxy_header_secret(request, keys) + + if not result: + if current_app.config.get("CHECK_PROXY_HEADER", False): + current_app.logger.warning(msg) + abort(403) + + # We need to return None to continue processing the request + # http://flask.pocoo.org/docs/0.12/api/#flask.Flask.before_request + return None + + +def _check_proxy_header_secret(request, secrets, header="X-Custom-Forwarder"): + if header not in request.headers: + return False, "Header missing" + + header_secret = request.headers.get(header) + if not header_secret: + return False, "Header exists but is empty" + + # if there isn't any non-empty secret configured we fail closed + if not any(secrets): + return False, "Secrets are not configured" + + for i, secret in enumerate(secrets): + if header_secret == secret: + return True, "Key used: {}".format( + i + 1 + ) # add 1 to make it human-compatible + + return False, "Header didn't match any keys" diff --git a/notifications_utils/s3.py b/notifications_utils/s3.py new file mode 100644 index 000000000..cdcc70a5c --- /dev/null +++ b/notifications_utils/s3.py @@ -0,0 +1,87 @@ +import os +import urllib + +import botocore +from boto3 import Session +from botocore.config import Config +from flask import current_app + +AWS_CLIENT_CONFIG = Config( + # This config is required to enable S3 to connect to FIPS-enabled + # endpoints. See https://aws.amazon.com/compliance/fips/ for more + # information. + s3={ + "addressing_style": "virtual", + }, + use_fips_endpoint=True, +) + +default_access_key_id = os.environ.get("AWS_ACCESS_KEY_ID") +default_secret_access_key = os.environ.get("AWS_SECRET_ACCESS_KEY") +default_region = os.environ.get("AWS_REGION") + + +def s3upload( + filedata, + region, + bucket_name, + file_location, + content_type="binary/octet-stream", + tags=None, + metadata=None, + access_key=default_access_key_id, + secret_key=default_secret_access_key, +): + session = Session( + aws_access_key_id=access_key, + aws_secret_access_key=secret_key, + region_name=region, + ) + _s3 = session.resource("s3", config=AWS_CLIENT_CONFIG) + + key = _s3.Object(bucket_name, file_location) + + put_args = { + "Body": filedata, + "ServerSideEncryption": "AES256", + "ContentType": content_type, + } + + if tags: + tags = urllib.parse.urlencode(tags) + put_args["Tagging"] = tags + + if metadata: + metadata = put_args["Metadata"] = metadata + + try: + key.put(**put_args) + except botocore.exceptions.ClientError as e: + current_app.logger.error( + "Unable to upload file to S3 bucket {}".format(bucket_name) + ) + raise e + + +class S3ObjectNotFound(botocore.exceptions.ClientError): + pass + + +def s3download( + bucket_name, + filename, + region=default_region, + access_key=default_access_key_id, + secret_key=default_secret_access_key, +): + try: + session = Session( + aws_access_key_id=access_key, + aws_secret_access_key=secret_key, + region_name=region, + ) + s3 = session.resource("s3", config=AWS_CLIENT_CONFIG) + key = s3.Object(bucket_name, filename) + return key.get()["Body"] + except botocore.exceptions.ClientError as error: + raise S3ObjectNotFound(error.response, error.operation_name) diff --git a/notifications_utils/safe_string.py b/notifications_utils/safe_string.py new file mode 100644 index 000000000..be49c81a4 --- /dev/null +++ b/notifications_utils/safe_string.py @@ -0,0 +1,25 @@ +import re +import unicodedata + + +def make_string_safe(string, whitespace): + # strips accents, diacritics etc + string = "".join( + c + for c in unicodedata.normalize("NFD", string) + if unicodedata.category(c) != "Mn" + ) + string = "".join( + word.lower() if word.isalnum() or word == whitespace else "" + for word in re.sub(r"\s+", whitespace, string.strip()) + ) + string = re.sub(r"\.{2,}", ".", string) + return string.strip(".") + + +def make_string_safe_for_email_local_part(string): + return make_string_safe(string, whitespace=".") + + +def make_string_safe_for_id(string): + return make_string_safe(string, whitespace="-") diff --git a/notifications_utils/sanitise_text.py b/notifications_utils/sanitise_text.py new file mode 100644 index 000000000..3e9da0764 --- /dev/null +++ b/notifications_utils/sanitise_text.py @@ -0,0 +1,310 @@ +import ast +import unicodedata + +from regex import regex + + +class SanitiseText: + ALLOWED_CHARACTERS = set() + + REPLACEMENT_CHARACTERS = { + "–": "-", # EN DASH (U+2013) + "—": "-", # EM DASH (U+2014) + "…": "...", # HORIZONTAL ELLIPSIS (U+2026) + "‘": "'", # LEFT SINGLE QUOTATION MARK (U+2018) + "’": "'", # RIGHT SINGLE QUOTATION MARK (U+2019) + "“": '"', # LEFT DOUBLE QUOTATION MARK (U+201C) + "”": '"', # RIGHT DOUBLE QUOTATION MARK (U+201D) + "\u180E": "", # Mongolian vowel separator + "\u200B": "", # zero width space + "\u200C": "", # zero width non-joiner + "\u200D": "", # zero width joiner + "\u2060": "", # word joiner + "\uFEFF": "", # zero width non-breaking space + "\u00A0": " ", # NON BREAKING WHITE SPACE (U+200B) + "\t": " ", # TAB + } + + @classmethod + def encode(cls, content): + return "".join(cls.encode_char(char) for char in content) + + @classmethod + def get_non_compatible_characters(cls, content): + """ + Given an input string, return a set of non compatible characters. + + This follows the same rules as `cls.encode`, but returns just the characters that encode would replace with `?` + """ + return set( + c + for c in content + if c not in cls.ALLOWED_CHARACTERS + and cls.is_extended_language(c) is False + and cls.downgrade_character(c) is None + ) + + @staticmethod + def get_unicode_char_from_codepoint(codepoint): + """ + Given a unicode codepoint (eg 002E for '.', 0061 for 'a', etc), return that actual unicode character. + + unicodedata.decomposition returns strings containing codepoints, so we need to eval them ourselves + """ + # lets just make sure we aren't evaling anything weird + if not set(codepoint) <= set("0123456789ABCDEF") or not len(codepoint) == 4: + raise ValueError("{} is not a valid unicode codepoint".format(codepoint)) + return ast.literal_eval('"\\u{}"'.format(codepoint)) + + @classmethod + def downgrade_character(cls, c): + """ + Attempt to downgrade a non-compatible character to the allowed character set. May downgrade to multiple + characters, eg `… -> ...` + + Will return None if character is either already valid or has no known downgrade + """ + decomposed = unicodedata.decomposition(c) + if decomposed != "" and "<" not in decomposed: + # decomposition lists the unicode code points a character is made up of, if it's made up of multiple + # points. For example the á character returns '0061 0301', as in, the character a, followed by a combining + # acute accent. The decomposition might, however, also contain a decomposition mapping in angle brackets. + # For a full list of the types, see here: https://www.compart.com/en/unicode/decomposition. + # If it's got a mapping, we're not sure how best to downgrade it, so just see if it's in the + # REPLACEMENT_CHARACTERS map. If not, then it's probably a letter with a modifier, eg á + # ASSUMPTION: The first character of a combined unicode character (eg 'á' == '0061 0301') + # will be the ascii char + return cls.get_unicode_char_from_codepoint(decomposed.split()[0]) + else: + # try and find a mapping (eg en dash -> hyphen ('–': '-')), else return None + return cls.REPLACEMENT_CHARACTERS.get(c) + + @classmethod + def is_japanese(cls, value): + if regex.search(r"([\p{IsHan}\p{IsHiragana}\p{IsKatakana}]+)", value): + return True + return False + + @classmethod + def is_chinese(cls, value): + # This range supports all "CJK Unified Ideoglyphs" + # It may be missing some rare/historic characters that are not in common use + if regex.search(r"[\u4e00-\u9fff]+", value) or value in [ + "。", + "、", + ":", + "?", + "!", + ";", + "(", + ")", + "“", + "”", + ",", + ]: + return True + return False + + @classmethod + def is_arabic(cls, value): + # For some reason, the python definition of Arabic (IsArabic) doesn't include + # some standard diacritics, so add them here. + if ( + regex.search(r"\p{IsArabic}", value) + or regex.search(r"[\uFE70]+", value) + or regex.search(r"[\u064B]+", value) + or regex.search(r"[\u064F]+", value) + ): + return True + return False + + @classmethod + def is_punjabi(cls, value): + # Gukmukhi script or Shahmukhi script + + if regex.search(r"[\u0A00-\u0A7F]+", value): + return True + elif regex.search(r"[\u0600-\u06FF]+", value): + return True + elif regex.search(r"[\u0750-\u077F]+", value): + return True + elif regex.search(r"[\u08A0-\u08FF]+", value): + return True + elif regex.search(r"[\uFB50-\uFDFF]+", value): + return True + elif regex.search(r"[\uFE70-\uFEFF]+", value): + return True + elif regex.search(r"[\u0900-\u097F]+", value): + return True + return False + + @classmethod + def _is_extended_language_group_one(cls, value): + if regex.search(r"\p{IsHangul}", value): # Korean + return True + elif regex.search(r"\p{IsCyrillic}", value): + return True + elif SanitiseText.is_arabic(value): + return True + elif regex.search(r"\p{IsArmenian}", value): + return True + elif regex.search(r"\p{IsBengali}", value): + return True + elif SanitiseText.is_punjabi(value): + return True + return False + + @classmethod + def _is_extended_language_group_two(cls, value): + if regex.search(r"\p{IsBuhid}", value): + return True + if regex.search(r"\p{IsCanadian_Aboriginal}", value): + return True + if regex.search(r"\p{IsCherokee}", value): + return True + if regex.search(r"\p{IsDevanagari}", value): + return True + if regex.search(r"\p{IsEthiopic}", value): + return True + if regex.search(r"\p{IsGeorgian}", value): + return True + return False + + @classmethod + def _is_extended_language_group_three(cls, value): + if regex.search(r"\p{IsGreek}", value): + return True + if regex.search(r"\p{IsGujarati}", value): + return True + if regex.search(r"\p{IsHanunoo}", value): + return True + if regex.search(r"\p{IsHebrew}", value): + return True + if regex.search(r"\p{IsLimbu}", value): + return True + if regex.search(r"\p{IsKannada}", value): + return True + return False + + @classmethod + def _is_extended_language_group_four(cls, value): + if regex.search( + r"([\p{IsKhmer}\p{IsLao}\p{IsMongolian}\p{IsMyanmar}\p{IsTibetan}\p{IsYi}]+)", + value, + ): + return True + + if regex.search( + r"([\p{IsOgham}\p{IsOriya}\p{IsSinhala}\p{IsSyriac}\p{IsTagalog}]+)", value + ): + return True + + if regex.search( + r"([\p{IsTagbanwa}\p{IsTaiLe}\p{IsTamil}\p{IsTelugu}\p{IsThaana}\p{IsThai}]+)", + value, + ): + return True + + # Vietnamese + if regex.search( + r"\b\S*[AĂÂÁẮẤÀẰẦẢẲẨÃẴẪẠẶẬĐEÊÉẾÈỀẺỂẼỄẸỆIÍÌỈĨỊOÔƠÓỐỚÒỒỜỎỔỞÕỖỠỌỘỢUƯÚỨÙỪỦỬŨỮỤỰYÝỲỶỸỴAĂÂÁẮẤÀẰẦẢẲẨÃẴẪẠẶẬĐEÊÉẾÈỀẺỂẼỄẸỆIÍÌỈĨỊOÔƠÓỐỚÒỒỜỎỔỞÕỖỠỌỘỢUƯÚỨÙỪỦỬŨỮỤỰYÝỲỶỸỴAĂÂÁẮẤÀẰẦẢẲẨÃẴẪẠẶẬĐEÊÉẾÈỀẺỂẼỄẸỆIÍÌỈĨỊOÔƠÓỐỚÒỒỜỎỔỞÕỖỠỌỘỢUƯÚỨÙỪỦỬŨỮỤỰYÝỲỶỸỴAĂÂÁẮẤÀẰẦẢẲẨÃẴẪẠẶẬĐEÊÉẾÈỀẺỂẼỄẸỆIÍÌỈĨỊOÔƠÓỐỚÒỒỜỎỔỞÕỖỠỌỘỢUƯÚỨÙỪỦỬŨỮỤỰYÝỲỶỸỴAĂÂÁẮẤÀẰẦẢẲẨÃẴẪẠẶẬĐEÊÉẾÈỀẺỂẼỄẸỆIÍÌỈĨỊOÔƠÓỐỚÒỒỜỎỔỞÕỖỠỌỘỢUƯÚỨÙỪỦỬŨỮỤỰYÝỲỶỸỴAĂÂÁẮẤÀẰẦẢẲẨÃẴẪẠẶẬĐEÊÉẾÈỀẺỂẼỄẸỆIÍÌỈĨỊOÔƠÓỐỚÒỒỜỎỔỞÕỖỠỌỘỢUƯÚỨÙỪỦỬŨỮỤỰYÝỲỶỸỴđa-zA-Z]+\S*\b", # noqa + value, + ): + return True + + # Turkish + if regex.search(r"\b\S*[a-zA-ZçğışöüÇĞİŞÖÜ]+\S*\b", value): + return True + + return False + + @classmethod + def is_extended_language(cls, value): + """ + Languages are combined in groups to handle cyclomatic complexity warnings + """ + if cls._is_extended_language_group_one(value): + return True + if cls._is_extended_language_group_two(value): + return True + if cls._is_extended_language_group_three(value): + return True + if cls.is_japanese(value): + return True + if cls._is_extended_language_group_four(value): + return True + if cls.is_chinese(value): + return True + + return False + + @classmethod + def encode_char(cls, c): + """ + Given a single unicode character, return a compatible character from the allowed set. + """ + # char is a good character already - return that native character. + if c in cls.ALLOWED_CHARACTERS: + return c + elif cls.is_extended_language(c): + return c + else: + c = cls.downgrade_character(c) + return c if c is not None else "?" + + +class SanitiseSMS(SanitiseText): + """ + Given an input string, makes it GSM and Welsh character compatible. This involves removing all non-gsm characters by + applying the following rules + * characters within the GSM character set (https://en.wikipedia.org/wiki/GSM_03.38) + and extension character set are kept + + * Welsh characters not included in the default GSM character set are kept + + * characters with sensible downgrades are replaced in place + * characters with diacritics (accents, umlauts, cedillas etc) are replaced with their base character, eg é -> e + * en dash and em dash (– and —) are replaced with hyphen (-) + * left/right quotation marks (‘, ’, “, ”) are replaced with ' and " + * zero width spaces (sometimes used to stop eg "gov.uk" linkifying) are removed + * tabs are replaced with a single space + + * any remaining unicode characters (eg chinese/cyrillic/glyphs/emoji) are replaced with ? + """ + + WELSH_DIACRITICS = set( + "àèìòùẁỳ" + "ÀÈÌÒÙẀỲ" # grave + "áéíóúẃý" + "ÁÉÍÓÚẂÝ" # acute + "äëïöüẅÿ" + "ÄËÏÖÜẄŸ" # diaeresis + "âêîôûŵŷ" + "ÂÊÎÔÛŴŶ" # carets + ) + + EXTENDED_GSM_CHARACTERS = set("^{}\\[~]|€") + + GSM_CHARACTERS = ( + set( + "@£$¥èéùìòÇ\nØø\rÅåΔ_ΦΓΛΩΠΨΣΘΞ\x1bÆæßÉ !\"#¤%&'()*+,-./0123456789:;<=>?" + + "¡ABCDEFGHIJKLMNOPQRSTUVWXYZÄÖÑܧ¿abcdefghijklmnopqrstuvwxyzäöñüà" + ) + | EXTENDED_GSM_CHARACTERS + ) + + ALLOWED_CHARACTERS = GSM_CHARACTERS | WELSH_DIACRITICS + # some welsh characters are in GSM and some aren't - we need to distinguish between these for counting fragments + WELSH_NON_GSM_CHARACTERS = WELSH_DIACRITICS - GSM_CHARACTERS + + +class SanitiseASCII(SanitiseText): + """ + As SMS above, but the allowed characters are printable ascii, from character range 32 to 126 inclusive. + [chr(x) for x in range(32, 127)] + """ + + ALLOWED_CHARACTERS = set( + " !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ" + + "[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~" + ) diff --git a/notifications_utils/serialised_model.py b/notifications_utils/serialised_model.py new file mode 100644 index 000000000..8e925008e --- /dev/null +++ b/notifications_utils/serialised_model.py @@ -0,0 +1,61 @@ +from abc import ABC, abstractmethod + + +class SerialisedModel(ABC): + """ + A SerialisedModel takes a dictionary, typically created by + serialising a database object. It then takes the value of specified + keys from the dictionary and adds them to itself as properties, so + that it can be interacted with like any other object. It is cleaner + and safer than dealing with dictionaries directly because it + guarantees that: + - all of the ALLOWED_PROPERTIES are present in the underlying + dictionary + - any other abritrary properties of the underlying dictionary can’t + be accessed + + If you are adding a new field to a model, you should ensure that + all sources of the cache data are updated to return that new field, + then clear the cache, before adding that field to the + ALLOWED_PROPERTIES list. + """ + + @property + @abstractmethod + def ALLOWED_PROPERTIES(self): + pass + + def __init__(self, _dict): + for property in self.ALLOWED_PROPERTIES: + setattr(self, property, _dict[property]) + + +class SerialisedModelCollection(ABC): + """ + A SerialisedModelCollection takes a list of dictionaries, typically + created by serialising database objects. When iterated over it + returns a model instance for each of the items in the list. + """ + + @property + @abstractmethod + def model(self): + pass + + def __init__(self, items): + self.items = items + + def __bool__(self): + return bool(self.items) + + def __getitem__(self, index): + return self.model(self.items[index]) + + def __len__(self): + return len(self.items) + + def __add__(self, other): + return list(self) + list(other) + + def __radd__(self, other): + return list(other) + list(self) diff --git a/notifications_utils/take.py b/notifications_utils/take.py new file mode 100644 index 000000000..ea46c39cf --- /dev/null +++ b/notifications_utils/take.py @@ -0,0 +1,3 @@ +class Take(str): + def then(self, func, *args, **kwargs): + return self.__class__(func(self, *args, **kwargs)) diff --git a/notifications_utils/template.py b/notifications_utils/template.py new file mode 100644 index 000000000..ec112173f --- /dev/null +++ b/notifications_utils/template.py @@ -0,0 +1,974 @@ +import math +import re +from abc import ABC, abstractmethod +from datetime import datetime +from functools import lru_cache +from html import unescape +from os import path + +from jinja2 import Environment, FileSystemLoader, select_autoescape +from markupsafe import Markup + +from notifications_utils import ( + LETTER_MAX_PAGE_COUNT, + MAGIC_SEQUENCE, + SMS_CHAR_COUNT_LIMIT, +) +from notifications_utils.countries.data import Postage +from notifications_utils.field import Field, PlainTextField +from notifications_utils.formatters import ( + add_prefix, + add_trailing_newline, + autolink_urls, + escape_html, + formatted_list, + make_quotes_smart, + nl2br, + normalise_multiple_newlines, + normalise_whitespace, + normalise_whitespace_and_newlines, + remove_smart_quotes_from_email_addresses, + remove_whitespace_before_punctuation, + replace_hyphens_with_en_dashes, + replace_hyphens_with_non_breaking_hyphens, + sms_encode, + strip_leading_whitespace, + strip_unsupported_characters, + unlink_govuk_escaped, +) +from notifications_utils.insensitive_dict import InsensitiveDict +from notifications_utils.markdown import ( + notify_email_markdown, + notify_email_preheader_markdown, + notify_letter_preview_markdown, + notify_plain_text_email_markdown, +) +from notifications_utils.postal_address import PostalAddress, address_lines_1_to_7_keys +from notifications_utils.sanitise_text import SanitiseSMS +from notifications_utils.take import Take +from notifications_utils.template_change import TemplateChange + +template_env = Environment( + autoescape=select_autoescape(), + loader=FileSystemLoader( + path.join( + path.dirname(path.abspath(__file__)), + "jinja_templates", + ) + ), +) + + +class Template(ABC): + encoding = "utf-8" + + def __init__( + self, + template, + values=None, + redact_missing_personalisation=False, + ): + if not isinstance(template, dict): + raise TypeError("Template must be a dict") + if values is not None and not isinstance(values, dict): + raise TypeError("Values must be a dict") + if template.get("template_type") != self.template_type: + raise TypeError( + f"Cannot initialise {self.__class__.__name__} " + f'with {template.get("template_type")} template_type' + ) + self.id = template.get("id", None) + self.name = template.get("name", None) + self.content = template["content"] + self.values = values + self._template = template + self.redact_missing_personalisation = redact_missing_personalisation + + def __repr__(self): + return '{}("{}", {})'.format(self.__class__.__name__, self.content, self.values) + + @abstractmethod + def __str__(self): + pass + + @property + def content_with_placeholders_filled_in(self): + return str( + Field( + self.content, + self.values, + html="passthrough", + redact_missing_personalisation=self.redact_missing_personalisation, + markdown_lists=True, + ) + ).strip() + + @property + def values(self): + if hasattr(self, "_values"): + return self._values + return {} + + @values.setter + def values(self, value): + if not value: + self._values = {} + else: + placeholders = InsensitiveDict.from_keys(self.placeholders) + self._values = InsensitiveDict(value).as_dict_with_keys( + self.placeholders + | set( + key + for key in value.keys() + if InsensitiveDict.make_key(key) not in placeholders.keys() + ) + ) + + @property + def placeholders(self): + return get_placeholders(self.content) + + @property + def missing_data(self): + return list( + placeholder + for placeholder in self.placeholders + if self.values.get(placeholder) is None + ) + + @property + def additional_data(self): + return self.values.keys() - self.placeholders + + def get_raw(self, key, default=None): + return self._template.get(key, default) + + def compare_to(self, new): + return TemplateChange(self, new) + + @property + def content_count(self): + return len(self.content_with_placeholders_filled_in) + + def is_message_empty(self): + if not self.content: + return True + + if not self.content.startswith("((") or not self.content.endswith("))"): + # If the content doesn’t start or end with a placeholder we + # can guarantee it’s not empty, no matter what + # personalisation has been provided. + return False + + return self.content_count == 0 + + def is_message_too_long(self): + return False + + +class BaseSMSTemplate(Template): + template_type = "sms" + + def __init__( + self, + template, + values=None, + prefix=None, + show_prefix=True, + sender=None, + ): + self.prefix = prefix + self.show_prefix = show_prefix + self.sender = sender + self._content_count = None + super().__init__(template, values) + + @property + def values(self): + return super().values + + @values.setter + def values(self, value): + # If we change the values of the template it’s possible the + # content count will have changed, so we need to reset the + # cached count. + if self._content_count is not None: + self._content_count = None + + # Assigning to super().values doesn’t work here. We need to get + # the property object instead, which has the special method + # fset, which invokes the setter it as if we were + # assigning to it outside this class. + super(BaseSMSTemplate, type(self)).values.fset(self, value) + + @property + def content_with_placeholders_filled_in(self): + # We always call SMSMessageTemplate.__str__ regardless of + # subclass, to avoid any HTML formatting. SMS templates differ + # in that the content can include the service name as a prefix. + # So historically we’ve returned the fully-formatted message, + # rather than some plain-text represenation of the content. To + # preserve compatibility for consumers of the API we maintain + # that behaviour by overriding this method here. + return SMSMessageTemplate.__str__(self) + + @property + def prefix(self): + return self._prefix if self.show_prefix else None + + @prefix.setter + def prefix(self, value): + self._prefix = value + + @property + def content_count(self): + """ + Return the number of characters in the message. Note that we don't distinguish between GSM and non-GSM + characters at this point, as `get_sms_fragment_count` handles that separately. + + Also note that if values aren't provided, will calculate the raw length of the unsubstituted placeholders, + as in the message `foo ((placeholder))` has a length of 19. + """ + if self._content_count is None: + self._content_count = len(self._get_unsanitised_content()) + return self._content_count + + @property + def content_count_without_prefix(self): + # subtract 2 extra characters to account for the colon and the space, + # added max zero in case the content is empty the __str__ methods strips the white space. + if self.prefix: + return max((self.content_count - len(self.prefix) - 2), 0) + else: + return self.content_count + + @property + def fragment_count(self): + """ + A fragment is up to 140 bytes, which could consist of 160 GSM chars, 140 ascii chars, or 70 ucs-2 chars, + or any combination thereof. + + Since we are supporting more or less "all" languages, it doesn't seem like we really want to count chars, + and that counting bytes should suffice. + """ + + # check if all chars are in the GSM-7 character set + def gsm_check(x): + rule = re.compile( + r'^[\sa-zA-Z0-9_@?£!1$"¥#è?¤é%ù&ì\\ò(Ç)*:Ø+;ÄäøÆ,ÜüåÉ/§à¡¿\']+$' + ) + gsm_match = rule.search(x) + if gsm_match is None: + return False + return True + + message_str = self.content_with_placeholders_filled_in + + content_len = len(message_str) + + """ + Checks for GSM-7 char set, calculates msg size, and + then fragments based on multipart message rules. ASCII + was not specifically called out as almost all messages will + switch from 7bit GSM to Unicode. + + Calculations are based on https://messente.com/documentation/tools/sms-length-calculator + """ + if gsm_check(message_str): + if content_len <= 160: + return math.ceil(content_len / 160) + else: + return math.ceil(content_len / 153) + else: + if content_len <= 70: + return math.ceil(content_len / 70) + else: + return math.ceil(content_len / 67) + + def is_message_too_long(self): + """ + Message is validated with out the prefix. + We have decided to be lenient and let the message go over the character limit. The SMS provider will + send messages well over our limit. There were some inconsistencies with how we were validating the + length of a message. This should be the method used anytime we want to reject a message for being too long. + """ + return self.content_count_without_prefix > SMS_CHAR_COUNT_LIMIT + + def is_message_empty(self): + return self.content_count_without_prefix == 0 + + def _get_unsanitised_content(self): + # This is faster to call than SMSMessageTemplate.__str__ if all + # you need to know is how many characters are in the message + if self.values: + values = self.values + else: + values = {key: MAGIC_SEQUENCE for key in self.placeholders} + return ( + Take(PlainTextField(self.content, values, html="passthrough")) + .then(add_prefix, self.prefix) + .then(remove_whitespace_before_punctuation) + .then(normalise_whitespace_and_newlines) + .then(normalise_multiple_newlines) + .then(str.strip) + .then(str.replace, MAGIC_SEQUENCE, "") + ) + + +class SMSMessageTemplate(BaseSMSTemplate): + def __str__(self): + return sms_encode(self._get_unsanitised_content()) + + +class SMSBodyPreviewTemplate(BaseSMSTemplate): + def __init__( + self, + template, + values=None, + ): + super().__init__(template, values, show_prefix=False) + + def __str__(self): + return Markup( + Take( + Field( + self.content, + self.values, + html="escape", + redact_missing_personalisation=True, + ) + ) + .then(sms_encode) + .then(remove_whitespace_before_punctuation) + .then(normalise_whitespace_and_newlines) + .then(normalise_multiple_newlines) + .then(str.strip) + ) + + +class SMSPreviewTemplate(BaseSMSTemplate): + jinja_template = template_env.get_template("sms_preview_template.jinja2") + + def __init__( + self, + template, + values=None, + prefix=None, + show_prefix=True, + sender=None, + show_recipient=False, + show_sender=False, + downgrade_non_sms_characters=True, + redact_missing_personalisation=False, + ): + self.show_recipient = show_recipient + self.show_sender = show_sender + self.downgrade_non_sms_characters = downgrade_non_sms_characters + super().__init__(template, values, prefix, show_prefix, sender) + self.redact_missing_personalisation = redact_missing_personalisation + + def __str__(self): + return Markup( + self.jinja_template.render( + { + "sender": self.sender, + "show_sender": self.show_sender, + "recipient": Field( + "((phone number))", + self.values, + with_brackets=False, + html="escape", + ), + "show_recipient": self.show_recipient, + "body": Take( + Field( + self.content, + self.values, + html="escape", + redact_missing_personalisation=self.redact_missing_personalisation, + ) + ) + .then( + add_prefix, + ( + (escape_html(self.prefix) or None) + if self.show_prefix + else None + ), + ) + .then(sms_encode if self.downgrade_non_sms_characters else str) + .then(remove_whitespace_before_punctuation) + .then(normalise_whitespace_and_newlines) + .then(normalise_multiple_newlines) + .then(nl2br) + .then( + autolink_urls, + classes="govuk-link govuk-link--no-visited-state", + ), + } + ) + ) + + +class BaseBroadcastTemplate(BaseSMSTemplate): + template_type = "broadcast" + + MAX_CONTENT_COUNT_GSM = 1_395 + MAX_CONTENT_COUNT_UCS2 = 615 + + @property + def encoded_content_count(self): + if self.non_gsm_characters: + return self.content_count + return self.content_count + count_extended_gsm_chars( + self.content_with_placeholders_filled_in + ) + + @property + def non_gsm_characters(self): + return non_gsm_characters(self.content) + + @property + def max_content_count(self): + if self.non_gsm_characters: + return self.MAX_CONTENT_COUNT_UCS2 + return self.MAX_CONTENT_COUNT_GSM + + @property + def content_too_long(self): + return self.encoded_content_count > self.max_content_count + + +class BroadcastPreviewTemplate(BaseBroadcastTemplate, SMSPreviewTemplate): + jinja_template = template_env.get_template("broadcast_preview_template.jinja2") + + +class BroadcastMessageTemplate(BaseBroadcastTemplate, SMSMessageTemplate): + @classmethod + def from_content(cls, content): + return cls( + template={ + "template_type": cls.template_type, + "content": content, + }, + values=None, # events have already done interpolation of any personalisation + ) + + @classmethod + def from_event(cls, broadcast_event): + """ + should be directly callable with the results of the BroadcastEvent.serialize() function from api/models.py + """ + return cls.from_content(broadcast_event["transmitted_content"]["body"]) + + def __str__(self): + return ( + Take( + Field( + self.content.strip(), + self.values, + html="escape", + ) + ) + .then(sms_encode) + .then(remove_whitespace_before_punctuation) + .then(normalise_whitespace_and_newlines) + .then(normalise_multiple_newlines) + ) + + +class SubjectMixin: + def __init__(self, template, values=None, **kwargs): + self._subject = template["subject"] + super().__init__(template, values, **kwargs) + + @property + def subject(self): + return Markup( + Take( + Field( + self._subject, + self.values, + html="escape", + redact_missing_personalisation=self.redact_missing_personalisation, + ) + ) + .then(do_nice_typography) + .then(normalise_whitespace) + ) + + @property + def placeholders(self): + return get_placeholders(self._subject) | super().placeholders + + +class BaseEmailTemplate(SubjectMixin, Template): + template_type = "email" + + @property + def html_body(self): + return ( + Take( + Field( + self.content, + self.values, + html="escape", + markdown_lists=True, + redact_missing_personalisation=self.redact_missing_personalisation, + ) + ) + .then(unlink_govuk_escaped) + .then(strip_unsupported_characters) + .then(add_trailing_newline) + .then(notify_email_markdown) + .then(do_nice_typography) + ) + + @property + def content_size_in_bytes(self): + return len(self.content_with_placeholders_filled_in.encode("utf8")) + + def is_message_too_long(self): + """ + SES rejects email messages bigger than 10485760 bytes (just over 10 MB per message (after base64 encoding)): + https://docs.aws.amazon.com/ses/latest/DeveloperGuide/quotas.html#limits-message + + Base64 is apparently wasteful because we use just 64 different values per byte, whereas a byte can represent + 256 different characters. That is, we use bytes (which are 8-bit words) as 6-bit words. There is + a waste of 2 bits for each 8 bits of transmission data. To send three bytes of information + (3 times 8 is 24 bits), you need to use four bytes (4 times 6 is again 24 bits). Thus the base64 version + of a file is 4/3 larger than it might be. So we use 33% more storage than we could. + https://lemire.me/blog/2019/01/30/what-is-the-space-overhead-of-base64-encoding/ + + That brings down our max safe size to 7.5 MB == 7500000 bytes before base64 encoding + + But this is not the end! The message we send to SES is structured as follows: + "Message": { + 'Subject': { + 'Data': subject, + }, + 'Body': {'Text': {'Data': body}, 'Html': {'Data': html_body}} + }, + Which means that we are sending the contents of email message twice in one request: once in plain text + and once with html tags. That means our plain text content needs to be much shorter to make sure we + fit within the limit, especially since HTML body can be much byte-heavier than plain text body. + + Hence, we decided to put the limit at 1MB, which is equivalent of between 250 and 500 pages of text. + That's still an extremely long email, and should be sufficient for all normal use, while at the same + time giving us safe margin while sending the emails through Amazon SES. + + EDIT: putting size up to 2MB as GOV.UK email digests are hitting the limit. + """ + return self.content_size_in_bytes > 2000000 + + +class PlainTextEmailTemplate(BaseEmailTemplate): + def __str__(self): + return ( + Take( + Field( + self.content, self.values, html="passthrough", markdown_lists=True + ) + ) + .then(unlink_govuk_escaped) + .then(strip_unsupported_characters) + .then(add_trailing_newline) + .then(notify_plain_text_email_markdown) + .then(do_nice_typography) + .then(unescape) + .then(strip_leading_whitespace) + .then(add_trailing_newline) + ) + + @property + def subject(self): + return Markup( + Take( + Field( + self._subject, + self.values, + html="passthrough", + redact_missing_personalisation=self.redact_missing_personalisation, + ) + ) + .then(do_nice_typography) + .then(normalise_whitespace) + ) + + +class HTMLEmailTemplate(BaseEmailTemplate): + jinja_template = template_env.get_template("email_template.jinja2") + + PREHEADER_LENGTH_IN_CHARACTERS = 256 + + def __init__( + self, + template, + values=None, + govuk_banner=True, + complete_html=True, + brand_logo=None, + brand_text=None, + brand_colour=None, + brand_banner=False, + brand_name=None, + ): + super().__init__(template, values) + self.govuk_banner = govuk_banner + self.complete_html = complete_html + self.brand_logo = brand_logo + self.brand_text = brand_text + self.brand_colour = brand_colour + self.brand_banner = brand_banner + self.brand_name = brand_name + + @property + def preheader(self): + return " ".join( + Take( + Field( + self.content, + self.values, + html="escape", + markdown_lists=True, + ) + ) + .then(unlink_govuk_escaped) + .then(strip_unsupported_characters) + .then(add_trailing_newline) + .then(notify_email_preheader_markdown) + .then(do_nice_typography) + .split() + )[: self.PREHEADER_LENGTH_IN_CHARACTERS].strip() + + def __str__(self): + return self.jinja_template.render( + { + "subject": self.subject, + "body": self.html_body, + "preheader": self.preheader, + "govuk_banner": self.govuk_banner, + "complete_html": self.complete_html, + "brand_logo": self.brand_logo, + "brand_text": self.brand_text, + "brand_colour": self.brand_colour, + "brand_banner": self.brand_banner, + "brand_name": self.brand_name, + } + ) + + +class EmailPreviewTemplate(BaseEmailTemplate): + jinja_template = template_env.get_template("email_preview_template.jinja2") + + def __init__( + self, + template, + values=None, + from_name=None, + from_address=None, + reply_to=None, + show_recipient=True, + redact_missing_personalisation=False, + ): + super().__init__( + template, + values, + redact_missing_personalisation=redact_missing_personalisation, + ) + self.from_name = from_name + self.from_address = from_address + self.reply_to = reply_to + self.show_recipient = show_recipient + + def __str__(self): + return Markup( + self.jinja_template.render( + { + "body": self.html_body, + "subject": self.subject, + "from_name": escape_html(self.from_name), + "from_address": self.from_address, + "reply_to": self.reply_to, + "recipient": Field( + "((email address))", self.values, with_brackets=False + ), + "show_recipient": self.show_recipient, + } + ) + ) + + @property + def subject(self): + return ( + Take( + Field( + self._subject, + self.values, + html="escape", + redact_missing_personalisation=self.redact_missing_personalisation, + ) + ) + .then(do_nice_typography) + .then(normalise_whitespace) + ) + + +class BaseLetterTemplate(SubjectMixin, Template): + template_type = "letter" + + address_block = "\n".join( + f'(({line.replace("_", " ")}))' for line in address_lines_1_to_7_keys + ) + + def __init__( + self, + template, + values=None, + contact_block=None, + admin_base_url="http://localhost:6012", + logo_file_name=None, + redact_missing_personalisation=False, + date=None, + ): + self.contact_block = (contact_block or "").strip() + super().__init__( + template, + values, + redact_missing_personalisation=redact_missing_personalisation, + ) + self.admin_base_url = admin_base_url + self.logo_file_name = logo_file_name + self.date = date or datetime.utcnow() + + @property + def subject(self): + return ( + Take( + Field( + self._subject, + self.values, + redact_missing_personalisation=self.redact_missing_personalisation, + html="escape", + ) + ) + .then(do_nice_typography) + .then(normalise_whitespace) + ) + + @property + def placeholders(self): + return get_placeholders(self.contact_block) | super().placeholders + + @property + def postal_address(self): + return PostalAddress.from_personalisation(InsensitiveDict(self.values)) + + @property + def _address_block(self): + if ( + self.postal_address.has_enough_lines + and not self.postal_address.has_too_many_lines + ): + return self.postal_address.normalised_lines + + if "address line 7" not in self.values and "postcode" in self.values: + self.values["address line 7"] = self.values["postcode"] + + return Field( + self.address_block, + self.values, + html="escape", + with_brackets=False, + ).splitlines() + + @property + def _contact_block(self): + return ( + Take( + Field( + "\n".join(line.strip() for line in self.contact_block.split("\n")), + self.values, + redact_missing_personalisation=self.redact_missing_personalisation, + html="escape", + ) + ) + .then(remove_whitespace_before_punctuation) + .then(nl2br) + ) + + @property + def _date(self): + return self.date.strftime("%-d %B %Y") + + @property + def _message(self): + return ( + Take( + Field( + self.content, + self.values, + html="escape", + markdown_lists=True, + redact_missing_personalisation=self.redact_missing_personalisation, + ) + ) + .then(add_trailing_newline) + .then(notify_letter_preview_markdown) + .then(do_nice_typography) + .then(replace_hyphens_with_non_breaking_hyphens) + ) + + +class LetterPreviewTemplate(BaseLetterTemplate): + jinja_template = template_env.get_template("letter_pdf/preview.jinja2") + + def __str__(self): + return Markup( + self.jinja_template.render( + { + "admin_base_url": self.admin_base_url, + "logo_file_name": self.logo_file_name, + # logo_class should only ever be None, svg or png + "logo_class": ( + self.logo_file_name.lower()[-3:] + if self.logo_file_name + else None + ), + "subject": self.subject, + "message": self._message, + "address": self._address_block, + "contact_block": self._contact_block, + "date": self._date, + } + ) + ) + + +class LetterPrintTemplate(LetterPreviewTemplate): + jinja_template = template_env.get_template("letter_pdf/print.jinja2") + + +class LetterImageTemplate(BaseLetterTemplate): + jinja_template = template_env.get_template("letter_image_template.jinja2") + first_page_number = 1 + allowed_postage_types = ( + Postage.FIRST, + Postage.SECOND, + Postage.EUROPE, + Postage.REST_OF_WORLD, + ) + + def __init__( + self, + template, + values=None, + image_url=None, + page_count=None, + contact_block=None, + postage=None, + ): + super().__init__(template, values, contact_block=contact_block) + if not image_url: + raise TypeError("image_url is required") + if not page_count: + raise TypeError("page_count is required") + if postage not in [None] + list(self.allowed_postage_types): + raise TypeError( + "postage must be None, {}".format( + formatted_list( + self.allowed_postage_types, + conjunction="or", + before_each="'", + after_each="'", + ) + ) + ) + self.image_url = image_url + self.page_count = int(page_count) + self._postage = postage + + @property + def postage(self): + if self.postal_address.international: + return self.postal_address.postage + return self._postage + + @property + def last_page_number(self): + return min(self.page_count, LETTER_MAX_PAGE_COUNT) + self.first_page_number + + @property + def page_numbers(self): + return list(range(self.first_page_number, self.last_page_number)) + + @property + def postage_description(self): + return { + Postage.FIRST: "first class", + Postage.SECOND: "second class", + Postage.EUROPE: "international", + Postage.REST_OF_WORLD: "international", + }.get(self.postage) + + @property + def postage_class_value(self): + return { + Postage.FIRST: "letter-postage-first", + Postage.SECOND: "letter-postage-second", + Postage.EUROPE: "letter-postage-international", + Postage.REST_OF_WORLD: "letter-postage-international", + }.get(self.postage) + + def __str__(self): + return Markup( + self.jinja_template.render( + { + "image_url": self.image_url, + "page_numbers": self.page_numbers, + "address": self._address_block, + "contact_block": self._contact_block, + "date": self._date, + "subject": self.subject, + "message": self._message, + "show_postage": bool(self.postage), + "postage_description": self.postage_description, + "postage_class_value": self.postage_class_value, + } + ) + ) + + +def get_sms_fragment_count(character_count, non_gsm_characters): + if non_gsm_characters: + return 1 if character_count <= 70 else math.ceil(float(character_count) / 67) + else: + return 1 if character_count <= 160 else math.ceil(float(character_count) / 153) + + +def non_gsm_characters(content): + """ + Returns a set of all the non gsm characters in a text. this doesn't include characters that we will downgrade (eg + emoji, ellipsis, ñ, etc). This only includes welsh non gsm characters that will force the entire SMS to be encoded + with UCS-2. + """ + return set(content) & set(SanitiseSMS.WELSH_NON_GSM_CHARACTERS) + + +def count_extended_gsm_chars(content): + return sum(map(content.count, SanitiseSMS.EXTENDED_GSM_CHARACTERS)) + + +def do_nice_typography(value): + return ( + Take(value) + .then(remove_whitespace_before_punctuation) + .then(make_quotes_smart) + .then(remove_smart_quotes_from_email_addresses) + .then(replace_hyphens_with_en_dashes) + ) + + +@lru_cache(maxsize=1024) +def get_placeholders(content): + return Field(content).placeholders diff --git a/notifications_utils/template_change.py b/notifications_utils/template_change.py new file mode 100644 index 000000000..e47a271f6 --- /dev/null +++ b/notifications_utils/template_change.py @@ -0,0 +1,31 @@ +from ordered_set import OrderedSet + +from notifications_utils.insensitive_dict import InsensitiveDict + + +class TemplateChange: + def __init__(self, old_template, new_template): + self.old_placeholders = InsensitiveDict.from_keys(old_template.placeholders) + self.new_placeholders = InsensitiveDict.from_keys(new_template.placeholders) + + @property + def has_different_placeholders(self): + return bool(self.new_placeholders.keys() ^ self.old_placeholders.keys()) + + @property + def placeholders_added(self): + return OrderedSet( + [ + self.new_placeholders.get(key) + for key in self.new_placeholders.keys() - self.old_placeholders.keys() + ] + ) + + @property + def placeholders_removed(self): + return OrderedSet( + [ + self.old_placeholders.get(key) + for key in self.old_placeholders.keys() - self.new_placeholders.keys() + ] + ) diff --git a/notifications_utils/timezones.py b/notifications_utils/timezones.py new file mode 100644 index 000000000..277a139f0 --- /dev/null +++ b/notifications_utils/timezones.py @@ -0,0 +1,16 @@ +import os + +import pytz +from dateutil import parser + +local_timezone = pytz.timezone(os.getenv("TIMEZONE", "America/New_York")) + + +def utc_string_to_aware_gmt_datetime(date): + """ + Date can either be a string, naïve UTC datetime or an aware UTC datetime + Returns an aware local datetime, essentially the time you'd see on your clock + """ + date = parser.parse(date) + forced_utc = date.replace(tzinfo=pytz.utc) + return forced_utc.astimezone(local_timezone) diff --git a/notifications_utils/url_safe_token.py b/notifications_utils/url_safe_token.py new file mode 100644 index 000000000..502534809 --- /dev/null +++ b/notifications_utils/url_safe_token.py @@ -0,0 +1,13 @@ +from itsdangerous import URLSafeTimedSerializer + +from notifications_utils.formatters import url_encode_full_stops + + +def generate_token(payload, secret, salt): + return url_encode_full_stops(URLSafeTimedSerializer(secret).dumps(payload, salt)) + + +def check_token(token, secret, salt, max_age_seconds): + ser = URLSafeTimedSerializer(secret) + payload = ser.loads(token, max_age=max_age_seconds, salt=salt) + return payload diff --git a/poetry.lock b/poetry.lock index 930c20232..a5415efc2 100644 --- a/poetry.lock +++ b/poetry.lock @@ -171,41 +171,41 @@ files = [ [[package]] name = "boto3" -version = "1.29.6" +version = "1.34.78" description = "The AWS SDK for Python" optional = false -python-versions = ">= 3.7" +python-versions = ">=3.8" files = [ - {file = "boto3-1.29.6-py3-none-any.whl", hash = "sha256:f4d19e01d176c3a5a05e4af733185ff1891b08a3c38d4a439800fa132aa6e9be"}, - {file = "boto3-1.29.6.tar.gz", hash = "sha256:d1d0d979a70bf9b0b13ae3b017f8523708ad953f62d16f39a602d67ee9b25554"}, + {file = "boto3-1.34.78-py3-none-any.whl", hash = "sha256:47a7899af97960493ed58754c838be658650c8fb231c658866f491965ddfc94f"}, + {file = "boto3-1.34.78.tar.gz", hash = "sha256:227487f9a40e7963aa108f4fabc81374d65e085891a2a442c190dfd976b86a9e"}, ] [package.dependencies] -botocore = ">=1.32.6,<1.33.0" +botocore = ">=1.34.78,<1.35.0" jmespath = ">=0.7.1,<2.0.0" -s3transfer = ">=0.7.0,<0.8.0" +s3transfer = ">=0.10.0,<0.11.0" [package.extras] crt = ["botocore[crt] (>=1.21.0,<2.0a0)"] [[package]] name = "botocore" -version = "1.32.7" +version = "1.34.78" description = "Low-level, data-driven core of boto 3." optional = false -python-versions = ">= 3.7" +python-versions = ">=3.8" files = [ - {file = "botocore-1.32.7-py3-none-any.whl", hash = "sha256:58b33d02cafa23461c8a9d211b30e8cded992380a84de409379fd02811fa3e11"}, - {file = "botocore-1.32.7.tar.gz", hash = "sha256:c6795c731b04c8e3635588c44cfd1a4462fc5987859195522c96812cf3eceff9"}, + {file = "botocore-1.34.78-py3-none-any.whl", hash = "sha256:bc10738826a4970a6d3a40ac40b9799c02b1b661c0c741a67b915b500562ab3c"}, + {file = "botocore-1.34.78.tar.gz", hash = "sha256:889fcfd1813fad225a5a70940c58cd4bd7a6f5ba6c9769a1d41d0c670272b75d"}, ] [package.dependencies] jmespath = ">=0.7.1,<2.0.0" python-dateutil = ">=2.1,<3.0.0" -urllib3 = {version = ">=1.25.4,<2.1", markers = "python_version >= \"3.10\""} +urllib3 = {version = ">=1.25.4,<2.2.0 || >2.2.0,<3", markers = "python_version >= \"3.10\""} [package.extras] -crt = ["awscrt (==0.19.17)"] +crt = ["awscrt (==0.19.19)"] [[package]] name = "cachecontrol" @@ -1596,62 +1596,6 @@ docopt = ">=0.3.0" PyJWT = ">=1.5.1" requests = ">=2.0.0" -[[package]] -name = "notifications-utils" -version = "0.4.0" -description = "" -optional = false -python-versions = "^3.12.2" -files = [] -develop = false - -[package.dependencies] -async-timeout = "^4.0.2" -bleach = "^6.1.0" -blinker = "^1.6.2" -boto3 = "^1.28.66" -botocore = "^1.31.66" -cachetools = "^5.3.0" -certifi = "^2024.2.2" -cffi = "^1.16.0" -charset-normalizer = "^3.1.0" -click = "^8.1.3" -cryptography = "^42.0.4" -flask = "^2.3.2" -flask-redis = "^0.4.0" -geojson = "^3.0.1" -govuk-bank-holidays = "^0.13" -idna = "^3.6" -itsdangerous = "^2.1.2" -jinja2 = "^3.1.3" -jmespath = "^1.0.1" -markupsafe = "^2.1.5" -mistune = "==0.8.4" -numpy = "^1.24.2" -ordered-set = "^4.1.0" -phonenumbers = "^8.13.32" -pycparser = "^2.21" -python-dateutil = "^2.8.2" -python-json-logger = "^2.0.7" -pytz = "^2023.3" -pyyaml = "^6.0" -redis = "^5.0.3" -regex = "^2023.12.25" -requests = "^2.31.0" -s3transfer = "^0.7.0" -shapely = "^2.0.1" -six = "^1.16.0" -smartypants = "^2.0.1" -urllib3 = "^1.26.18" -webencodings = "^0.5.1" -werkzeug = "^3.0.1" - -[package.source] -type = "git" -url = "https://github.com/GSA/notifications-utils.git" -reference = "HEAD" -resolved_reference = "4cf526bc1fd9532507936c174418dbd3be52c925" - [[package]] name = "numpy" version = "1.26.4" @@ -1777,13 +1721,13 @@ files = [ [[package]] name = "phonenumbers" -version = "8.13.33" +version = "8.13.34" description = "Python version of Google's common library for parsing, formatting, storing and validating international phone numbers." optional = false python-versions = "*" files = [ - {file = "phonenumbers-8.13.33-py2.py3-none-any.whl", hash = "sha256:f2d653268ece55a4f3752d9cda4be6f7465f298e6d028d522aedda13cf057201"}, - {file = "phonenumbers-8.13.33.tar.gz", hash = "sha256:991f2619f0593b36b674c345af47944ec4bae526b353cf53d707e662087be63b"}, + {file = "phonenumbers-8.13.34-py2.py3-none-any.whl", hash = "sha256:bc0bb5d3bab29e28549194f6bf57cb3ca03c3dd84238af12674fe24031631bda"}, + {file = "phonenumbers-8.13.34.tar.gz", hash = "sha256:7c2676be07b7d0f74411e275e0660380a0ec3ee0d359f070d719424bd2c5f62e"}, ] [[package]] @@ -2619,20 +2563,20 @@ diagram = ["matplotlib (>=3.0.0)", "pydot (>=1.3.0)", "tqdm (>=v4.31.0)"] [[package]] name = "s3transfer" -version = "0.7.0" +version = "0.10.1" description = "An Amazon S3 Transfer Manager" optional = false -python-versions = ">= 3.7" +python-versions = ">= 3.8" files = [ - {file = "s3transfer-0.7.0-py3-none-any.whl", hash = "sha256:10d6923c6359175f264811ef4bf6161a3156ce8e350e705396a7557d6293c33a"}, - {file = "s3transfer-0.7.0.tar.gz", hash = "sha256:fd3889a66f5fe17299fe75b82eae6cf722554edca744ca5d5fe308b104883d2e"}, + {file = "s3transfer-0.10.1-py3-none-any.whl", hash = "sha256:ceb252b11bcf87080fb7850a224fb6e05c8a776bab8f2b64b7f25b969464839d"}, + {file = "s3transfer-0.10.1.tar.gz", hash = "sha256:5683916b4c724f799e600f41dd9e10a9ff19871bf87623cc8f491cb4f5fa0a19"}, ] [package.dependencies] -botocore = ">=1.12.36,<2.0a.0" +botocore = ">=1.33.2,<2.0a.0" [package.extras] -crt = ["botocore[crt] (>=1.20.29,<2.0a.0)"] +crt = ["botocore[crt] (>=1.33.2,<2.0a.0)"] [[package]] name = "setuptools" @@ -2930,4 +2874,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = "^3.12.2" -content-hash = "fb81af6421e07a1640bd784f2e1934237a064b30705a0c02e6f06415493e05ff" +content-hash = "d7d011a6d117194a553deafd8be3048d865ebd0261fb2661073a96d8b54b6ebf" diff --git a/pyproject.toml b/pyproject.toml index 40a1aac24..a3f3c97fc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,7 +24,6 @@ itsdangerous = "~=2.1" jinja2 = "~=3.1" newrelic = "*" notifications-python-client = "==9.0.0" -notifications-utils = {git = "https://github.com/GSA/notifications-utils.git"} pyexcel = "==0.7.0" pyexcel-io = "==0.6.6" pyexcel-ods3 = "==0.6.1" @@ -38,6 +37,27 @@ rtreelib = "==0.2.0" werkzeug = "^3.0.1" wtforms = "~=3.1" markdown = "^3.5.2" +async-timeout = "^4.0.3" +bleach = "^6.1.0" +boto3 = "^1.34.78" +botocore = "^1.34.78" +cachetools = "^5.3.3" +cffi = "^1.16.0" +cryptography = "^42.0.5" +flask-redis = "^0.4.0" +geojson = "^3.1.0" +jmespath = "^1.0.1" +mistune = "0.8.4" +numpy = "^1.26.4" +ordered-set = "^4.1.0" +phonenumbers = "^8.13.34" +pycparser = "^2.22" +python-json-logger = "^2.0.7" +redis = "^5.0.3" +regex = "^2023.12.25" +s3transfer = "^0.10.1" +shapely = "^2.0.3" +smartypants = "^2.0.1" [tool.poetry.group.dev.dependencies] diff --git a/tests/app/main/views/test_new_password.py b/tests/app/main/views/test_new_password.py index 3684801de..89c8fef9e 100644 --- a/tests/app/main/views/test_new_password.py +++ b/tests/app/main/views/test_new_password.py @@ -5,8 +5,8 @@ import pytest from flask import url_for from freezegun import freeze_time from itsdangerous import SignatureExpired -from notifications_utils.url_safe_token import generate_token +from notifications_utils.url_safe_token import generate_token from tests.conftest import SERVICE_ONE_ID, url_for_endpoint_with_token diff --git a/tests/app/main/views/test_send.py b/tests/app/main/views/test_send.py index e15772833..449259d8d 100644 --- a/tests/app/main/views/test_send.py +++ b/tests/app/main/views/test_send.py @@ -12,11 +12,11 @@ from zipfile import BadZipFile import pytest from flask import url_for from notifications_python_client.errors import HTTPError -from notifications_utils.recipients import RecipientCSV -from notifications_utils.template import SMSPreviewTemplate from xlrd.biffh import XLRDError from xlrd.xldate import XLDateAmbiguous, XLDateError, XLDateNegative, XLDateTooLarge +from notifications_utils.recipients import RecipientCSV +from notifications_utils.template import SMSPreviewTemplate from tests import validate_route_permission, validate_route_permission_with_client from tests.conftest import ( SERVICE_ONE_ID, @@ -979,7 +979,7 @@ def test_upload_valid_csv_shows_preview_and_table( '
    A
    ', ( ' ' - '
    ' + "
    " "
      " "
    • foo
    • foo
    • foo
    • " "
    " @@ -992,7 +992,7 @@ def test_upload_valid_csv_shows_preview_and_table( '
    B
    ', ( ' ' - '
    ' + "
    " "
      " "
    • foo
    • foo
    • foo
    • " "
    " @@ -1005,7 +1005,7 @@ def test_upload_valid_csv_shows_preview_and_table( '
    C
    ', ( ' ' - '
    ' + "
    " "
      " "
    • foo
    • foo
    • " "
    " diff --git a/tests/app/main/views/test_user_profile.py b/tests/app/main/views/test_user_profile.py index a9a78f497..7e444478c 100644 --- a/tests/app/main/views/test_user_profile.py +++ b/tests/app/main/views/test_user_profile.py @@ -3,8 +3,8 @@ import uuid import pytest from flask import url_for -from notifications_utils.url_safe_token import generate_token +from notifications_utils.url_safe_token import generate_token from tests.conftest import ( create_api_user_active, create_user, diff --git a/tests/app/utils/test_templates.py b/tests/app/utils/test_templates.py index 6061f954c..557611156 100644 --- a/tests/app/utils/test_templates.py +++ b/tests/app/utils/test_templates.py @@ -1,7 +1,7 @@ import pytest -from notifications_utils.template import Template from app.utils.templates import get_sample_template +from notifications_utils.template import Template @pytest.mark.parametrize("template_type", ["sms", "email"]) diff --git a/tests/conftest.py b/tests/conftest.py index 4c2ceeea9..3ecdfa694 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -11,9 +11,9 @@ from bs4 import BeautifulSoup from dotenv import load_dotenv from flask import Flask, url_for from notifications_python_client.errors import HTTPError -from notifications_utils.url_safe_token import generate_token from app import create_app +from notifications_utils.url_safe_token import generate_token from . import ( TestClient, diff --git a/tests/notifications_utils/__init__.py b/tests/notifications_utils/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/notifications_utils/clients/antivirus/test_antivirus_client.py b/tests/notifications_utils/clients/antivirus/test_antivirus_client.py new file mode 100644 index 000000000..a8f7a85bc --- /dev/null +++ b/tests/notifications_utils/clients/antivirus/test_antivirus_client.py @@ -0,0 +1,63 @@ +import io + +import pytest +import requests + +from notifications_utils.clients.antivirus.antivirus_client import ( + AntivirusClient, + AntivirusError, +) + + +@pytest.fixture(scope="function") +def antivirus(app, mocker): + client = AntivirusClient() + app.config["ANTIVIRUS_API_HOST"] = "https://antivirus" + app.config["ANTIVIRUS_API_KEY"] = "test-antivirus-key" + client.init_app(app) + return client + + +def test_scan_document(antivirus, rmock): + document = io.BytesIO(b"filecontents") + rmock.request( + "POST", + "https://antivirus/scan", + json={"ok": True}, + request_headers={ + "Authorization": "Bearer test-antivirus-key", + }, + status_code=200, + ) + + resp = antivirus.scan(document) + + assert resp + assert "filecontents" in rmock.last_request.text + assert document.tell() == 0 + + +def test_should_raise_for_status(antivirus, rmock): + with pytest.raises(AntivirusError) as excinfo: + rmock.request( + "POST", + "https://antivirus/scan", + json={"error": "Antivirus error"}, + status_code=400, + ) + + antivirus.scan(io.BytesIO(b"document")) + + assert excinfo.value.message == "Antivirus error" + assert excinfo.value.status_code == 400 + + +def test_should_raise_for_connection_errors(antivirus, rmock): + with pytest.raises(AntivirusError) as excinfo: + rmock.request( + "POST", "https://antivirus/scan", exc=requests.exceptions.ConnectTimeout + ) + antivirus.scan(io.BytesIO(b"document")) + + assert excinfo.value.message == "connection error" + assert excinfo.value.status_code == 503 diff --git a/tests/notifications_utils/clients/encryption/test_encryption_client.py b/tests/notifications_utils/clients/encryption/test_encryption_client.py new file mode 100644 index 000000000..c392ba529 --- /dev/null +++ b/tests/notifications_utils/clients/encryption/test_encryption_client.py @@ -0,0 +1,88 @@ +import pytest + +from notifications_utils.clients.encryption.encryption_client import ( + Encryption, + EncryptionError, +) + + +@pytest.fixture() +def encryption_client(app): + client = Encryption() + + app.config["SECRET_KEY"] = "test-notify-secret-key" + app.config["DANGEROUS_SALT"] = "test-notify-salt" + + client.init_app(app) + + return client + + +def test_should_ensure_shared_salt_security(app): + client = Encryption() + app.config["SECRET_KEY"] = "test-notify-secret-key" + app.config["DANGEROUS_SALT"] = "too-short" + with pytest.raises(EncryptionError): + client.init_app(app) + + +def test_should_ensure_custom_salt_security(encryption_client): + with pytest.raises(EncryptionError): + encryption_client.encrypt("this", salt="too-short") + + +def test_should_encrypt_strings(encryption_client): + encrypted = encryption_client.encrypt("this") + assert encrypted != "this" + assert isinstance(encrypted, str) + + +def test_should_encrypt_dicts(encryption_client): + to_encrypt = {"hello": "world"} + encrypted = encryption_client.encrypt(to_encrypt) + assert encrypted != to_encrypt + assert encryption_client.decrypt(encrypted) == to_encrypt + + +def test_encryption_is_nondeterministic(encryption_client): + first_run = encryption_client.encrypt("this") + second_run = encryption_client.encrypt("this") + assert first_run != second_run + + +def test_should_decrypt_content(encryption_client): + encrypted = encryption_client.encrypt("this") + assert encryption_client.decrypt(encrypted) == "this" + + +def test_should_decrypt_content_with_custom_salt(encryption_client): + salt = "different-salt-value" + encrypted = encryption_client.encrypt("this", salt=salt) + assert encryption_client.decrypt(encrypted, salt=salt) == "this" + + +def test_should_verify_decryption(encryption_client): + encrypted = encryption_client.encrypt("this") + with pytest.raises(EncryptionError): + encryption_client.decrypt(encrypted, salt="different-salt-value") + + +def test_should_sign_and_serialize_string(encryption_client): + signed = encryption_client.sign("this") + assert signed != "this" + + +def test_should_verify_signature_and_deserialize_string(encryption_client): + signed = encryption_client.sign("this") + assert encryption_client.verify_signature(signed) == "this" + + +def test_should_raise_encryption_error_on_bad_salt(encryption_client): + signed = encryption_client.sign("this") + with pytest.raises(EncryptionError): + encryption_client.verify_signature(signed, salt="different-salt-value") + + +def test_should_sign_and_serialize_json(encryption_client): + signed = encryption_client.sign({"this": "that"}) + assert encryption_client.verify_signature(signed) == {"this": "that"} diff --git a/tests/notifications_utils/clients/redis/test_redis_client.py b/tests/notifications_utils/clients/redis/test_redis_client.py new file mode 100644 index 000000000..1f122206b --- /dev/null +++ b/tests/notifications_utils/clients/redis/test_redis_client.py @@ -0,0 +1,221 @@ +import uuid +from datetime import datetime +from unittest.mock import Mock, call + +import pytest +from freezegun import freeze_time + +from notifications_utils.clients.redis.redis_client import RedisClient, prepare_value + + +@pytest.fixture(scope="function") +def mocked_redis_pipeline(): + return Mock() + + +@pytest.fixture +def delete_mock(): + return Mock(return_value=4) + + +@pytest.fixture(scope="function") +def mocked_redis_client(app, mocked_redis_pipeline, delete_mock, mocker): + app.config["REDIS_ENABLED"] = True + + redis_client = RedisClient() + redis_client.init_app(app) + + mocker.patch.object(redis_client.redis_store, "get", return_value=100) + mocker.patch.object(redis_client.redis_store, "set") + mocker.patch.object(redis_client.redis_store, "incr") + mocker.patch.object(redis_client.redis_store, "delete") + mocker.patch.object( + redis_client.redis_store, "pipeline", return_value=mocked_redis_pipeline + ) + + mocker.patch.object( + redis_client, "scripts", {"delete-keys-by-pattern": delete_mock} + ) + + mocker.patch.object( + redis_client.redis_store, + "hgetall", + return_value={b"template-1111": b"8", b"template-2222": b"8"}, + ) + + return redis_client + + +@pytest.fixture +def failing_redis_client(mocked_redis_client, delete_mock): + mocked_redis_client.redis_store.get.side_effect = Exception("get failed") + mocked_redis_client.redis_store.set.side_effect = Exception("set failed") + mocked_redis_client.redis_store.incr.side_effect = Exception("incr failed") + mocked_redis_client.redis_store.pipeline.side_effect = Exception("pipeline failed") + mocked_redis_client.redis_store.delete.side_effect = Exception("delete failed") + delete_mock.side_effect = Exception("delete by pattern failed") + return mocked_redis_client + + +def test_should_not_raise_exception_if_raise_set_to_false( + app, caplog, failing_redis_client, mocker +): + mock_logger = mocker.patch("flask.Flask.logger") + + assert failing_redis_client.get("get_key") is None + assert failing_redis_client.set("set_key", "set_value") is None + assert failing_redis_client.incr("incr_key") is None + assert failing_redis_client.exceeded_rate_limit("rate_limit_key", 100, 100) is False + assert failing_redis_client.delete("delete_key") is None + assert failing_redis_client.delete("a", "b", "c") is None + assert failing_redis_client.delete_by_pattern("pattern") == 0 + + assert mock_logger.mock_calls == [ + call.exception("Redis error performing get on get_key"), + call.exception("Redis error performing set on set_key"), + call.exception("Redis error performing incr on incr_key"), + call.exception("Redis error performing rate-limit-pipeline on rate_limit_key"), + call.exception("Redis error performing delete on delete_key"), + call.exception("Redis error performing delete on a, b, c"), + call.exception("Redis error performing delete-by-pattern on pattern"), + ] + + +def test_should_raise_exception_if_raise_set_to_true( + app, + failing_redis_client, +): + with pytest.raises(Exception) as e: + failing_redis_client.get("test", raise_exception=True) + assert str(e.value) == "get failed" + + with pytest.raises(Exception) as e: + failing_redis_client.set("test", "test", raise_exception=True) + assert str(e.value) == "set failed" + + with pytest.raises(Exception) as e: + failing_redis_client.incr("test", raise_exception=True) + assert str(e.value) == "incr failed" + + with pytest.raises(Exception) as e: + failing_redis_client.exceeded_rate_limit("test", 100, 200, raise_exception=True) + assert str(e.value) == "pipeline failed" + + with pytest.raises(Exception) as e: + failing_redis_client.delete("test", raise_exception=True) + assert str(e.value) == "delete failed" + + with pytest.raises(Exception) as e: + failing_redis_client.delete_by_pattern("pattern", raise_exception=True) + assert str(e.value) == "delete by pattern failed" + + +def test_should_not_call_if_not_enabled(mocked_redis_client, delete_mock): + mocked_redis_client.active = False + + assert mocked_redis_client.get("get_key") is None + assert mocked_redis_client.set("set_key", "set_value") is None + assert mocked_redis_client.incr("incr_key") is None + assert mocked_redis_client.exceeded_rate_limit("rate_limit_key", 100, 100) is False + assert mocked_redis_client.delete("delete_key") is None + assert mocked_redis_client.delete_by_pattern("pattern") == 0 + + mocked_redis_client.redis_store.get.assert_not_called() + mocked_redis_client.redis_store.set.assert_not_called() + mocked_redis_client.redis_store.incr.assert_not_called() + mocked_redis_client.redis_store.delete.assert_not_called() + mocked_redis_client.redis_store.pipeline.assert_not_called() + delete_mock.assert_not_called() + + +def test_should_call_set_if_enabled(mocked_redis_client): + mocked_redis_client.set("key", "value") + mocked_redis_client.redis_store.set.assert_called_with( + "key", "value", None, None, False, False + ) + + +def test_should_call_get_if_enabled(mocked_redis_client): + assert mocked_redis_client.get("key") == 100 + mocked_redis_client.redis_store.get.assert_called_with("key") + + +@freeze_time("2001-01-01 12:00:00.000000") +def test_exceeded_rate_limit_should_add_correct_calls_to_the_pipe( + mocked_redis_client, mocked_redis_pipeline +): + mocked_redis_client.exceeded_rate_limit("key", 100, 100) + assert mocked_redis_client.redis_store.pipeline.called + mocked_redis_pipeline.zadd.assert_called_with("key", {978350400.0: 978350400.0}) + mocked_redis_pipeline.zremrangebyscore.assert_called_with( + "key", "-inf", 978350300.0 + ) + mocked_redis_pipeline.zcard.assert_called_with("key") + mocked_redis_pipeline.expire.assert_called_with("key", 100) + assert mocked_redis_pipeline.execute.called + + +@freeze_time("2001-01-01 12:00:00.000000") +def test_exceeded_rate_limit_should_fail_request_if_over_limit( + mocked_redis_client, mocked_redis_pipeline +): + mocked_redis_pipeline.execute.return_value = [True, True, 100, True] + assert mocked_redis_client.exceeded_rate_limit("key", 99, 100) + + +@freeze_time("2001-01-01 12:00:00.000000") +def test_exceeded_rate_limit_should_allow_request_if_not_over_limit( + mocked_redis_client, mocked_redis_pipeline +): + mocked_redis_pipeline.execute.return_value = [True, True, 100, True] + assert not mocked_redis_client.exceeded_rate_limit("key", 101, 100) + + +@freeze_time("2001-01-01 12:00:00.000000") +def test_exceeded_rate_limit_not_exceeded(mocked_redis_client, mocked_redis_pipeline): + mocked_redis_pipeline.execute.return_value = [True, True, 80, True] + assert not mocked_redis_client.exceeded_rate_limit("key", 90, 100) + + +def test_exceeded_rate_limit_should_not_call_if_not_enabled( + mocked_redis_client, mocked_redis_pipeline +): + mocked_redis_client.active = False + + assert not mocked_redis_client.exceeded_rate_limit("key", 100, 100) + assert not mocked_redis_client.redis_store.pipeline.called + + +def test_delete(mocked_redis_client): + key = "hash-key" + mocked_redis_client.delete(key) + mocked_redis_client.redis_store.delete.assert_called_with(key) + + +def test_delete_multi(mocked_redis_client): + mocked_redis_client.delete("a", "b", "c") + mocked_redis_client.redis_store.delete.assert_called_with("a", "b", "c") + + +@pytest.mark.parametrize( + "input,output", + [ + (b"asdf", b"asdf"), + ("asdf", "asdf"), + (0, 0), + (1.2, 1.2), + (uuid.UUID(int=0), "00000000-0000-0000-0000-000000000000"), + pytest.param({"a": 1}, None, marks=pytest.mark.xfail(raises=ValueError)), + pytest.param( + datetime.utcnow(), None, marks=pytest.mark.xfail(raises=ValueError) + ), + ], +) +def test_prepare_value(input, output): + assert prepare_value(input) == output + + +def test_delete_by_pattern(mocked_redis_client, delete_mock): + ret = mocked_redis_client.delete_by_pattern("foo") + assert ret == 4 + delete_mock.assert_called_once_with(args=["foo"]) diff --git a/tests/notifications_utils/clients/redis/test_request_cache.py b/tests/notifications_utils/clients/redis/test_request_cache.py new file mode 100644 index 000000000..876c4a976 --- /dev/null +++ b/tests/notifications_utils/clients/redis/test_request_cache.py @@ -0,0 +1,190 @@ +import pytest + +from notifications_utils.clients.redis import RequestCache +from notifications_utils.clients.redis.redis_client import RedisClient + + +@pytest.fixture(scope="function") +def mocked_redis_client(app): + app.config["REDIS_ENABLED"] = True + redis_client = RedisClient() + redis_client.init_app(app) + return redis_client + + +@pytest.fixture +def cache(mocked_redis_client): + return RequestCache(mocked_redis_client) + + +@pytest.mark.parametrize( + "args, kwargs, expected_cache_key", + ( + ([1, 2, 3], {}, "1-2-3-None-None-None"), + ([1, 2, 3, 4, 5, 6], {}, "1-2-3-4-5-6"), + ([1, 2, 3], {"x": 4, "y": 5, "z": 6}, "1-2-3-4-5-6"), + ([1, 2, 3, 4], {"y": 5}, "1-2-3-4-5-None"), + ), +) +def test_set( + mocker, + mocked_redis_client, + cache, + args, + kwargs, + expected_cache_key, +): + mock_redis_set = mocker.patch.object( + mocked_redis_client, + "set", + ) + mock_redis_get = mocker.patch.object( + mocked_redis_client, + "get", + return_value=None, + ) + + @cache.set("{a}-{b}-{c}-{x}-{y}-{z}") + def foo(a, b, c, x=None, y=None, z=None): + return "bar" + + assert foo(*args, **kwargs) == "bar" + + mock_redis_get.assert_called_once_with(expected_cache_key) + + mock_redis_set.assert_called_once_with( + expected_cache_key, + '"bar"', + ex=604_800, + ) + + +@pytest.mark.parametrize( + "cache_set_call, expected_redis_client_ttl", + ( + (0, 0), + (1, 1), + (1.111, 1), + ("2000", 2_000), + ), +) +def test_set_with_custom_ttl( + mocker, + mocked_redis_client, + cache, + cache_set_call, + expected_redis_client_ttl, +): + mock_redis_set = mocker.patch.object( + mocked_redis_client, + "set", + ) + mocker.patch.object( + mocked_redis_client, + "get", + return_value=None, + ) + + @cache.set("foo", ttl_in_seconds=cache_set_call) + def foo(): + return "bar" + + foo() + + mock_redis_set.assert_called_once_with( + "foo", + '"bar"', + ex=expected_redis_client_ttl, + ) + + +def test_raises_if_key_doesnt_match_arguments(cache): + @cache.set("{baz}") + def foo(bar): + pass + + with pytest.raises(KeyError): + foo(1) + + with pytest.raises(KeyError): + foo() + + +def test_get(mocker, mocked_redis_client, cache): + mock_redis_get = mocker.patch.object( + mocked_redis_client, + "get", + return_value=b'"bar"', + ) + + @cache.set("{a}-{b}-{c}") + def foo(a, b, c): + # This function should not be called because the cache has + # returned a value + raise RuntimeError + + assert foo(1, 2, 3) == "bar" + + mock_redis_get.assert_called_once_with("1-2-3") + + +def test_delete(mocker, mocked_redis_client, cache): + mock_redis_delete = mocker.patch.object( + mocked_redis_client, + "delete", + ) + + @cache.delete("{a}-{b}-{c}") + def foo(a, b, c): + return "bar" + + assert foo(1, 2, 3) == "bar" + + mock_redis_delete.assert_called_once_with("1-2-3") + + +def test_delete_even_if_call_raises(mocker, mocked_redis_client, cache): + mock_redis_delete = mocker.patch.object( + mocked_redis_client, + "delete", + ) + + @cache.delete("bar") + def foo(): + raise RuntimeError + + with pytest.raises(RuntimeError): + foo() + + mock_redis_delete.assert_called_once_with("bar") + + +def test_delete_by_pattern(mocker, mocked_redis_client, cache): + mock_redis_delete = mocker.patch.object( + mocked_redis_client, + "delete_by_pattern", + ) + + @cache.delete_by_pattern("{a}-{b}-{c}-???") + def foo(a, b, c): + return "bar" + + assert foo(1, 2, 3) == "bar" + + mock_redis_delete.assert_called_once_with("1-2-3-???") + + +def test_delete_by_pattern_even_if_call_raises(mocker, mocked_redis_client, cache): + mock_redis_delete = mocker.patch.object( + mocked_redis_client, + "delete_by_pattern", + ) + + @cache.delete_by_pattern("bar-???") + def foo(): + raise RuntimeError + + with pytest.raises(RuntimeError): + foo() + + mock_redis_delete.assert_called_once_with("bar-???") diff --git a/tests/notifications_utils/clients/test_redis.py b/tests/notifications_utils/clients/test_redis.py new file mode 100644 index 000000000..5d813b938 --- /dev/null +++ b/tests/notifications_utils/clients/test_redis.py @@ -0,0 +1,7 @@ +from notifications_utils.clients.redis import rate_limit_cache_key + + +def test_rate_limit_cache_key(sample_service): + assert rate_limit_cache_key(sample_service.id, "TEST") == "{}-TEST".format( + sample_service.id + ) diff --git a/tests/notifications_utils/clients/zendesk/test_zendesk_client.py b/tests/notifications_utils/clients/zendesk/test_zendesk_client.py new file mode 100644 index 000000000..be19c9a89 --- /dev/null +++ b/tests/notifications_utils/clients/zendesk/test_zendesk_client.py @@ -0,0 +1,220 @@ +from base64 import b64decode + +import pytest + +from notifications_utils.clients.zendesk.zendesk_client import ( + NotifySupportTicket, + ZendeskClient, + ZendeskError, +) + + +@pytest.fixture(scope="function") +def zendesk_client(app): + client = ZendeskClient() + + app.config["ZENDESK_API_KEY"] = "testkey" + + client.init_app(app) + + return client + + +def test_zendesk_client_send_ticket_to_zendesk(zendesk_client, app, mocker, rmock): + rmock.request( + "POST", + ZendeskClient.ZENDESK_TICKET_URL, + status_code=201, + json={ + "ticket": { + "id": 12345, + "subject": "Something is wrong", + } + }, + ) + mock_logger = mocker.patch.object(app.logger, "info") + + ticket = NotifySupportTicket("subject", "message", "incident") + zendesk_client.send_ticket_to_zendesk(ticket) + + assert rmock.last_request.headers["Authorization"][:6] == "Basic " + b64_auth = rmock.last_request.headers["Authorization"][6:] + assert ( + b64decode(b64_auth.encode()).decode() + == "zd-api-notify@digital.cabinet-office.gov.uk/token:testkey" + ) + assert rmock.last_request.json() == ticket.request_data + mock_logger.assert_called_once_with("Zendesk create ticket 12345 succeeded") + + +def test_zendesk_client_send_ticket_to_zendesk_error( + zendesk_client, app, mocker, rmock +): + rmock.request( + "POST", ZendeskClient.ZENDESK_TICKET_URL, status_code=401, json={"foo": "bar"} + ) + + mock_logger = mocker.patch.object(app.logger, "error") + + ticket = NotifySupportTicket("subject", "message", "incident") + + with pytest.raises(ZendeskError): + zendesk_client.send_ticket_to_zendesk(ticket) + + mock_logger.assert_called_with( + "Zendesk create ticket request failed with 401 '{'foo': 'bar'}'" + ) + + +@pytest.mark.parametrize( + "p1_arg, expected_tags, expected_priority", + ( + ( + {}, + ["govuk_notify_support"], + "normal", + ), + ( + { + "p1": False, + }, + ["govuk_notify_support"], + "normal", + ), + ( + { + "p1": True, + }, + ["govuk_notify_emergency"], + "urgent", + ), + ), +) +def test_notify_support_ticket_request_data(p1_arg, expected_tags, expected_priority): + notify_ticket_form = NotifySupportTicket("subject", "message", "question", **p1_arg) + + assert notify_ticket_form.request_data == { + "ticket": { + "subject": "subject", + "comment": { + "body": "message", + "public": True, + }, + "group_id": NotifySupportTicket.NOTIFY_GROUP_ID, + "organization_id": NotifySupportTicket.NOTIFY_ORG_ID, + "ticket_form_id": NotifySupportTicket.NOTIFY_TICKET_FORM_ID, + "priority": expected_priority, + "tags": expected_tags, + "type": "question", + "custom_fields": [ + {"id": "1900000744994", "value": "notify_ticket_type_non_technical"}, + {"id": "360022836500", "value": []}, + {"id": "360022943959", "value": None}, + {"id": "360022943979", "value": None}, + {"id": "1900000745014", "value": None}, + ], + } + } + + +def test_notify_support_ticket_request_data_with_message_hidden_from_requester(): + notify_ticket_form = NotifySupportTicket( + "subject", "message", "problem", requester_sees_message_content=False + ) + + assert notify_ticket_form.request_data["ticket"]["comment"]["public"] is False + + +@pytest.mark.parametrize( + "name, zendesk_name", [("Name", "Name"), (None, "(no name supplied)")] +) +def test_notify_support_ticket_request_data_with_user_name_and_email( + name, zendesk_name +): + notify_ticket_form = NotifySupportTicket( + "subject", "message", "question", user_name=name, user_email="user@example.com" + ) + + assert ( + notify_ticket_form.request_data["ticket"]["requester"]["email"] + == "user@example.com" + ) + assert ( + notify_ticket_form.request_data["ticket"]["requester"]["name"] == zendesk_name + ) + + +@pytest.mark.parametrize( + "custom_fields, tech_ticket_tag, categories, org_id, org_type, service_id", + [ + ( + {"technical_ticket": True}, + "notify_ticket_type_technical", + [], + None, + None, + None, + ), + ( + {"technical_ticket": False}, + "notify_ticket_type_non_technical", + [], + None, + None, + None, + ), + ( + {"ticket_categories": ["notify_billing", "notify_bug"]}, + "notify_ticket_type_non_technical", + ["notify_billing", "notify_bug"], + None, + None, + None, + ), + ( + {"org_id": "1234", "org_type": "local"}, + "notify_ticket_type_non_technical", + [], + "1234", + "notify_org_type_local", + None, + ), + ( + {"service_id": "abcd", "org_type": "nhs"}, + "notify_ticket_type_non_technical", + [], + None, + "notify_org_type_nhs", + "abcd", + ), + ], +) +def test_notify_support_ticket_request_data_custom_fields( + custom_fields, + tech_ticket_tag, + categories, + org_id, + org_type, + service_id, +): + notify_ticket_form = NotifySupportTicket( + "subject", "message", "question", **custom_fields + ) + + assert notify_ticket_form.request_data["ticket"]["custom_fields"] == [ + {"id": "1900000744994", "value": tech_ticket_tag}, + {"id": "360022836500", "value": categories}, + {"id": "360022943959", "value": org_id}, + {"id": "360022943979", "value": org_type}, + {"id": "1900000745014", "value": service_id}, + ] + + +def test_notify_support_ticket_request_data_email_ccs(): + notify_ticket_form = NotifySupportTicket( + "subject", "message", "question", email_ccs=["someone@example.com"] + ) + + assert notify_ticket_form.request_data["ticket"]["email_ccs"] == [ + {"user_email": "someone@example.com", "action": "put"}, + ] diff --git a/tests/notifications_utils/conftest.py b/tests/notifications_utils/conftest.py new file mode 100644 index 000000000..7150b1486 --- /dev/null +++ b/tests/notifications_utils/conftest.py @@ -0,0 +1,45 @@ +import pytest +import requests_mock +from flask import Flask + +from notifications_utils import request_helper + + +class FakeService: + id = "1234" + + +@pytest.fixture +def app(): + flask_app = Flask(__name__) + ctx = flask_app.app_context() + ctx.push() + + yield flask_app + + ctx.pop() + + +@pytest.fixture +def celery_app(mocker): + app = Flask(__name__) + app.config["CELERY"] = {"broker_url": "foo"} + app.config["NOTIFY_TRACE_ID_HEADER"] = "Ex-Notify-Request-Id" + request_helper.init_app(app) + + ctx = app.app_context() + ctx.push() + + yield app + ctx.pop() + + +@pytest.fixture(scope="session") +def sample_service(): + return FakeService() + + +@pytest.fixture +def rmock(): + with requests_mock.mock() as rmock: + yield rmock diff --git a/tests/notifications_utils/country_synonyms.py b/tests/notifications_utils/country_synonyms.py new file mode 100644 index 000000000..0c035a4c2 --- /dev/null +++ b/tests/notifications_utils/country_synonyms.py @@ -0,0 +1,1937 @@ +ALL = ( + ("al'iraq", "Iraq"), + ("aljazā'ir", "Algeria"), + ("amelikahuipu'ia", "United States"), + ("cote d'ivoire", "Ivory Coast"), + ("coted'ivoire", "Ivory Coast"), + ("democratic people's republic of koread", "North Korea"), + ("democraticpeople'srepublicofkoread", "North Korea"), + ("ityop'ia", "Ethiopia"), + ("lao people's democratic republic", "Laos"), + ("laopeople'sdemocraticrepublic", "Laos"), + ("o'zbekstan", "Uzbekistan"), + ("people's democratic republic of algeriaalgerie", "Algeria"), + ("people's republic of bangladesh", "Bangladesh"), + ("people's republic of china", "China"), + ("people'sdemocraticrepublicofalgeriaalgerie", "Algeria"), + ("people'srepublicofbangladesh", "Bangladesh"), + ("people'srepublicofchina", "China"), + ("republic of cote d'ivoire", "Ivory Coast"), + ("republicofcoted'ivoire", "Ivory Coast"), + ("sak'art'velo", "Georgia"), + ("socialist people's libyan arab", "Libya"), + ("socialistpeople'slibyanarab", "Libya"), + ("the democratic people's republic of korea", "North Korea"), + ("the lao people's democratic republic", "Laos"), + ("the people's democratic republic of algeria", "Algeria"), + ("the people's republic of bangladesh", "Bangladesh"), + ("the people's republic of china", "China"), + ("the republic of cote d'ivoire", "Ivory Coast"), + ("thedemocraticpeople'srepublicofkorea", "North Korea"), + ("thelaopeople'sdemocraticrepublic", "Laos"), + ("thepeople'sdemocraticrepublicofalgeria", "Algeria"), + ("thepeople'srepublicofbangladesh", "Bangladesh"), + ("thepeople'srepublicofchina", "China"), + ("therepublicofcoted'ivoire", "Ivory Coast"), + ("timor lorosa'e", "East Timor"), + ("timorlorosa'e", "East Timor"), + ("yisra'el", "Israel"), + ("\u200e", "Iraq"), + ("aaland", "Åland Islands"), + ("abudhabi", "Abu Dhabi"), + ("abyssinia", "Ethiopia"), + ("ad", "Andorra"), + ("ae", "United Arab Emirates"), + ("aeaj", "Ajman"), + ("aeaz", "Abu Dhabi"), + ("aedu", "Dubai"), + ("aefu", "Fujairah"), + ("aerk", "Ras al-Khaimah"), + ("aeroes", "Faroe Islands"), + ("aesh", "Sharjah"), + ("aeuq", "Umm al-Quwain"), + ("af", "Afghanistan"), + ("afganastan", "Afghanistan"), + ("afganestan", "Afghanistan"), + ("afganhistan", "Afghanistan"), + ("afganistan", "Afghanistan"), + ("afghanestan", "Afghanistan"), + ("afghanlstan", "Afghanistan"), + ("afghistan", "Afghanistan"), + ("aforika borwa", "South Africa"), + ("aforikaborwa", "South Africa"), + ("afrika borwa", "South Africa"), + ("afrika dzonga", "South Africa"), + ("afrikaborwa", "South Africa"), + ("afrikadzonga", "South Africa"), + ("afurika tshipembe", "South Africa"), + ("afurikatshipembe", "South Africa"), + ("ag", "Antigua and Barbuda"), + ("agawec", "Mauritania"), + ("ahvenanmaa", "Åland Islands"), + ("ai", "Anguilla"), + ("aigeria", "Algeria"), + ("al itihaad al islamiya", "Somalia"), + ("al", "Albania"), + ("alabnia", "Albania"), + ("aland", "Åland Islands"), + ("albana", "Albania"), + ("albanian", "Albania"), + ("albanija", "Albania"), + ("albaḥrayn", "Bahrain"), + ("albenia", "Albania"), + ("albiana", "Albania"), + ("alegeria", "Algeria"), + ("algeir", "Algeria"), + ("algeirs", "Algeria"), + ("algers", "Algeria"), + ("algieria", "Algeria"), + ("algiers", "Algeria"), + ("alibania", "Albania"), + ("aliraq", "Iraq"), + ("alitihaad alislamiya", "Somalia"), + ("alitihaadalislamiya", "Somalia"), + ("almamlaka al‘arabiyyah as sa‘ūdiyyah", "Saudi Arabia"), + ("almamlakaal‘arabiyyahassa‘ūdiyyah", "Saudi Arabia"), + ("almaɣréb", "Morocco"), + ("alyaman", "Yemen"), + ("al’imārat al‘arabiyyah almuttaḥidah", "United Arab Emirates"), + ("al’imāratal‘arabiyyahalmuttaḥidah", "United Arab Emirates"), + ("al’urdun", "Jordan"), + ("am", "Armenia"), + ("ameeri", "United States"), + ("ameica", "United States"), + ("amerca", "United States"), + ("amercia", "United States"), + ("ameria", "United States"), + ("america", "United States"), + ("american virgin islands", "United States Virgin Islands"), + ("americansamoa", "American Samoa"), + ("americanvirginislands", "United States Virgin Islands"), + ("americia", "United States"), + ("amerika sāmoa", "Samoa"), + ("amerikasāmoa", "Samoa"), + ("amerruk", "Morocco"), + ("amrica", "United States"), + ("angolo", "Angola"), + ("anmerica", "United States"), + ("annam", "Vietnam"), + ("antiguaandbarbuda", "Antigua and Barbuda"), + ("ao", "Angola"), + ("aorōkin m̧ajeļ", "Marshall Islands"), + ("aorōkinm̧ajeļ", "Marshall Islands"), + ("aotearoa", "New Zealand"), + ("aq", "Antarctica"), + ("ar", "Argentina"), + ("arab emir ates", "United Arab Emirates"), + ("arab emirates", "United Arab Emirates"), + ("arab republic of egypt", "Egypt"), + ("arabemirates", "United Arab Emirates"), + ("arabrepublicofegypt", "Egypt"), + ("argenina", "Argentina"), + ("argentiha", "Argentina"), + ("argentine republic", "Argentina"), + ("argentine", "Argentina"), + ("argentinerepublic", "Argentina"), + ("argentinia", "Argentina"), + ("argentna", "Argentina"), + ("arima", "Armenia"), + ("arminia", "Armenia"), + ("as", "American Samoa"), + ("ascension island", "Ascension"), + ("ascensionisland", "Ascension"), + ("assudan", "Sudan"), + ("at", "Austria"), + ("au", "Australia"), + ("ausralia", "Australia"), + ("austalia", "Australia"), + ("austraila", "Australia"), + ("austrailia", "Australia"), + ("australa", "Australia"), + ("australla", "Australia"), + ("austrilia", "Australia"), + ("austrlia", "Australia"), + ("autralia", "Australia"), + ("avstralia", "Australia"), + ("avstria", "Australia"), + ("aw", "Aruba"), + ("ax", "Åland Islands"), + ("ayiti", "Haiti"), + ("az", "Azerbaijan"), + ("azebaijan", "Azerbaijan"), + ("azeraijan", "Azerbaijan"), + ("azerbaijani republic", "Azerbaijan"), + ("azerbaijanirepublic", "Azerbaijan"), + ("azerbaijann", "Azerbaijan"), + ("azerbaisan", "Azerbaijan"), + ("azerbaizan", "Azerbaijan"), + ("azerbajan", "Azerbaijan"), + ("azerbajdzhan republic", "Azerbaijan"), + ("azerbajdzhan", "Azerbaijan"), + ("azerbajdzhanrepublic", "Azerbaijan"), + ("azerbaycan", "Azerbaijan"), + ("azerbayjan", "Azerbaijan"), + ("azerbeyjan", "Azerbaijan"), + ("azerbpijan", "Azerbaijan"), + ("azərbaycan", "Azerbaijan"), + ("aşşūmāl", "Somalia"), + ("ba", "Bosnia and Herzegovina"), + ("bailiwick of guernsey", "United Kingdom"), + ("bailiwick of jersey", "United Kingdom"), + ("bailiwickofguernsey", "United Kingdom"), + ("bailiwickofjersey", "United Kingdom"), + ("bakerisland", "Baker Island"), + ("bangla desh", "Bangladesh"), + ("basutoland", "Lesotho"), + ("bat", "British Antarctic Territory"), + ("bb", "Barbados"), + ("bd", "Bangladesh"), + ("be", "Belgium"), + ("belau", "Palau"), + ("belgie", "Belgium"), + ("belgien", "Belgium"), + ("belgique", "Belgium"), + ("belgië", "Belgium"), + ("belguim", "Belgium"), + ("bermudas", "Bermuda"), + ("bf", "Burkina Faso"), + ("bg", "Bulgaria"), + ("bh", "Bahrain"), + ("bharat", "India"), + ("bharôt", "India"), + ("bharôtô", "India"), + ("bhārat", "India"), + ("bhārata", "India"), + ("bhāratadēsam", "India"), + ("bhāratam", "India"), + ("bi", "Burundi"), + ("bielaruś", "Belarus"), + ("bih", "Bosnia and Herzegovina"), + ("bj", "Benin"), + ("bl", "Saint Barthélemy"), + ("bm", "Bermuda"), + ("bn", "Brunei"), + ("bo", "Bolivia"), + ("bolivarian republic of venezuela", "Venezuela"), + ("bolivarianrepublicofvenezuela", "Venezuela"), + ("bosna i hercegovina", "Bosnia and Herzegovina"), + ("bosnaihercegovina", "Bosnia and Herzegovina"), + ("bosniaandherzegovina", "Bosnia and Herzegovina"), + ("bosniaherzegovina", "Bosnia and Herzegovina"), + ("bouvetisland", "Bouvet Island"), + ("bqbo", "Bonaire"), + ("bqsa", "Saba"), + ("bqse", "Sint Eustatius"), + ("br", "Brazil"), + ("brasil", "Brazil"), + ("brazzaville", "Congo"), + ("british guiana", "Guyana"), + ("british honduras", "Belize"), + ("britishantarcticterritory", "British Antarctic Territory"), + ("britishguiana", "Guyana"), + ("britishhonduras", "Belize"), + ("britishindianoceanterritory", "British Indian Ocean Territory"), + ("britishvirginislands", "British Virgin Islands"), + ("brunei darussalam", "Brunei"), + ("bruneidarussalam", "Brunei"), + ("bs", "The Bahamas"), + ("bt", "Bhutan"), + ("bugaria", "Bulgaria"), + ("bukchosŏn", "North Korea"), + ("bulagar", "Bulgaria"), + ("bulgariya", "Bulgaria"), + ("buliwya", "Bolivia"), + ("bundesrepublik", "Germany"), + ("burkina fasoupper", "Burkina Faso"), + ("burkinafaso", "Burkina Faso"), + ("burkinafasoupper", "Burkina Faso"), + ("bv", "Bouvet Island"), + ("bvi", "British Virgin Islands"), + ("bw", "Botswana"), + ("by", "Belarus"), + ("byelarus", "Belarus"), + ("byelorussia", "Belarus"), + ("bz", "Belize"), + ("bārata", "India"), + ("bălgarija", "Bulgaria"), + ("ca", "Canada"), + ("cabo verde", "Cape Verde"), + ("cabo", "Cape Verde"), + ("caboverde", "Cape Verde"), + ("cameroun", "Cameroon"), + ("canadaigua", "Canada"), + ("candada", "Canada"), + ("capeverde", "Cape Verde"), + ("car", "Central African Republic"), + ("cathay", "China"), + ("caymanislands", "Cayman Islands"), + ("cc", "Cocos (Keeling) Islands"), + ("cd", "Congo (Democratic Republic)"), + ("central africa", "Central African Republic"), + ("centralafrica", "Central African Republic"), + ("centralafricanrepublic", "Central African Republic"), + ("ceska", "Czechia"), + ("ceylon", "Sri Lanka"), + ("cf", "Central African Republic"), + ("cg", "Congo"), + ("ch", "Switzerland"), + ("chinese taipei", "Taiwan"), + ("chinesetaipei", "Taiwan"), + ("christmasisland", "Christmas Island"), + ("ci", "Ivory Coast"), + ("citta del vaticano", "Vatican City"), + ("cittadelvaticano", "Vatican City"), + ("ck", "Cook Islands"), + ("cl", "Chile"), + ("cm", "Cameroon"), + ("cn", "China"), + ("co", "Colombia"), + ("coasta rica", "Costa Rica"), + ("coastarica", "Costa Rica"), + ("cocos(keeling)islands", "Cocos (Keeling) Islands"), + ("collectivity of saint martin", "Saint-Martin (French part)"), + ("collectivityofsaintmartin", "Saint-Martin (French part)"), + ("commonwealth of australia", "Australia"), + ("commonwealth of bahamas", "The Bahamas"), + ("commonwealth of dominica", "Dominica"), + ("commonwealth of puerto rico", "Puerto Rico"), + ("commonwealth of the northern mariana islands", "Northern Mariana Islands"), + ("commonwealthofaustralia", "Australia"), + ("commonwealthofbahamas", "The Bahamas"), + ("commonwealthofdominica", "Dominica"), + ("commonwealthofpuertorico", "Puerto Rico"), + ("commonwealthofthenorthernmarianaislands", "Northern Mariana Islands"), + ("comores", "Comoros"), + ("congo(democraticrepublic)", "Congo (Democratic Republic)"), + ("congobrazzaville", "Congo (Democratic Republic)"), + ("cookislands", "Cook Islands"), + ("cooperative republic of guyana", "Guyana"), + ("cooperativerepublicofguyana", "Guyana"), + ("costa rico", "Costa Rica"), + ("costarica", "Costa Rica"), + ("costarico", "Costa Rica"), + ("cote divoire", "Ivory Coast"), + ("cotedivoire", "Ivory Coast"), + ("country of curaçao", "Curaçao"), + ("countryofcuraçao", "Curaçao"), + ("cr", "Costa Rica"), + ("crna gora", "Montenegro"), + ("crnagora", "Montenegro"), + ("cs", "Czechia"), + ("cu", "Cuba"), + ("curacao", "Curaçao"), + ("cv", "Cape Verde"), + ("cw", "Curaçao"), + ("cx", "Christmas Island"), + ("cy", "Cyprus"), + ("cz", "Czechia"), + ("czech republic", "Czechia"), + ("czechoslav", "Czechia"), + ("czechoslovak republic", "Czechia"), + ("czechoslovakrepublic", "Czechia"), + ("czechrepublic", "Czechia"), + ("dahomey", "Benin"), + ("danmark", "Denmark"), + ("dawlat ulkuwayt", "Kuwait"), + ("dawlatulkuwayt", "Kuwait"), + ("dd", "Germany"), + ("de", "Germany"), + ("democratic republic of sao tome and principe", "Sao Tome and Principe"), + ("democratic republic of the congo", "Congo (Democratic Republic)"), + ("democratic republic of timorlestetimor", "East Timor"), + ("democratic socialist republic of sri lanka", "Sri Lanka"), + ("democraticrepublicofsaotomeandprincipe", "Sao Tome and Principe"), + ("democraticrepublicofthecongo", "Congo (Democratic Republic)"), + ("democraticrepublicoftimorlestetimor", "East Timor"), + ("democraticsocialistrepublicofsrilanka", "Sri Lanka"), + ("deutschland", "Germany"), + ("dhivehi raajje", "Maldives"), + ("dhivehiraajje", "Maldives"), + ("dj", "Djibouti"), + ("dk", "Denmark"), + ("dm", "Dominica"), + ("do", "Dominican Republic"), + ("dominicanrepublic", "Dominican Republic"), + ("dominique", "Dominica"), + ("dprk", "North Korea"), + ("druk yul", "Bhutan"), + ("drukyul", "Bhutan"), + ("ducie and oeno islands", "Pitcairn, Henderson, Ducie and Oeno Islands"), + ("ducieandoenoislands", "Pitcairn, Henderson, Ducie and Oeno Islands"), + ("dutch east indies", "Indonesia"), + ("dutcheastindies", "Indonesia"), + ("dz", "Algeria"), + ("dzayer", "Algeria"), + ("e civitate vaticana", "Vatican City"), + ("east pakistan", "Bangladesh"), + ("eastern samoa", "American Samoa"), + ("easternsamoa", "American Samoa"), + ("eastgermany", "Germany"), + ("eastpakistan", "Bangladesh"), + ("easttimor", "East Timor"), + ("ec", "Ecuador"), + ("ecivitatevaticana", "Vatican City"), + ("ee", "Estonia"), + ("eesti", "Estonia"), + ("eg", "Egypt"), + ("egpyt", "Egypt"), + ("egyot", "Egypt"), + ("egyt", "Egypt"), + ("eh", "Western Sahara"), + ("eire", "Ireland"), + ("ellada", "Greece"), + ("ellan vannin", "United Kingdom"), + ("ellanvannin", "United Kingdom"), + ("ellas", "Greece"), + ("ellice islands", "Tuvalu"), + ("elliceislands", "Tuvalu"), + ("elmeɣrib", "Morocco"), + ("elsalvador", "El Salvador"), + ("emirate of abu dhabi", "Abu Dhabi"), + ("emirate of ajman", "Ajman"), + ("emirate of dubai", "Dubai"), + ("emirate of fujairah", "Fujairah"), + ("emirate of ras alkhaimah", "Ras al-Khaimah"), + ("emirate of sharjah", "Sharjah"), + ("emirate of umm alquwain", "Umm al-Quwain"), + ("emirateofabudhabi", "Abu Dhabi"), + ("emirateofajman", "Ajman"), + ("emirateofdubai", "Dubai"), + ("emirateoffujairah", "Fujairah"), + ("emirateofrasalkhaimah", "Ras al-Khaimah"), + ("emirateofsharjah", "Sharjah"), + ("emirateofummalquwain", "Umm al-Quwain"), + ("equatorialguinea", "Equatorial Guinea"), + ("er", "Eritrea"), + ("ertra", "Eritrea"), + ("es", "Spain"), + ("esce", "Ceuta"), + ("esml", "Melilla"), + ("espainia", "Spain"), + ("espanha", "Spain"), + ("espanya", "Spain"), + ("españa", "Spain"), + ("estados unidos", "United States"), + ("estadosunidos", "United States"), + ("esthonia", "Estonia"), + ("et", "Ethiopia"), + ("ethopi", "Ethiopia"), + ("falklandislands", "Falkland Islands"), + ("faroeislands", "Faroe Islands"), + ("faroes", "Faroe Islands"), + ("federal democratic republic of ethiopia", "Ethiopia"), + ("federal democratic republic of nepal", "Nepal"), + ("federal islamic republic of the comoros", "Comoros"), + ("federal republic of germany", "Germany"), + ("federal republic of nigeria", "Nigeria"), + ("federal republic of somalia", "Somalia"), + ("federal republic of somaliaaiai", "Somalia"), + ("federaldemocraticrepublicofethiopia", "Ethiopia"), + ("federaldemocraticrepublicofnepal", "Nepal"), + ("federalislamicrepublicofthecomoros", "Comoros"), + ("federalrepublicofgermany", "Germany"), + ("federalrepublicofnigeria", "Nigeria"), + ("federalrepublicofsomalia", "Somalia"), + ("federalrepublicofsomaliaaiai", "Somalia"), + ("federated states of micronesia", "Micronesia"), + ("federatedstatesofmicronesia", "Micronesia"), + ("federation of malaysia", "Malaysia"), + ("federation of saint christopher and nevis", "St Kitts and Nevis"), + ("federationofmalaysia", "Malaysia"), + ("federationofsaintchristopherandnevis", "St Kitts and Nevis"), + ("federative republic of brazil", "Brazil"), + ("federativerepublicofbrazil", "Brazil"), + ("fi", "Finland"), + ("fj", "Fiji"), + ("fk", "Falkland Islands"), + ("fm", "Micronesia"), + ("fo", "Faroe Islands"), + ("fr", "France"), + ("french congo", "Congo"), + ("french guinea", "Guinea"), + ("french oceania", "French Polynesia"), + ("french republic", "France"), + ("french sudan", "Mali"), + ("frenchcongo", "Congo"), + ("frenchguiana", "French Guiana"), + ("frenchguinea", "Guinea"), + ("frenchoceania", "French Polynesia"), + ("frenchpolynesia", "French Polynesia"), + ("frenchrepublic", "France"), + ("frenchsouthernterritories", "French Southern Territories"), + ("frenchsudan", "Mali"), + ("frg", "Germany"), + ("friendly islands", "Tonga"), + ("friendlyislands", "Tonga"), + ("færøerne", "Faroe Islands"), + ("føroyar", "Faroe Islands"), + ("ga", "Gabon"), + ("gabonese republic", "Gabon"), + ("gaboneserepublic", "Gabon"), + ("gabun", "Gabon"), + ("gabuuti", "Djibouti"), + ("gd", "Grenada"), + ("ge", "Georgia"), + ("genus argentina", "Argentina"), + ("genusargentina", "Argentina"), + ("germany democratic republic", "Germany"), + ("germanydemocraticrepublic", "Germany"), + ("gf", "French Guiana"), + ("gg", "United Kingdom"), + ("gh", "Ghana"), + ("gi", "Gibraltar"), + ("gine", "Guinea"), + ("gl", "Greenland"), + ("gm", "The Gambia"), + ("gn", "Guinea"), + ("gold coast", "Ghana"), + ("goldcoast", "Ghana"), + ("gp", "Guadeloupe"), + ("gq", "Equatorial Guinea"), + ("gr", "Greece"), + ("grand duchy of luxembourg", "Luxembourg"), + ("grandduchyofluxembourg", "Luxembourg"), + ("gronland", "Greenland"), + ("grønland", "Greenland"), + ("gs", "South Georgia and South Sandwich Islands"), + ("gt", "Guatemala"), + ("gu", "Guam"), + ("guinea ecuatorial", "Equatorial Guinea"), + ("guineaecuatorial", "Equatorial Guinea"), + ("guinée", "Guinea"), + ("guyane", "French Guiana"), + ("guåhån", "Guam"), + ("gw", "Guinea-Bissau"), + ("gy", "Guyana"), + ("hanguk", "South Korea"), + ("hashemite kingdom of jordan", "Jordan"), + ("hashemitekingdomofjordan", "Jordan"), + ("hayastan", "Armenia"), + ("hayastán", "Armenia"), + ("haïti", "Haiti"), + ("heardislandandmcdonaldislands", "Heard Island and McDonald Islands"), + ("hellas", "Greece"), + ("hellenic republic", "Greece"), + ("hellenicrepublic", "Greece"), + ("henderson", "Pitcairn, Henderson, Ducie and Oeno Islands"), + ("heung gong", "Hong Kong"), + ("heunggong", "Hong Kong"), + ("hindustan", "India"), + ("hk", "Hong Kong"), + ("hm", "Heard Island and McDonald Islands"), + ("hn", "Honduras"), + ("holland", "Netherlands"), + ("holy see", "Vatican City"), + ("holysee", "Vatican City"), + ("hong kong special administrative region", "Hong Kong"), + ("hongkong", "Hong Kong"), + ("hongkongspecialadministrativeregion", "Hong Kong"), + ("howlandisland", "Howland Island"), + ("hr", "Croatia"), + ("hrvatska", "Croatia"), + ("ht", "Haiti"), + ("hu", "Hungary"), + ("id", "Indonesia"), + ("ie", "Ireland"), + ("il", "Israel"), + ("ilikwet", "Kuwait"), + ("im", "United Kingdom"), + ("in", "India"), + ("independent state of papua new guinea", "Papua New Guinea"), + ("independent state of samoa", "Samoa"), + ("independentstateofpapuanewguinea", "Papua New Guinea"), + ("independentstateofsamoa", "Samoa"), + ("iningizimu afrika", "South Africa"), + ("iningizimuafrika", "South Africa"), + ("io", "British Indian Ocean Territory"), + ("iot", "British Indian Ocean Territory"), + ("iq", "Iraq"), + ("ir", "Iran"), + ("irak", "Iraq"), + ("irelend", "Ireland"), + ("irish republic", "Ireland"), + ("irishrepublic", "Ireland"), + ("iritriya", "Eritrea"), + ("is", "Iceland"), + ("isewula afrika", "South Africa"), + ("isewulaafrika", "South Africa"), + ("islamic republic of afghanistan", "Afghanistan"), + ("islamic republic of gambia", "The Gambia"), + ("islamic republic of iran", "Iran"), + ("islamic republic of mauritania", "Mauritania"), + ("islamic republic of pakistan", "Pakistan"), + ("islamicrepublicofafghanistan", "Afghanistan"), + ("islamicrepublicofgambia", "The Gambia"), + ("islamicrepublicofiran", "Iran"), + ("islamicrepublicofmauritania", "Mauritania"), + ("islamicrepublicofpakistan", "Pakistan"), + ("island of guernsey", "United Kingdom"), + ("island of jersey", "United Kingdom"), + ("island", "Iceland"), + ("islandofguernsey", "United Kingdom"), + ("islandofjersey", "United Kingdom"), + ("isleofman", "United Kingdom"), + ("israʼiyl", "Israel"), + ("isreal", "Israel"), + ("it", "Italy"), + ("italia", "Italy"), + ("italian republic", "Italy"), + ("italianrepublic", "Italy"), + ("itlay", "Italy"), + ("ivorycoast", "Ivory Coast"), + ("jabuuti", "Djibouti"), + ("jamaca", "Jamaica"), + ("jamacia", "Jamaica"), + ("jamahiriya", "Libya"), + ("jarvisisland", "Jarvis Island"), + ("je", "United Kingdom"), + ("jm", "Jamaica"), + ("jo", "Jordan"), + ("johnstonatoll", "Johnston Atoll"), + ("jp", "Japan"), + ("juzur alqamar", "Comoros"), + ("juzuralqamar", "Comoros"), + ("jèrri", "Tuvalu"), + ("jībūtī", "Djibouti"), + ("kalaallit nunaat", "Greenland"), + ("kalaallitnunaat", "Greenland"), + ("kampuchea", "Cambodia"), + ("kangwane", "Eswatini"), + ("katar", "Qatar"), + ("kazakh", "Kazakhstan"), + ("kazakhstán", "Kazakhstan"), + ("kazakstan", "Kazakhstan"), + ("ke", "Kenya"), + ("kg", "Kyrgyzstan"), + ("kh", "Cambodia"), + ("ki", "Kiribati"), + ("kingdom of bahrain", "Bahrain"), + ("kingdom of belgium", "Belgium"), + ("kingdom of bhutan", "Bhutan"), + ("kingdom of cambodia", "Cambodia"), + ("kingdom of denmark", "Denmark"), + ("kingdom of eswatini", "Eswatini"), + ("kingdom of lesotho", "Lesotho"), + ("kingdom of moroccoalmagrib", "Morocco"), + ("kingdom of norway", "Norway"), + ("kingdom of saudi arabia", "Saudi Arabia"), + ("kingdom of spain", "Spain"), + ("kingdom of swaziland", "Eswatini"), + ("kingdom of sweden", "Sweden"), + ("kingdom of thailand", "Thailand"), + ("kingdom of the netherlands", "Netherlands"), + ("kingdom of tonga", "Tonga"), + ("kingdomofbahrain", "Bahrain"), + ("kingdomofbelgium", "Belgium"), + ("kingdomofbhutan", "Bhutan"), + ("kingdomofcambodia", "Cambodia"), + ("kingdomofdenmark", "Denmark"), + ("kingdomofeswatini", "Eswatini"), + ("kingdomoflesotho", "Lesotho"), + ("kingdomofmoroccoalmagrib", "Morocco"), + ("kingdomofnorway", "Norway"), + ("kingdomofsaudiarabia", "Saudi Arabia"), + ("kingdomofspain", "Spain"), + ("kingdomofswaziland", "Eswatini"), + ("kingdomofsweden", "Sweden"), + ("kingdomofthailand", "Thailand"), + ("kingdomofthenetherlands", "Netherlands"), + ("kingdomoftonga", "Tonga"), + ("kingmanreef", "Kingman Reef"), + ("kirghizia", "Kyrgyzstan"), + ("kirghizstan", "Kyrgyzstan"), + ("kirgiz", "Kyrgyzstan"), + ("kirgizia", "Kyrgyzstan"), + ("kirgizija", "Kyrgyzstan"), + ("kirgizstan", "Kyrgyzstan"), + ("km", "Comoros"), + ("kn", "St Kitts and Nevis"), + ("komori", "Comoros"), + ("kosova", "Kosovo"), + ("koweit", "Kuwait"), + ("kp", "North Korea"), + ("kr", "South Korea"), + ("kw", "Kuwait"), + ("ky", "Cayman Islands"), + ("kypros", "Cyprus"), + ("kyrgyz republic", "Kyrgyzstan"), + ("kyrgyz republickirghiz", "Kyrgyzstan"), + ("kyrgyzrepublic", "Kyrgyzstan"), + ("kyrgyzrepublickirghiz", "Kyrgyzstan"), + ("kz", "Kazakhstan"), + ("kòrsou", "Curaçao"), + ("ködörösêse tî bêafrîka", "Central African Republic"), + ("ködörösêsetîbêafrîka", "Central African Republic"), + ("kýpros", "Cyprus"), + ("kıbrıs", "Cyprus"), + ("la", "Laos"), + ("lao", "Laos"), + ("las malvinas", "Falkland Islands"), + ("lasmalvinas", "Falkland Islands"), + ("latvija", "Latvia"), + ("latvijaz", "Latvia"), + ("lb", "Lebanon"), + ("lc", "St Lucia"), + ("lebanese republic", "Lebanon"), + ("lebaneserepublic", "Lebanon"), + ("li", "Liechtenstein"), + ("lietuva", "Lithuania"), + ("lk", "Sri Lanka"), + ("lr", "Liberia"), + ("ls", "Lesotho"), + ("lt", "Lithuania"), + ("lu", "Luxembourg"), + ("lubnān", "Lebanon"), + ("luxemborg", "Luxembourg"), + ("luxemburg", "Luxembourg"), + ("lv", "Latvia"), + ("ly", "Libya"), + ("lëtzebuerg", "Luxembourg"), + ("lībiyā", "Libya"), + ("ma", "Morocco"), + ("macao special administrative region", "Macao"), + ("macaospecialadministrativeregion", "Macao"), + ("macedon", "North Macedonia"), + ("madagasikara", "Madagascar"), + ("magyarorszag", "Hungary"), + ("magyarország", "Hungary"), + ("mainland china", "China"), + ("mainlandchina", "China"), + ("makedonija", "North Macedonia"), + ("malagasy republic", "Madagascar"), + ("malagasyrepublic", "Madagascar"), + ("malyasi", "Malaysia"), + ("malēṣiyā", "Malaysia"), + ("maroc", "Morocco"), + ("marruecos", "Morocco"), + ("marshallislands", "Marshall Islands"), + ("masr", "Egypt"), + ("maurice", "Mauritius"), + ("mauritanie", "Mauritania"), + ("mc", "Monaco"), + ("md", "Moldova"), + ("me", "Montenegro"), + ("mexcio", "Mexico"), + ("mexicanos", "Mexico"), + ("mexixo", "Mexico"), + ("mf", "Saint-Martin (French part)"), + ("mg", "Madagascar"), + ("mh", "Marshall Islands"), + ("midwayislands", "Midway Islands"), + ("misr", "Egypt"), + ("mk", "North Macedonia"), + ("ml", "Mali"), + ("mm", "Myanmar (Burma)"), + ("mn", "Mongolia"), + ("mo", "Macao"), + ("mocambique", "Mozambique"), + ("moldavia", "Moldova"), + ("mongol uls", "Mongolia"), + ("mongoluls", "Mongolia"), + ("mongγol ulus", "Mongolia"), + ("mongγolulus", "Mongolia"), + ("moris", "Mauritius"), + ("moçambique", "Mozambique"), + ("mp", "Northern Mariana Islands"), + ("mq", "Martinique"), + ("mr", "Mauritania"), + ("ms", "Montserrat"), + ("mt", "Malta"), + ("mu", "Mauritius"), + ("mueang thai", "Thailand"), + ("mueangthai", "Thailand"), + ("muritan", "Mauritania"), + ("muritaniya", "Mauritania"), + ("muscat and oman", "Oman"), + ("muscatandoman", "Oman"), + ("mv", "Maldives"), + ("mw", "Malawi"), + ("mx", "Mexico"), + ("my", "Malaysia"), + ("myanma", "Myanmar (Burma)"), + ("myanmar(burma)", "Myanmar (Burma)"), + ("mz", "Mozambique"), + ("méxico", "Mexico"), + ("mēxihco", "Mexico"), + ("mūrītānyā", "Mauritania"), + ("mǎláixīyà", "Malaysia"), + ("na", "Namibia"), + ("namhan", "South Korea"), + ("namibië", "Namibia"), + ("naoero", "Nauru"), + ("navassaisland", "Navassa Island"), + ("naíjíríà", "Nigeria"), + ("nc", "New Caledonia"), + ("ne", "Niger"), + ("nederland", "Netherlands"), + ("nederlân", "Netherlands"), + ("nepāl", "Nepal"), + ("new hebrides", "Vanuatu"), + ("newcaledonia", "New Caledonia"), + ("newhebrides", "Vanuatu"), + ("newzealand", "New Zealand"), + ("nf", "Norfolk Island"), + ("ng", "Nigeria"), + ("ngwane", "Eswatini"), + ("nihon", "Japan"), + ("nijar", "Niger"), + ("nijeriya", "Nigeria"), + ("nippon", "Japan"), + ("niuē", "Niue"), + ("nl", "Netherlands"), + ("no", "Norway"), + ("noreg", "Norway"), + ("norfolkisland", "Norfolk Island"), + ("norge", "Norway"), + ("northern marianas", "Northern Mariana Islands"), + ("northern rhodesia", "Zambia"), + ("northernmarianaislands", "Northern Mariana Islands"), + ("northernmarianas", "Northern Mariana Islands"), + ("northernrhodesia", "Zambia"), + ("northkorea", "North Korea"), + ("northmacedonia", "North Macedonia"), + ("nouvellecalédonie", "New Caledonia"), + ("np", "Nepal"), + ("nr", "Nauru"), + ("nu", "Niue"), + ("nyasaland", "Malawi"), + ("nz", "New Zealand"), + ("occupiedpalestinianterritories", "Occupied Palestinian Territories"), + ("oesterreich", "Austria"), + ("om", "Oman"), + ("oriental republic of uruguay", "Uruguay"), + ("orientalrepublicofuruguay", "Uruguay"), + ("osterreich", "Austria"), + ("outer mongolia", "Mongolia"), + ("outermongolia", "Mongolia"), + ("o‘zbekiston", "Uzbekistan"), + ("o’zbekstan", "Uzbekistan"), + ("pa", "Panama"), + ("palmyraatoll", "Palmyra Atoll"), + ("panamá", "Panama"), + ("papua niugini", "Papua New Guinea"), + ("papuanewguinea", "Papua New Guinea"), + ("papuaniugini", "Papua New Guinea"), + ("paraguái", "Paraguay"), + ("pe", "Peru"), + ("pelew", "Palau"), + ("peoples republic", "China"), + ("peoplesrepublic", "China"), + ("persia", "Iran"), + ("perú", "Peru"), + ("pf", "French Polynesia"), + ("pg", "Papua New Guinea"), + ("ph", "Philippines"), + ("philippine islands", "Philippines"), + ("philippineislands", "Philippines"), + ("phillippine", "Philippines"), + ("pilipinas", "Philippines"), + ("pinas", "Philippines"), + ("piruw", "Peru"), + ("pitcairn", "Pitcairn, Henderson, Ducie and Oeno Islands"), + ( + "pitcairn,henderson,ducieandoenoislands", + "Pitcairn, Henderson, Ducie and Oeno Islands", + ), + ("pk", "Pakistan"), + ("pl", "Poland"), + ("plurinational state of bolivia", "Bolivia"), + ("plurinationalstateofbolivia", "Bolivia"), + ("pm", "Saint Pierre and Miquelon"), + ("pn", "Pitcairn, Henderson, Ducie and Oeno Islands"), + ("png", "Papua New Guinea"), + ("polska", "Poland"), + ("polynésie française", "French Polynesia"), + ("polynésiefrançaise", "French Polynesia"), + ("porto rico", "Puerto Rico"), + ("portorico", "Puerto Rico"), + ("portuguesa", "Portugal"), + ("portuguese guinea", "Guinea-Bissau"), + ("portuguese republic", "Portugal"), + ("portugueseguinea", "Guinea-Bissau"), + ("portugueserepublic", "Portugal"), + ("pr", "Puerto Rico"), + ("prathet thai", "Thailand"), + ("prathetthai", "Thailand"), + ("prc", "China"), + ("principality of andorra", "Andorra"), + ("principality of liechtenstein", "Liechtenstein"), + ("principality of monaco", "Monaco"), + ("principalityofandorra", "Andorra"), + ("principalityofliechtenstein", "Liechtenstein"), + ("principalityofmonaco", "Monaco"), + ("prk", "North Korea"), + ("ps", "Occupied Palestinian Territories"), + ("pt", "Portugal"), + ("puarto rico", "Puerto Rico"), + ("puartorico", "Puerto Rico"), + ("puertorico", "Puerto Rico"), + ("pw", "Palau"), + ("py", "Paraguay"), + ("qa", "Qatar"), + ("qazaqstan", "Kazakhstan"), + ("rasalkhaimah", "Ras al-Khaimah"), + ("rastafari", "Jamaica"), + ("rastas", "Jamaica"), + ("ratchaanachak thai", "Thailand"), + ("ratchaanachakthai", "Thailand"), + ("re", "Réunion"), + ("rep of ireland", "Ireland"), + ("repubblica", "San Marino"), + ("repubilika ya kongo", "Congo (Democratic Republic)"), + ("repubilikayakongo", "Congo (Democratic Republic)"), + ("republic of albania", "Albania"), + ("republic of angola", "Angola"), + ("republic of armenia", "Armenia"), + ("republic of austria", "Austria"), + ("republic of azerbaijan", "Azerbaijan"), + ("republic of belarusbelorussia", "Belarus"), + ("republic of benin", "Benin"), + ("republic of bosnia and herzegovina", "Bosnia and Herzegovina"), + ("republic of botswana", "Botswana"), + ("republic of bulgaria", "Bulgaria"), + ("republic of burundi", "Burundi"), + ("republic of cabo verde", "Cape Verde"), + ("republic of cameroon", "Cameroon"), + ("republic of chad", "Chad"), + ("republic of chile", "Chile"), + ("republic of colombia", "Colombia"), + ("republic of costa rica", "Costa Rica"), + ("republic of croatia", "Croatia"), + ("republic of cuba", "Cuba"), + ("republic of cyprus", "Cyprus"), + ("republic of djibouti", "Djibouti"), + ("republic of ecuador", "Ecuador"), + ("republic of el salvador", "El Salvador"), + ("republic of equatorial guinea", "Equatorial Guinea"), + ("republic of estonia", "Estonia"), + ("republic of fiji", "Fiji"), + ("republic of finland", "Finland"), + ("republic of ghana", "Ghana"), + ("republic of guatemala", "Guatemala"), + ("republic of guinea", "Guinea"), + ("republic of guineabissau", "Guinea-Bissau"), + ("republic of haiti", "Haiti"), + ("republic of honduras", "Honduras"), + ("republic of iceland", "Iceland"), + ("republic of india", "India"), + ("republic of indonesia", "Indonesia"), + ("republic of iraq", "Iraq"), + ("republic of ireland", "Ireland"), + ("republic of kazakhstankazak", "Kazakhstan"), + ("republic of kenya", "Kenya"), + ("republic of kiribati", "Kiribati"), + ("republic of korea", "South Korea"), + ("republic of kosovo", "Kosovo"), + ("republic of latvia", "Latvia"), + ("republic of liberia", "Liberia"), + ("republic of lithuanialietuva", "Lithuania"), + ("republic of macedonia", "North Macedonia"), + ("republic of madagascar", "Madagascar"), + ("republic of malawi", "Malawi"), + ("republic of maldives", "Maldives"), + ("republic of mali", "Mali"), + ("republic of malta", "Malta"), + ("republic of mauritius", "Mauritius"), + ("republic of moldova", "Moldova"), + ("republic of mozambique", "Mozambique"), + ("republic of namibia", "Namibia"), + ("republic of nauru", "Nauru"), + ("republic of nicaragua", "Nicaragua"), + ("republic of niger", "Niger"), + ("republic of palau", "Palau"), + ("republic of panama", "Panama"), + ("republic of paraguay", "Paraguay"), + ("republic of peru", "Peru"), + ("republic of poland", "Poland"), + ("republic of rwandaruanda", "Rwanda"), + ("republic of san marino", "San Marino"), + ("republic of senegal", "Senegal"), + ("republic of serbia", "Serbia"), + ("republic of seychelles", "Seychelles"), + ("republic of sierra leone", "Sierra Leone"), + ("republic of singapore", "Singapore"), + ("republic of slovenia", "Slovenia"), + ("republic of south africa", "South Africa"), + ("republic of south sudan", "South Sudan"), + ("republic of suriname", "Suriname"), + ("republic of tajikistantadjik", "Tajikistan"), + ("republic of the congo", "Congo"), + ("republic of the marshall islands", "Marshall Islands"), + ("republic of the philippines", "Philippines"), + ("republic of the sudan", "Sudan"), + ("republic of the union of myanmar", "Myanmar (Burma)"), + ("republic of trinidad and tobago", "Trinidad and Tobago"), + ("republic of turkey", "Turkey"), + ("republic of uganda", "Uganda"), + ("republic of uzbekistan", "Uzbekistan"), + ("republic of vanuatu", "Vanuatu"), + ("republic of yemen", "Yemen"), + ("republic of zambia", "Zambia"), + ("republic of zimbabwe", "Zimbabwe"), + ("republicofalbania", "Albania"), + ("republicofangola", "Angola"), + ("republicofarmenia", "Armenia"), + ("republicofaustria", "Austria"), + ("republicofazerbaijan", "Azerbaijan"), + ("republicofbelarusbelorussia", "Belarus"), + ("republicofbenin", "Benin"), + ("republicofbosniaandherzegovina", "Bosnia and Herzegovina"), + ("republicofbotswana", "Botswana"), + ("republicofbulgaria", "Bulgaria"), + ("republicofburundi", "Burundi"), + ("republicofcaboverde", "Cape Verde"), + ("republicofcameroon", "Cameroon"), + ("republicofchad", "Chad"), + ("republicofchile", "Chile"), + ("republicofcolombia", "Colombia"), + ("republicofcostarica", "Costa Rica"), + ("republicofcroatia", "Croatia"), + ("republicofcuba", "Cuba"), + ("republicofcyprus", "Cyprus"), + ("republicofdjibouti", "Djibouti"), + ("republicofecuador", "Ecuador"), + ("republicofelsalvador", "El Salvador"), + ("republicofequatorialguinea", "Equatorial Guinea"), + ("republicofestonia", "Estonia"), + ("republicoffiji", "Fiji"), + ("republicoffinland", "Finland"), + ("republicofghana", "Ghana"), + ("republicofguatemala", "Guatemala"), + ("republicofguinea", "Guinea"), + ("republicofguineabissau", "Guinea-Bissau"), + ("republicofhaiti", "Haiti"), + ("republicofhonduras", "Honduras"), + ("republicoficeland", "Iceland"), + ("republicofindia", "India"), + ("republicofindonesia", "Indonesia"), + ("republicofiraq", "Iraq"), + ("republicofireland", "Ireland"), + ("republicofkazakhstankazak", "Kazakhstan"), + ("republicofkenya", "Kenya"), + ("republicofkiribati", "Kiribati"), + ("republicofkorea", "South Korea"), + ("republicofkosovo", "Kosovo"), + ("republicoflatvia", "Latvia"), + ("republicofliberia", "Liberia"), + ("republicoflithuanialietuva", "Lithuania"), + ("republicofmacedonia", "North Macedonia"), + ("republicofmadagascar", "Madagascar"), + ("republicofmalawi", "Malawi"), + ("republicofmaldives", "Maldives"), + ("republicofmali", "Mali"), + ("republicofmalta", "Malta"), + ("republicofmauritius", "Mauritius"), + ("republicofmoldova", "Moldova"), + ("republicofmozambique", "Mozambique"), + ("republicofnamibia", "Namibia"), + ("republicofnauru", "Nauru"), + ("republicofnicaragua", "Nicaragua"), + ("republicofniger", "Niger"), + ("republicofpalau", "Palau"), + ("republicofpanama", "Panama"), + ("republicofparaguay", "Paraguay"), + ("republicofperu", "Peru"), + ("republicofpoland", "Poland"), + ("republicofrwandaruanda", "Rwanda"), + ("republicofsanmarino", "San Marino"), + ("republicofsenegal", "Senegal"), + ("republicofserbia", "Serbia"), + ("republicofseychelles", "Seychelles"), + ("republicofsierraleone", "Sierra Leone"), + ("republicofsingapore", "Singapore"), + ("republicofslovenia", "Slovenia"), + ("republicofsouthafrica", "South Africa"), + ("republicofsouthsudan", "South Sudan"), + ("republicofsuriname", "Suriname"), + ("republicoftajikistantadjik", "Tajikistan"), + ("republicofthecongo", "Congo"), + ("republicofthemarshallislands", "Marshall Islands"), + ("republicofthephilippines", "Philippines"), + ("republicofthesudan", "Sudan"), + ("republicoftheunionofmyanmar", "Myanmar (Burma)"), + ("republicoftrinidadandtobago", "Trinidad and Tobago"), + ("republicofturkey", "Turkey"), + ("republicofuganda", "Uganda"), + ("republicofuzbekistan", "Uzbekistan"), + ("republicofvanuatu", "Vanuatu"), + ("republicofyemen", "Yemen"), + ("republicofzambia", "Zambia"), + ("republicofzimbabwe", "Zimbabwe"), + ("república dominicana", "Dominican Republic"), + ("república oriental del uruguay", "Uruguay"), + ("repúblicadominicana", "Dominican Republic"), + ("repúblicaorientaldeluruguay", "Uruguay"), + ("reunion", "Réunion"), + ("rgypt", "Egypt"), + ("rhodesia", "Zimbabwe"), + ("ro", "Romania"), + ("roi", "Ireland"), + ("românia", "Romania"), + ("rossiya", "Russia"), + ("rossiâ", "Russia"), + ("roumania", "Romania"), + ("rs", "Serbia"), + ("rsa", "South Africa"), + ("rsm", "San Marino"), + ("ru", "Russia"), + ("rumania", "Romania"), + ("russian federation", "Russia"), + ("russianfederation", "Russia"), + ("rw", "Rwanda"), + ("rwandese republic", "Rwanda"), + ("rwandeserepublic", "Rwanda"), + ("république centrafricaine", "Central African Republic"), + ("république du congo", "Congo"), + ("république démocratique du congo", "Congo (Democratic Republic)"), + ("république française", "France"), + ("république gabonaise", "Gabon"), + ("républiquecentrafricaine", "Central African Republic"), + ("républiqueducongo", "Congo"), + ("républiquedémocratiqueducongo", "Congo (Democratic Republic)"), + ("républiquefrançaise", "France"), + ("républiquegabonaise", "Gabon"), + ("sa", "Saudi Arabia"), + ("saint lucia", "St Lucia"), + ("saint vincent and the grenadines", "St Vincent"), + ("saintbarthélemy", "Saint Barthélemy"), + ("sainthelena", "Saint Helena"), + ("saintlucia", "St Lucia"), + ("saintmartin(frenchpart)", "Saint-Martin (French part)"), + ("saintpierre et miquelon", "Saint Pierre and Miquelon"), + ("saintpierreandmiquelon", "Saint Pierre and Miquelon"), + ("saintpierreetmiquelon", "Saint Pierre and Miquelon"), + ("saintvincentandthegrenadines", "St Vincent"), + ("sakartvelo", "Georgia"), + ("salvador", "El Salvador"), + ("samo", "Samoa"), + ("samoa i sisifo", "Samoa"), + ("samoaisisifo", "Samoa"), + ("sanmarino", "San Marino"), + ("sao thome e principe", "Sao Tome and Principe"), + ("sao tome e principe", "Sao Tome and Principe"), + ("saothomeeprincipe", "Sao Tome and Principe"), + ("saotomeandprincipe", "Sao Tome and Principe"), + ("saotomeeprincipe", "Sao Tome and Principe"), + ("sarnam sranangron", "Suriname"), + ("sarnam", "Suriname"), + ("sarnamsranangron", "Suriname"), + ("saudiarabia", "Saudi Arabia"), + ("sb", "Solomon Islands"), + ("sc", "Seychelles"), + ("schweiz", "Switzerland"), + ("sd", "Sudan"), + ("se", "Sweden"), + ("sesel", "Seychelles"), + ("sg", "Singapore"), + ("shac", "Ascension"), + ("shhl", "Saint Helena"), + ("shqipëria", "Albania"), + ("shta", "Tristan da Cunha"), + ("si", "Slovenia"), + ("siam", "Thailand"), + ("sierraleone", "Sierra Leone"), + ("singapur", "Singapore"), + ("singapura", "Singapore"), + ("sint maarten", "Sint Maarten (Dutch part)"), + ("sinteustatius", "Sint Eustatius"), + ("sintmaarten", "Sint Maarten (Dutch part)"), + ("sintmaarten(dutchpart)", "Sint Maarten (Dutch part)"), + ("sion", "Israel"), + ("sj", "Svalbard and Jan Mayen"), + ("sk", "Slovakia"), + ("sl", "Sierra Leone"), + ("slovak republic", "Slovakia"), + ("slovakrepublic", "Slovakia"), + ("slovenija", "Slovenia"), + ("slovensko", "Slovakia"), + ("slovenská", "Slovakia"), + ("sm", "San Marino"), + ("sn", "Senegal"), + ("so", "Somalia"), + ("socialist republic of vietnam", "Vietnam"), + ("socialistrepublicofvietnam", "Vietnam"), + ("solomon aelan", "Solomon Islands"), + ("solomonaelan", "Solomon Islands"), + ("solomonislands", "Solomon Islands"), + ("solomons", "Solomon Islands"), + ("soomaaliya", "Somalia"), + ("soudan", "Sudan"), + ( + "south georgia and the south sandwich islands", + "South Georgia and South Sandwich Islands", + ), + ("south west africa", "Namibia"), + ("southafrica", "South Africa"), + ("southgeorgiaandsouthsandwichislands", "South Georgia and South Sandwich Islands"), + ( + "southgeorgiaandthesouthsandwichislands", + "South Georgia and South Sandwich Islands", + ), + ("southkorea", "South Korea"), + ("southsudan", "South Sudan"), + ("southwestafrica", "Namibia"), + ("sovereign base areas of akrotiri and dhekelia", "Dhekelia"), + ("sovereignbaseareasofakrotirianddhekelia", "Dhekelia"), + ("spanish guinea", "Equatorial Guinea"), + ("spanishguinea", "Equatorial Guinea"), + ("sr", "Suriname"), + ("sranangron", "Suriname"), + ("srbija", "Serbia"), + ("sri lankā", "Sri Lanka"), + ("srilanka", "Sri Lanka"), + ("srilankā", "Sri Lanka"), + ("ss", "South Sudan"), + ("st barth", "Saint Barthélemy"), + ("st barthelemy", "Saint Barthélemy"), + ("st thomas and principe", "Sao Tome and Principe"), + ("st", "Sao Tome and Principe"), + ("state of bahrain", "Bahrain"), + ("state of eritrea", "Eritrea"), + ("state of israel", "Israel"), + ("state of kuwait", "Kuwait"), + ("state of qatar", "Qatar"), + ("stateofbahrain", "Bahrain"), + ("stateoferitrea", "Eritrea"), + ("stateofisrael", "Israel"), + ("stateofkuwait", "Kuwait"), + ("stateofqatar", "Qatar"), + ("stbarth", "Saint Barthélemy"), + ("stbarthelemy", "Saint Barthélemy"), + ("stkittsandnevis", "St Kitts and Nevis"), + ("stlucia", "St Lucia"), + ("stthomasandprincipe", "Sao Tome and Principe"), + ("stvincent", "St Vincent"), + ("suidafrika", "South Africa"), + ("suisse", "Switzerland"), + ("sultanate of oman", "Oman"), + ("sultanateofoman", "Oman"), + ("suomi", "Finland"), + ("suriyah", "Syria"), + ("sv", "El Salvador"), + ("svalbardandjanmayen", "Svalbard and Jan Mayen"), + ("sverige", "Sweden"), + ("svizra", "Switzerland"), + ("svizzera", "Switzerland"), + ("swatini", "Eswatini"), + ("swiss confederation", "Switzerland"), + ("swissconfederation", "Switzerland"), + ("switerland", "Switzerland"), + ("sx", "Sint Maarten (Dutch part)"), + ("sy", "Syria"), + ("syrian arab republic", "Syria"), + ("syrianarabrepublic", "Syria"), + ("sz", "Eswatini"), + ("são tomé e príncipe", "Sao Tome and Principe"), + ("sãotoméepríncipe", "Sao Tome and Principe"), + ("sénégal", "Senegal"), + ("tadzhik", "Tajikistan"), + ("tadzhikistan", "Tajikistan"), + ("tajik", "Tajikistan"), + ("tc", "Turks and Caicos Islands"), + ("tchad", "Chad"), + ("td", "Chad"), + ("territory of american samoa", "American Samoa"), + ("territory of christmas island", "Christmas Island"), + ("territory of guam", "Guam"), + ( + "territory of heard island and mcdonald islands", + "Heard Island and McDonald Islands", + ), + ("territory of norfolk island", "Norfolk Island"), + ("territory of the cocos (keeling) islands", "Cocos (Keeling) Islands"), + ("territory of the wallis and futuna islands", "Wallis and Futuna"), + ("territoryofamericansamoa", "American Samoa"), + ("territoryofchristmasisland", "Christmas Island"), + ("territoryofguam", "Guam"), + ("territoryofheardislandandmcdonaldislands", "Heard Island and McDonald Islands"), + ("territoryofnorfolkisland", "Norfolk Island"), + ("territoryofthecocos(keeling)islands", "Cocos (Keeling) Islands"), + ("territoryofthewallisandfutunaislands", "Wallis and Futuna"), + ("tf", "French Southern Territories"), + ("tg", "Togo"), + ("th", "Thailand"), + ("the arab republic of egypt", "Egypt"), + ("the argentine republic", "Argentina"), + ("the bolivarian republic of venezuela", "Venezuela"), + ("the british indian ocean territory", "British Indian Ocean Territory"), + ("the central african republic", "Central African Republic"), + ("the commonwealth of australia", "Australia"), + ("the commonwealth of dominica", "Dominica"), + ("the commonwealth of the bahamas", "The Bahamas"), + ("the cooperative republic of guyana", "Guyana"), + ("the czech republic", "Czechia"), + ("the democratic republic of sao tome and principe", "Sao Tome and Principe"), + ("the democratic republic of the congo", "Congo (Democratic Republic)"), + ("the democratic republic of timorleste", "East Timor"), + ("the democratic socialist republic of sri lanka", "Sri Lanka"), + ("the dominican republic", "Dominican Republic"), + ("the federal democratic republic of ethiopia", "Ethiopia"), + ("the federal democratic republic of nepal", "Nepal"), + ("the federal republic of germany", "Germany"), + ("the federal republic of nigeria", "Nigeria"), + ("the federated states of micronesia", "Micronesia"), + ("the federation of saint christopher and nevis", "St Kitts and Nevis"), + ("the federative republic of brazil", "Brazil"), + ("the french republic", "France"), + ("the gabonese republic", "Gabon"), + ("the grand duchy of luxembourg", "Luxembourg"), + ("the hashemite kingdom of jordan", "Jordan"), + ("the hellenic republic", "Greece"), + ("the independent state of papua new guinea", "Papua New Guinea"), + ("the independent state of samoa", "Samoa"), + ("the islamic republic of afghanistan", "Afghanistan"), + ("the islamic republic of iran", "Iran"), + ("the islamic republic of mauritania", "Mauritania"), + ("the islamic republic of pakistan", "Pakistan"), + ("the italian republic", "Italy"), + ("the kingdom of bahrain", "Bahrain"), + ("the kingdom of belgium", "Belgium"), + ("the kingdom of bhutan", "Bhutan"), + ("the kingdom of cambodia", "Cambodia"), + ("the kingdom of denmark", "Denmark"), + ("the kingdom of lesotho", "Lesotho"), + ("the kingdom of morocco", "Morocco"), + ("the kingdom of norway", "Norway"), + ("the kingdom of saudi arabia", "Saudi Arabia"), + ("the kingdom of spain", "Spain"), + ("the kingdom of swaziland", "Eswatini"), + ("the kingdom of sweden", "Sweden"), + ("the kingdom of thailand", "Thailand"), + ("the kingdom of the netherlands", "Netherlands"), + ("the kingdom of tonga", "Tonga"), + ("the kyrgyz republic", "Kyrgyzstan"), + ("the lebanese republic", "Lebanon"), + ("the occupied palestinian territories", "Occupied Palestinian Territories"), + ("the oriental republic of uruguay", "Uruguay"), + ("the plurinational state of bolivia", "Bolivia"), + ("the portuguese republic", "Portugal"), + ("the principality of andorra", "Andorra"), + ("the principality of liechtenstein", "Liechtenstein"), + ("the principality of monaco", "Monaco"), + ("the republic of albania", "Albania"), + ("the republic of angola", "Angola"), + ("the republic of armenia", "Armenia"), + ("the republic of austria", "Austria"), + ("the republic of azerbaijan", "Azerbaijan"), + ("the republic of belarus", "Belarus"), + ("the republic of benin", "Benin"), + ("the republic of botswana", "Botswana"), + ("the republic of bulgaria", "Bulgaria"), + ("the republic of burundi", "Burundi"), + ("the republic of cabo verde", "Cape Verde"), + ("the republic of cameroon", "Cameroon"), + ("the republic of chad", "Chad"), + ("the republic of chile", "Chile"), + ("the republic of colombia", "Colombia"), + ("the republic of costa rica", "Costa Rica"), + ("the republic of croatia", "Croatia"), + ("the republic of cuba", "Cuba"), + ("the republic of cyprus", "Cyprus"), + ("the republic of djibouti", "Djibouti"), + ("the republic of ecuador", "Ecuador"), + ("the republic of el salvador", "El Salvador"), + ("the republic of equatorial guinea", "Equatorial Guinea"), + ("the republic of estonia", "Estonia"), + ("the republic of fiji", "Fiji"), + ("the republic of finland", "Finland"), + ("the republic of ghana", "Ghana"), + ("the republic of guatemala", "Guatemala"), + ("the republic of guinea", "Guinea"), + ("the republic of guineabissau", "Guinea-Bissau"), + ("the republic of haiti", "Haiti"), + ("the republic of honduras", "Honduras"), + ("the republic of iceland", "Iceland"), + ("the republic of india", "India"), + ("the republic of indonesia", "Indonesia"), + ("the republic of iraq", "Iraq"), + ("the republic of kazakhstan", "Kazakhstan"), + ("the republic of kenya", "Kenya"), + ("the republic of kiribati", "Kiribati"), + ("the republic of korea", "South Korea"), + ("the republic of kosovo", "Kosovo"), + ("the republic of latvia", "Latvia"), + ("the republic of liberia", "Liberia"), + ("the republic of lithuania", "Lithuania"), + ("the republic of macedonia", "North Macedonia"), + ("the republic of madagascar", "Madagascar"), + ("the republic of malawi", "Malawi"), + ("the republic of maldives", "Maldives"), + ("the republic of mali", "Mali"), + ("the republic of malta", "Malta"), + ("the republic of mauritius", "Mauritius"), + ("the republic of moldova", "Moldova"), + ("the republic of mozambique", "Mozambique"), + ("the republic of namibia", "Namibia"), + ("the republic of nauru", "Nauru"), + ("the republic of nicaragua", "Nicaragua"), + ("the republic of niger", "Niger"), + ("the republic of palau", "Palau"), + ("the republic of panama", "Panama"), + ("the republic of paraguay", "Paraguay"), + ("the republic of peru", "Peru"), + ("the republic of poland", "Poland"), + ("the republic of rwanda", "Rwanda"), + ("the republic of san marino", "San Marino"), + ("the republic of senegal", "Senegal"), + ("the republic of serbia", "Serbia"), + ("the republic of seychelles", "Seychelles"), + ("the republic of sierra leone", "Sierra Leone"), + ("the republic of singapore", "Singapore"), + ("the republic of slovenia", "Slovenia"), + ("the republic of south africa", "South Africa"), + ("the republic of south sudan", "South Sudan"), + ("the republic of suriname", "Suriname"), + ("the republic of tajikistan", "Tajikistan"), + ("the republic of the congo", "Congo"), + ("the republic of the gambia", "The Gambia"), + ("the republic of the marshall islands", "Marshall Islands"), + ("the republic of the philippines", "Philippines"), + ("the republic of the sudan", "Sudan"), + ("the republic of the union of myanmar", "Myanmar (Burma)"), + ("the republic of trinidad and tobago", "Trinidad and Tobago"), + ("the republic of turkey", "Turkey"), + ("the republic of uganda", "Uganda"), + ("the republic of uzbekistan", "Uzbekistan"), + ("the republic of vanuatu", "Vanuatu"), + ("the republic of yemen", "Yemen"), + ("the republic of zambia", "Zambia"), + ("the republic of zimbabwe", "Zimbabwe"), + ("the russian federation", "Russia"), + ("the slovak republic", "Slovakia"), + ("the socialist republic of vietnam", "Vietnam"), + ("the state of eritrea", "Eritrea"), + ("the state of israel", "Israel"), + ("the state of kuwait", "Kuwait"), + ("the state of qatar", "Qatar"), + ("the states", "United States"), + ("the sultanate of oman", "Oman"), + ("the swiss confederation", "Switzerland"), + ("the syrian arab republic", "Syria"), + ("the togolese republic", "Togo"), + ("the tunisian republic", "Tunisia"), + ("the union of the comoros", "Comoros"), + ("the united arab emirates", "United Arab Emirates"), + ("the united mexican states", "Mexico"), + ("the united republic of tanzania", "Tanzania"), + ("the united states of america", "United States"), + ("the virgin islands", "British Virgin Islands"), + ("thearabrepublicofegypt", "Egypt"), + ("theargentinerepublic", "Argentina"), + ("thebahamas", "The Bahamas"), + ("thebolivarianrepublicofvenezuela", "Venezuela"), + ("thebritishindianoceanterritory", "British Indian Ocean Territory"), + ("thecentralafricanrepublic", "Central African Republic"), + ("thecommonwealthofaustralia", "Australia"), + ("thecommonwealthofdominica", "Dominica"), + ("thecommonwealthofthebahamas", "The Bahamas"), + ("thecooperativerepublicofguyana", "Guyana"), + ("theczechrepublic", "Czechia"), + ("thedemocraticrepublicofsaotomeandprincipe", "Sao Tome and Principe"), + ("thedemocraticrepublicofthecongo", "Congo (Democratic Republic)"), + ("thedemocraticrepublicoftimorleste", "East Timor"), + ("thedemocraticsocialistrepublicofsrilanka", "Sri Lanka"), + ("thedominicanrepublic", "Dominican Republic"), + ("thefederaldemocraticrepublicofethiopia", "Ethiopia"), + ("thefederaldemocraticrepublicofnepal", "Nepal"), + ("thefederalrepublicofgermany", "Germany"), + ("thefederalrepublicofnigeria", "Nigeria"), + ("thefederatedstatesofmicronesia", "Micronesia"), + ("thefederationofsaintchristopherandnevis", "St Kitts and Nevis"), + ("thefederativerepublicofbrazil", "Brazil"), + ("thefrenchrepublic", "France"), + ("thegaboneserepublic", "Gabon"), + ("thegambia", "The Gambia"), + ("thegrandduchyofluxembourg", "Luxembourg"), + ("thehashemitekingdomofjordan", "Jordan"), + ("thehellenicrepublic", "Greece"), + ("theindependentstateofpapuanewguinea", "Papua New Guinea"), + ("theindependentstateofsamoa", "Samoa"), + ("theislamicrepublicofafghanistan", "Afghanistan"), + ("theislamicrepublicofiran", "Iran"), + ("theislamicrepublicofmauritania", "Mauritania"), + ("theislamicrepublicofpakistan", "Pakistan"), + ("theitalianrepublic", "Italy"), + ("thekingdomofbahrain", "Bahrain"), + ("thekingdomofbelgium", "Belgium"), + ("thekingdomofbhutan", "Bhutan"), + ("thekingdomofcambodia", "Cambodia"), + ("thekingdomofdenmark", "Denmark"), + ("thekingdomoflesotho", "Lesotho"), + ("thekingdomofmorocco", "Morocco"), + ("thekingdomofnorway", "Norway"), + ("thekingdomofsaudiarabia", "Saudi Arabia"), + ("thekingdomofspain", "Spain"), + ("thekingdomofswaziland", "Eswatini"), + ("thekingdomofsweden", "Sweden"), + ("thekingdomofthailand", "Thailand"), + ("thekingdomofthenetherlands", "Netherlands"), + ("thekingdomoftonga", "Tonga"), + ("thekyrgyzrepublic", "Kyrgyzstan"), + ("thelebaneserepublic", "Lebanon"), + ("theoccupiedpalestinianterritories", "Occupied Palestinian Territories"), + ("theorientalrepublicofuruguay", "Uruguay"), + ("theplurinationalstateofbolivia", "Bolivia"), + ("theportugueserepublic", "Portugal"), + ("theprincipalityofandorra", "Andorra"), + ("theprincipalityofliechtenstein", "Liechtenstein"), + ("theprincipalityofmonaco", "Monaco"), + ("therepublicofalbania", "Albania"), + ("therepublicofangola", "Angola"), + ("therepublicofarmenia", "Armenia"), + ("therepublicofaustria", "Austria"), + ("therepublicofazerbaijan", "Azerbaijan"), + ("therepublicofbelarus", "Belarus"), + ("therepublicofbenin", "Benin"), + ("therepublicofbotswana", "Botswana"), + ("therepublicofbulgaria", "Bulgaria"), + ("therepublicofburundi", "Burundi"), + ("therepublicofcaboverde", "Cape Verde"), + ("therepublicofcameroon", "Cameroon"), + ("therepublicofchad", "Chad"), + ("therepublicofchile", "Chile"), + ("therepublicofcolombia", "Colombia"), + ("therepublicofcostarica", "Costa Rica"), + ("therepublicofcroatia", "Croatia"), + ("therepublicofcuba", "Cuba"), + ("therepublicofcyprus", "Cyprus"), + ("therepublicofdjibouti", "Djibouti"), + ("therepublicofecuador", "Ecuador"), + ("therepublicofelsalvador", "El Salvador"), + ("therepublicofequatorialguinea", "Equatorial Guinea"), + ("therepublicofestonia", "Estonia"), + ("therepublicoffiji", "Fiji"), + ("therepublicoffinland", "Finland"), + ("therepublicofghana", "Ghana"), + ("therepublicofguatemala", "Guatemala"), + ("therepublicofguinea", "Guinea"), + ("therepublicofguineabissau", "Guinea-Bissau"), + ("therepublicofhaiti", "Haiti"), + ("therepublicofhonduras", "Honduras"), + ("therepublicoficeland", "Iceland"), + ("therepublicofindia", "India"), + ("therepublicofindonesia", "Indonesia"), + ("therepublicofiraq", "Iraq"), + ("therepublicofkazakhstan", "Kazakhstan"), + ("therepublicofkenya", "Kenya"), + ("therepublicofkiribati", "Kiribati"), + ("therepublicofkorea", "South Korea"), + ("therepublicofkosovo", "Kosovo"), + ("therepublicoflatvia", "Latvia"), + ("therepublicofliberia", "Liberia"), + ("therepublicoflithuania", "Lithuania"), + ("therepublicofmacedonia", "North Macedonia"), + ("therepublicofmadagascar", "Madagascar"), + ("therepublicofmalawi", "Malawi"), + ("therepublicofmaldives", "Maldives"), + ("therepublicofmali", "Mali"), + ("therepublicofmalta", "Malta"), + ("therepublicofmauritius", "Mauritius"), + ("therepublicofmoldova", "Moldova"), + ("therepublicofmozambique", "Mozambique"), + ("therepublicofnamibia", "Namibia"), + ("therepublicofnauru", "Nauru"), + ("therepublicofnicaragua", "Nicaragua"), + ("therepublicofniger", "Niger"), + ("therepublicofpalau", "Palau"), + ("therepublicofpanama", "Panama"), + ("therepublicofparaguay", "Paraguay"), + ("therepublicofperu", "Peru"), + ("therepublicofpoland", "Poland"), + ("therepublicofrwanda", "Rwanda"), + ("therepublicofsanmarino", "San Marino"), + ("therepublicofsenegal", "Senegal"), + ("therepublicofserbia", "Serbia"), + ("therepublicofseychelles", "Seychelles"), + ("therepublicofsierraleone", "Sierra Leone"), + ("therepublicofsingapore", "Singapore"), + ("therepublicofslovenia", "Slovenia"), + ("therepublicofsouthafrica", "South Africa"), + ("therepublicofsouthsudan", "South Sudan"), + ("therepublicofsuriname", "Suriname"), + ("therepublicoftajikistan", "Tajikistan"), + ("therepublicofthecongo", "Congo"), + ("therepublicofthegambia", "The Gambia"), + ("therepublicofthemarshallislands", "Marshall Islands"), + ("therepublicofthephilippines", "Philippines"), + ("therepublicofthesudan", "Sudan"), + ("therepublicoftheunionofmyanmar", "Myanmar (Burma)"), + ("therepublicoftrinidadandtobago", "Trinidad and Tobago"), + ("therepublicofturkey", "Turkey"), + ("therepublicofuganda", "Uganda"), + ("therepublicofuzbekistan", "Uzbekistan"), + ("therepublicofvanuatu", "Vanuatu"), + ("therepublicofyemen", "Yemen"), + ("therepublicofzambia", "Zambia"), + ("therepublicofzimbabwe", "Zimbabwe"), + ("therussianfederation", "Russia"), + ("theslovakrepublic", "Slovakia"), + ("thesocialistrepublicofvietnam", "Vietnam"), + ("thestateoferitrea", "Eritrea"), + ("thestateofisrael", "Israel"), + ("thestateofkuwait", "Kuwait"), + ("thestateofqatar", "Qatar"), + ("thestates", "United States"), + ("thesultanateofoman", "Oman"), + ("theswissconfederation", "Switzerland"), + ("thesyrianarabrepublic", "Syria"), + ("thetogoleserepublic", "Togo"), + ("thetunisianrepublic", "Tunisia"), + ("theunionofthecomoros", "Comoros"), + ("theunitedarabemirates", "United Arab Emirates"), + ("theunitedmexicanstates", "Mexico"), + ("theunitedrepublicoftanzania", "Tanzania"), + ("theunitedstatesofamerica", "United States"), + ("thevirginislands", "British Virgin Islands"), + ("timorleste", "East Timor"), + ("tj", "Tajikistan"), + ("tk", "Tokelau"), + ("tl", "East Timor"), + ("tm", "Turkmenistan"), + ("tn", "Tunisia"), + ("to", "Tonga"), + ("togolese republic", "Togo"), + ("togolese", "Togo"), + ("togoleserepublic", "Togo"), + ("tojikistan", "Tajikistan"), + ("toçikiston", "Tajikistan"), + ("tr", "Turkey"), + ("trinidadandtobago", "Trinidad and Tobago"), + ("tristandacunha", "Tristan da Cunha"), + ("tt", "Trinidad and Tobago"), + ("tunes", "Tunisia"), + ("tunisian republic", "Tunisia"), + ("tunisianrepublic", "Tunisia"), + ("turkiye", "Turkey"), + ("turkmen", "Turkmenistan"), + ("turkmenia", "Turkmenistan"), + ("turkomen", "Turkmenistan"), + ("turksandcaicosislands", "Turks and Caicos Islands"), + ("tv", "Tuvalu"), + ("tw", "Taiwan"), + ("tz", "Tanzania"), + ("táiwān", "Taiwan"), + ("türkiye", "Turkey"), + ("türkmenistan", "Turkmenistan"), + ("tšād", "Chad"), + ("tūns", "Tunisia"), + ("ua", "Ukraine"), + ("uae", "United Arab Emirates"), + ("ug", "Uganda"), + ("ukrayina", "Ukraine"), + ("ukraїna", "Ukraine"), + ("umbuso weswatini", "Eswatini"), + ("umbusoweswatini", "Eswatini"), + ("ummalquwain", "Umm al-Quwain"), + ("umzantsi afrika", "South Africa"), + ("umzantsiafrika", "South Africa"), + ("union of the comoros", "Comoros"), + ("unionofthecomoros", "Comoros"), + ("unit states", "United States"), + ("unite states", "United States"), + ("united arab republic", "Egypt"), + ("united mexican states", "Mexico"), + ("united republic of tanzania", "Tanzania"), + ("united sat", "United States"), + ("united staes", "United States"), + ("united stated", "United States"), + ("united states america", "United States"), + ("united states of america", "United States"), + ("united stats", "United States"), + ("united sttes", "United States"), + ("unitedarabemirates", "United Arab Emirates"), + ("unitedarabrepublic", "Egypt"), + ("unitedmexicanstates", "Mexico"), + ("unitedrepublicoftanzania", "Tanzania"), + ("unitedsat", "United States"), + ("unitedstaes", "United States"), + ("unitedstated", "United States"), + ("unitedstates", "United States"), + ("unitedstatesofamerica", "United States"), + ("unitedstatesvirginislands", "United States Virgin Islands"), + ("unitedstats", "United States"), + ("unitedsttes", "United States"), + ("unites states", "United States"), + ("unitesstates", "United States"), + ("unitestates", "United States"), + ("unitstates", "United States"), + ("untied state", "United States"), + ("untiedstate", "United States"), + ("us", "United States"), + ("usa", "United States"), + ("uvea mo futuna", "Wallis and Futuna"), + ("uveamofutuna", "Wallis and Futuna"), + ("uy", "Uruguay"), + ("uz", "Uzbekistan"), + ("uzbek", "Uzbekistan"), + ("va", "Vatican City"), + ("vatican city state", "Vatican City"), + ("vaticancity", "Vatican City"), + ("vaticancitystate", "Vatican City"), + ("vc", "St Vincent"), + ("ve", "Venezuela"), + ("veitnam", "Vietnam"), + ("venezula", "Venezuela"), + ("vg", "British Virgin Islands"), + ("vi", "United States Virgin Islands"), + ("vietman", "Vietnam"), + ("virgin islands of the united states", "United States Virgin Islands"), + ("virgin islands", "British Virgin Islands"), + ("virgina islands", "British Virgin Islands"), + ("virginaislands", "British Virgin Islands"), + ("virginislands", "British Virgin Islands"), + ("virginislandsoftheunitedstates", "United States Virgin Islands"), + ("viti", "Fiji"), + ("việt nam", "Vietnam"), + ("việtnam", "Vietnam"), + ("vn", "Vietnam"), + ("volta", "Burkina Faso"), + ("volívia", "Bolivia"), + ("vu", "Vanuatu"), + ("wakeisland", "Wake Island"), + ("wallisandfutuna", "Wallis and Futuna"), + ("wallisetfutuna", "Wallis and Futuna"), + ("west pakistan", "Pakistan"), + ("western samoa", "Samoa"), + ("westernsahara", "Western Sahara"), + ("westernsamoa", "Samoa"), + ("westpakistan", "Pakistan"), + ("weswatini swatini ngwane", "Eswatini"), + ("weswatini", "Eswatini"), + ("weswatiniswatiningwane", "Eswatini"), + ("wf", "Wallis and Futuna"), + ("white russia", "Belarus"), + ("whiterussia", "Belarus"), + ("ws", "Samoa"), + ("wuliwya", "Bolivia"), + ("xk", "Kosovo"), + ("xqz", "Akrotiri"), + ("xxd", "Dhekelia"), + ("xīnjiāpō", "Singapore"), + ("yaltopya", "Ethiopia"), + ("ye", "Yemen"), + ("yisrael", "Israel"), + ("yt", "Mayotte"), + ("za", "South Africa"), + ("zealnd", "New Zealand"), + ("zeland", "New Zealand"), + ("zhongguo", "China"), + ("zhonghua peoples republic", "China"), + ("zhonghua", "China"), + ("zhonghuapeoplesrepublic", "China"), + ("zhōngguó", "China"), + ("zhōnghuá mínguó", "Taiwan"), + ("zhōnghuámínguó", "Taiwan"), + ("zion", "Israel"), + ("zm", "Zambia"), + ("ztate of katar", "Qatar"), + ("ztateofkatar", "Qatar"), + ("zw", "Zimbabwe"), + ("åland", "Åland Islands"), + ("ålandislands", "Åland Islands"), + ("éire", "Ireland"), + ("étatsunis", "United States"), + ("ísland", "Iceland"), + ("îraq", "Iraq"), + ("österreich", "Austria"), + ("česko", "Czechia"), + ("česká republika", "Czechia"), + ("česká", "Czechia"), + ("českárepublika", "Czechia"), + ("īrān", "Iran"), + ("ελλάδα", "Greece"), + ("ελλάς", "Greece"), + ("κύπρος", "Cyprus"), + ("беларусь", "Belarus"), + ("босна и херцеговина", "Bosnia and Herzegovina"), + ("боснаихерцеговина", "Bosnia and Herzegovina"), + ("българия", "Bulgaria"), + ("казахстан", "Kazakhstan"), + ("киргизия", "Kyrgyzstan"), + ("косово", "Kosovo"), + ("кыргызстан", "Kyrgyzstan"), + ("македонија", "North Macedonia"), + ("монгол улс", "Mongolia"), + ("монголулс", "Mongolia"), + ("российская", "Russia"), + ("россия", "Russia"), + ("србија srbija", "Serbia"), + ("србија", "Serbia"), + ("србијаsrbija", "Serbia"), + ("тоҷикистон", "Tajikistan"), + ("україна", "Ukraine"), + ("црна гора", "Montenegro"), + ("црнагора", "Montenegro"), + ("ўзбекистон", "Uzbekistan"), + ("қазақстан", "Kazakhstan"), + ("հայաստան", "Armenia"), + ("ישראל", "Israel"), + ("إرتريا", "Eritrea"), + ("إسرائيل ישראל", "Israel"), + ("إسرائيل", "Israel"), + ("إسرائيلישראל", "Israel"), + ("افغانستان", "Afghanistan"), + ("الأردن", "Jordan"), + ("الإمارات العربيّة المتّحدة", "United Arab Emirates"), + ("الإمارات", "United Arab Emirates"), + ("الإماراتالعربيّةالمتّحدة", "United Arab Emirates"), + ("البحرين", "Bahrain"), + ("الجزائر", "Algeria"), + ("السعودية", "Saudi Arabia"), + ("السودان", "Sudan"), + ("الصومال", "Somalia"), + ("العراق", "Iraq"), + ("العراق\u200e", "Iraq"), + ("الكويت", "Kuwait"), + ("المغرب", "Morocco"), + ("المملكة العربية السعودية", "Saudi Arabia"), + ("المملكةالعربيةالسعودية", "Saudi Arabia"), + ("الموريتانية", "Mauritania"), + ("اليمن", "Yemen"), + ("ایران", "Iran"), + ("بروني", "Brunei"), + ("تشاد", "Chad"), + ("تشاد\u200e", "Chad"), + ("تونس", "Tunisia"), + ("جز القمر", "Comoros"), + ("جزالقمر", "Comoros"), + ("جزر القمر", "Comoros"), + ("جزرالقمر", "Comoros"), + ("جيبوتي", "Djibouti"), + ("جيبوتي\u200e", "Djibouti"), + ("دولة الكويت", "Kuwait"), + ("دولةالكويت", "Kuwait"), + ("سورية", "Syria"), + ("عمان", "Oman"), + ("عُمان", "Oman"), + ("فلسطين", "Occupied Palestinian Territories"), + ("قازاقستان", "Kazakhstan"), + ("قطر", "Qatar"), + ("لبنان", "Lebanon"), + ("لصحراء الغربية", "Western Sahara"), + ("لصحراءالغربية", "Western Sahara"), + ("ليبيا", "Libya"), + ("مصر", "Egypt"), + ("موريتانيا", "Mauritania"), + ("پاکستان", "Pakistan"), + ("नेपाल", "Nepal"), + ("फ़िजी", "Fiji"), + ("भारत गणराज्य", "India"), + ("भारत", "India"), + ("भारतगणराज्य", "India"), + ("भारतम्", "India"), + ("भूटान", "Bhutan"), + ("शर्नम्", "Suriname"), + ("বাংলাদেশ", "Bangladesh"), + ("ভারত", "India"), + ("ভাৰত", "India"), + ("ਭਾਰਤ", "India"), + ("ભારત", "India"), + ("ଭାରତ", "India"), + ("இலங்கை", "Sri Lanka"), + ("சிங்கப்பூர் குடியரசு", "Singapore"), + ("சிங்கப்பூர்", "Singapore"), + ("சிங்கப்பூர்கு", "Singapore"), + ("சிங்கப்பூர்குடியரசு", "Singapore"), + ("டியரசு", "Singapore"), + ("பாரதம்", "India"), + ("மலேசியா", "Malaysia"), + ("భారత దేశం", "India"), + ("భారతదేశం", "India"), + ("ಭಾರತ", "India"), + ("ഭാരതം", "India"), + # ('ශ්රී ලංකා இலங்கை', 'Sri Lanka'), + # ('ශ්රී ලංකා', 'Sri Lanka'), + # ('ශ්රී ලංකාව', 'Sri Lanka'), + # ('ශ්රීලංකා', 'Sri Lanka'), + # ('ශ්රීලංකාஇலங்கை', 'Sri Lanka'), + # ('ශ්රීලංකාව', 'Sri Lanka'), + ("ประเทศไทย", "Thailand"), + ("ราชอาณาจักรไทย", "Thailand"), + ("เมืองไทย", "Thailand"), + ("ປະເທດລາວ", "Laos"), + ("འབྲུག་ཡུལ", "Bhutan"), + ("မြန်မာ", "Myanmar (Burma)"), + ("საქართველო", "Georgia"), + ("ኢትዮጵያ", "Ethiopia"), + ("ኤርትራ", "Eritrea"), + ("កម្ពុជា", "Cambodia"), + ("ᠮᠤᠩᠭᠤᠯ ᠤᠯᠤᠰ", "Mongolia"), + ("ᠮᠤᠩᠭᠤᠯᠤᠯᠤᠰ", "Mongolia"), + ("‘umān", "Oman"), + ("ⴰⴳⴰⵡⵛ", "Mauritania"), + ("ⴰⵎⵔⵔⵓⴽ", "Morocco"), + ("ⴷⵣⴰⵢⴻⵔ", "Algeria"), + ("ⵍⵉⴱⵢⴰ", "Libya"), + ("ⵍⵎⵖⵔⵉⴱ", "Morocco"), + ("ⵎⵓⵔⵉⵜⴰⵏ", "Mauritania"), + ("ⵜⵓⵏⵙ", "Tunisia"), + ("中华", "China"), + ("中国", "China"), + ("中国/中华", "China"), + ("中華民國", "Taiwan"), + ("台灣", "Taiwan"), + ("新加坡", "Singapore"), + ("新加坡共和国", "Singapore"), + ("日本", "Japan"), + ("臺灣", "Taiwan"), + ("臺灣/台灣", "Taiwan"), + ("香港", "Hong Kong"), + ("马来西亚", "Malaysia"), + ("남한", "South Korea"), + ("북조선", "North Korea"), + ("조선 / 朝鮮", "North Korea"), + ("조선/朝鮮", "North Korea"), + ("한국 / 韓國", "South Korea"), + ("한국/韓國", "South Korea"), +) + + +CROWDSOURCED_MISTAKES = ( + ("burma", "Myanmar (Burma)", "rest-of-world"), + ("canary islands", "Canary Islands", "europe"), + ("czechoslovakia", "Czechia", "europe"), + ("east germany", "Germany", "europe"), + ("easter island", "Easter Island", "rest-of-world"), + ("Eidal", "Italy", "europe"), + ("falklands", "Falkland Islands", "rest-of-world"), + ("Ffrainc", "France", "europe"), + ("hawaii", "United States", "rest-of-world"), + ("Herm", "United Kingdom", "united-kingdom"), + ("Khazakhstan", "Kazakhstan", "europe"), + ("korea", "South Korea", "rest-of-world"), + ("macau", "Macao", "rest-of-world"), + ("myanmar", "Myanmar (Burma)", "rest-of-world"), + ("new zeeland", "New Zealand", "rest-of-world"), + ("NI", "United Kingdom", "united-kingdom"), + ("Pitcairn Island", "Pitcairn, Henderson, Ducie and Oeno Islands", "rest-of-world"), + ("republic of china", "Taiwan", "rest-of-world"), + ("Republik Österreich", "Austria", "europe"), + ("République Islamique de Mauritanie", "Mauritania", "rest-of-world"), + ("Mauritanie", "Mauritania", "rest-of-world"), + ("Sark", "United Kingdom", "united-kingdom"), + ("South Ireland", "Ireland", "europe"), + ("St Helena", "Saint Helena", "rest-of-world"), + ("Swaziland", "Eswatini", "rest-of-world"), + ("the falkland islands", "Falkland Islands", "rest-of-world"), + ("the falklands", "Falkland Islands", "rest-of-world"), + ( + "the sandwich islands", + "South Georgia and the South Sandwich Islands", + "rest-of-world", + ), + ("South Georgia", "South Georgia and the South Sandwich Islands", "rest-of-world"), + ("Tristan", "Tristan da Cunha", "rest-of-world"), + ("ussr", None, None), + ("USSR", None, None), + ("союз советских социалистических республик", None, None), + ("Vatican", "Vatican City", "europe"), + ("West Germany", "Germany", "europe"), + ("Yr Alban", "United Kingdom", "united-kingdom"), + ("yugoslavia", None, None), + ("jugoslavija", None, None), + ("Swistir", "Switzerland", "europe"), + ("Y Swistir", "Switzerland", "europe"), + ("Saint Kitts", "St Kitts and Nevis", "rest-of-world"), + ("St Kitts", "St Kitts and Nevis", "rest-of-world"), +) diff --git a/tests/notifications_utils/test_base64_uuid.py b/tests/notifications_utils/test_base64_uuid.py new file mode 100644 index 000000000..e9d35b24d --- /dev/null +++ b/tests/notifications_utils/test_base64_uuid.py @@ -0,0 +1,57 @@ +import binascii +import os +from uuid import UUID + +import pytest + +from notifications_utils.base64_uuid import ( + base64_to_bytes, + base64_to_uuid, + bytes_to_base64, + uuid_to_base64, +) + + +def test_bytes_to_base64_to_bytes(): + b = os.urandom(32) + b64 = bytes_to_base64(b) + assert base64_to_bytes(b64) == b + + +@pytest.mark.parametrize( + "url_val", + [ + "AAAAAAAAAAAAAAAAAAAAAQ", + "AAAAAAAAAAAAAAAAAAAAAQ=", # even though this has invalid padding we put extra =s on the end so this is okay + "AAAAAAAAAAAAAAAAAAAAAQ==", + ], +) +def test_base64_converter_to_python(url_val): + assert base64_to_uuid(url_val) == UUID(int=1) + + +@pytest.mark.parametrize( + "python_val", [UUID(int=1), "00000000-0000-0000-0000-000000000001"] +) +def test_base64_converter_to_url(python_val): + assert uuid_to_base64(python_val) == "AAAAAAAAAAAAAAAAAAAAAQ" + + +@pytest.mark.parametrize( + "url_val,expectation", + [ + ( + "this_is_valid_base64_but_is_too_long_to_be_a_uuid", + pytest.raises(binascii.Error), + ), + ("this_one_has_emoji_➕➕➕", pytest.raises(UnicodeEncodeError)), + ], +) +def test_base64_converter_to_python_raises_validation_error(url_val, expectation): + with expectation: + base64_to_uuid(url_val) + + +def test_base64_converter_to_url_raises_validation_error(): + with pytest.raises(AttributeError): + uuid_to_base64(object()) diff --git a/tests/notifications_utils/test_base_template.py b/tests/notifications_utils/test_base_template.py new file mode 100644 index 000000000..c9d126d12 --- /dev/null +++ b/tests/notifications_utils/test_base_template.py @@ -0,0 +1,119 @@ +from unittest.mock import patch + +import pytest + +from notifications_utils.template import SubjectMixin, Template + + +class ConcreteImplementation: + template_type = None + + # Can’t instantiate and test templates unless they implement __str__ + def __str__(self): + pass + + +class ConcreteTemplate(ConcreteImplementation, Template): + pass + + +class ConcreteTemplateWithSubject(SubjectMixin, ConcreteTemplate): + pass + + +def test_class(): + assert ( + repr(ConcreteTemplate({"content": "hello ((name))"})) + == 'ConcreteTemplate("hello ((name))", {})' + ) + + +def test_passes_through_template_attributes(): + assert ConcreteTemplate({"content": ""}).name is None + assert ( + ConcreteTemplate({"content": "", "name": "Two week reminder"}).name + == "Two week reminder" + ) + assert ConcreteTemplate({"content": ""}).id is None + assert ConcreteTemplate({"content": "", "id": "1234"}).id == "1234" + assert ConcreteTemplate({"content": ""}).template_type is None + + +def test_passes_through_subject(): + assert ( + ConcreteTemplateWithSubject( + {"content": "", "subject": "Your tax is due"} + ).subject + == "Your tax is due" + ) + + +def test_errors_for_missing_template_content(): + with pytest.raises(KeyError): + ConcreteTemplate({}) + + +@pytest.mark.parametrize("template", [0, 1, 2, True, False, None]) +def test_errors_for_invalid_template_types(template): + with pytest.raises(TypeError): + ConcreteTemplate(template) + + +@pytest.mark.parametrize("values", [[], False]) +def test_errors_for_invalid_values(values): + with pytest.raises(TypeError): + ConcreteTemplate({"content": ""}, values) + + +def test_matches_keys_to_placeholder_names(): + template = ConcreteTemplate({"content": "hello ((name))"}) + + template.values = {"NAME": "Chris"} + assert template.values == {"name": "Chris"} + + template.values = {"NAME": "Chris", "Town": "London"} + assert template.values == {"name": "Chris", "Town": "London"} + assert template.additional_data == {"Town"} + + template.values = None + assert template.missing_data == ["name"] + + +@pytest.mark.parametrize( + "template_content, template_subject, expected", + [ + ("the quick brown fox", "jumps", []), + ("the quick ((colour)) fox", "jumps", ["colour"]), + ("the quick ((colour)) ((animal))", "jumps", ["colour", "animal"]), + ("((colour)) ((animal)) ((colour)) ((animal))", "jumps", ["colour", "animal"]), + ("the quick brown fox", "((colour))", ["colour"]), + ("the quick ((colour)) ", "((animal))", ["animal", "colour"]), + ("((colour)) ((animal)) ", "((colour)) ((animal))", ["colour", "animal"]), + ("Dear ((name)), ((warning?? This is a warning))", "", ["name", "warning"]), + ("((warning? one question mark))", "", ["warning? one question mark"]), + ], +) +def test_extracting_placeholders(template_content, template_subject, expected): + assert ( + ConcreteTemplateWithSubject( + {"content": template_content, "subject": template_subject} + ).placeholders + == expected + ) + + +def test_random_variable_retrieve(): + template = ConcreteTemplate({"content": "content", "created_by": "now"}) + assert template.get_raw("created_by") == "now" + assert template.get_raw("missing", default="random") == "random" + assert template.get_raw("missing") is None + + +def test_compare_template(): + with patch( + "notifications_utils.template_change.TemplateChange.__init__", return_value=None + ) as mocked: + old_template = ConcreteTemplate({"content": "faked"}) + new_template = ConcreteTemplate({"content": "faked"}) + old_template.compare_to(new_template) + mocked.assert_called_once_with(old_template, new_template) diff --git a/tests/notifications_utils/test_countries.py b/tests/notifications_utils/test_countries.py new file mode 100644 index 000000000..473b2731b --- /dev/null +++ b/tests/notifications_utils/test_countries.py @@ -0,0 +1,170 @@ +import pytest + +from notifications_utils.countries import Country, CountryMapping, CountryNotFoundError +from notifications_utils.countries.data import ( + _EUROPEAN_ISLANDS_LIST, + _UK_ISLANDS_LIST, + ADDITIONAL_SYNONYMS, + COUNTRIES_AND_TERRITORIES, + ROYAL_MAIL_EUROPEAN, + UK, + UK_ISLANDS, + WELSH_NAMES, + Postage, +) + +from .country_synonyms import ALL as ALL_SYNONYMS +from .country_synonyms import CROWDSOURCED_MISTAKES + + +def test_constants(): + assert UK == "United Kingdom" + assert UK_ISLANDS == [ + ("Alderney", UK), + ("Brecqhou", UK), + ("Guernsey", UK), + ("Herm", UK), + ("Isle of Man", UK), + ("Jersey", UK), + ("Jethou", UK), + ("Sark", UK), + ] + assert Postage.EUROPE == "europe" + assert Postage.REST_OF_WORLD == "rest-of-world" + assert Postage.UK == "united-kingdom" + + +@pytest.mark.parametrize("synonym, canonical", ADDITIONAL_SYNONYMS) +def test_hand_crafted_synonyms_map_to_canonical_countries(synonym, canonical): + exceptions_to_canonical_countries = [ + "Easter Island", + "South Georgia and the South Sandwich Islands", + ] + + synonyms = dict(COUNTRIES_AND_TERRITORIES).keys() + canonical_names = list(dict(COUNTRIES_AND_TERRITORIES).values()) + + assert canonical in ( + canonical_names + + _EUROPEAN_ISLANDS_LIST + + _UK_ISLANDS_LIST + + exceptions_to_canonical_countries + ) + + assert synonym not in {CountryMapping.make_key(synonym_) for synonym_ in synonyms} + assert Country(synonym).canonical_name == canonical + + +@pytest.mark.parametrize("welsh_name, canonical", WELSH_NAMES) +def test_welsh_names_map_to_canonical_countries(welsh_name, canonical): + assert Country(canonical).canonical_name == canonical + assert Country(welsh_name).canonical_name == canonical + + +def test_all_synonyms(): + for search, expected in ALL_SYNONYMS: + assert Country(search).canonical_name == expected + + +def test_crowdsourced_test_data(): + for search, expected_country, expected_postage in CROWDSOURCED_MISTAKES: + if expected_country or expected_postage: + assert Country(search).canonical_name == expected_country + assert Country(search).postage_zone == expected_postage + + +@pytest.mark.parametrize( + "search, expected", + ( + ("u.s.a", "United States"), + ("america", "United States"), + ("United States America", "United States"), + ("ROI", "Ireland"), + ("Irish Republic", "Ireland"), + ("Rep of Ireland", "Ireland"), + ("RepOfIreland", "Ireland"), + ("deutschland", "Germany"), + ("UK", "United Kingdom"), + ("England", "United Kingdom"), + ("Northern Ireland", "United Kingdom"), + ("Scotland", "United Kingdom"), + ("Wales", "United Kingdom"), + ("N. Ireland", "United Kingdom"), + ("GB", "United Kingdom"), + ("NIR", "United Kingdom"), + ("SCT", "United Kingdom"), + ("WLS", "United Kingdom"), + ("gambia", "The Gambia"), + ("Jersey", "United Kingdom"), + ("Guernsey", "United Kingdom"), + ("Lubnān", "Lebanon"), + ("Lubnan", "Lebanon"), + ("ESPAÑA", "Spain"), + ("ESPANA", "Spain"), + ("the democratic people's republic of korea", "North Korea"), + ("the democratic peoples republic of korea", "North Korea"), + ("ALAND", "Åland Islands"), + ("Sao Tome + Principe", "Sao Tome and Principe"), + ("Sao Tome & Principe", "Sao Tome and Principe"), + ("Antigua, and Barbuda", "Antigua and Barbuda"), + ("Azores", "Azores"), + ("Autonomous Region of the Azores", "Azores"), + ("Canary Islands", "Canary Islands"), + ("Islas Canarias", "Canary Islands"), + ("Canaries", "Canary Islands"), + ("Madeira", "Madeira"), + ("Autonomous Region of Madeira", "Madeira"), + ("Região Autónoma da Madeira", "Madeira"), + ("Balearic Islands", "Balearic Islands"), + ("Islas Baleares", "Balearic Islands"), + ("Illes Balears", "Balearic Islands"), + ("Corsica", "Corsica"), + ("Corse", "Corsica"), + ), +) +def test_hand_crafted_synonyms(search, expected): + assert Country(search).canonical_name == expected + + +def test_auto_checking_for_country_starting_with_the(): + canonical_names = dict(COUNTRIES_AND_TERRITORIES).values() + synonyms = dict(COUNTRIES_AND_TERRITORIES).keys() + assert "The Gambia" in canonical_names + assert "Gambia" not in synonyms + assert Country("Gambia").canonical_name == "The Gambia" + + +@pytest.mark.parametrize( + "search, expected_error_message", + ( + ("Qumran", "Not a known country or territory (Qumran)"), + ("Kumrahn", "Not a known country or territory (Kumrahn)"), + ), +) +def test_non_existant_countries(search, expected_error_message): + with pytest.raises(KeyError) as error: + Country(search) + assert str(error.value) == repr(expected_error_message) + assert isinstance(error.value, CountryNotFoundError) + + +@pytest.mark.parametrize( + "search, expected", + ( + ("u.s.a", "rest-of-world"), + ("Rep of Ireland", "europe"), + ("deutschland", "europe"), + ("UK", "united-kingdom"), + ("Jersey", "united-kingdom"), + ("Guernsey", "united-kingdom"), + ("isle-of-man", "united-kingdom"), + ("ESPAÑA", "europe"), + ), +) +def test_get_postage(search, expected): + assert Country(search).postage_zone == expected + + +def test_euro_postage_zone(): + for search in ROYAL_MAIL_EUROPEAN: + assert Country(search).postage_zone == Postage.EUROPE diff --git a/tests/notifications_utils/test_countries_iso.py b/tests/notifications_utils/test_countries_iso.py new file mode 100644 index 000000000..e1d3a68d8 --- /dev/null +++ b/tests/notifications_utils/test_countries_iso.py @@ -0,0 +1,526 @@ +import pytest + +from notifications_utils.countries import Country, CountryNotFoundError + + +def _country_not_found(*test_case): + return pytest.param( + *test_case, + marks=pytest.mark.xfail(raises=CountryNotFoundError), + ) + + +@pytest.mark.parametrize( + "alpha_2, expected_name", + ( + ("AF", "Afghanistan"), + ("AL", "Albania"), + ("DZ", "Algeria"), + ("AS", "American Samoa"), + ("AD", "Andorra"), + ("AO", "Angola"), + ("AI", "Anguilla"), + ("AQ", "Antarctica"), + ("AG", "Antigua and Barbuda"), + ("AR", "Argentina"), + ("AM", "Armenia"), + ("AW", "Aruba"), + ("AU", "Australia"), + ("AT", "Austria"), + ("AZ", "Azerbaijan"), + ("BS", "The Bahamas"), + ("BH", "Bahrain"), + ("BD", "Bangladesh"), + ("BB", "Barbados"), + ("BY", "Belarus"), + ("BE", "Belgium"), + ("BZ", "Belize"), + ("BJ", "Benin"), + ("BM", "Bermuda"), + ("BT", "Bhutan"), + ("BO", "Bolivia"), + _country_not_found("BQ", "Bonaire, Sint Eustatius and Saba"), + ("BA", "Bosnia and Herzegovina"), + ("BW", "Botswana"), + ("BV", "Bouvet Island"), + ("BR", "Brazil"), + ("IO", "British Indian Ocean Territory"), + ("BN", "Brunei"), + ("BG", "Bulgaria"), + ("BF", "Burkina Faso"), + ("BI", "Burundi"), + ("CV", "Cape Verde"), + ("KH", "Cambodia"), + ("CM", "Cameroon"), + ("CA", "Canada"), + ("KY", "Cayman Islands"), + ("CF", "Central African Republic"), + ("TD", "Chad"), + ("CL", "Chile"), + ("CN", "China"), + ("CX", "Christmas Island"), + ("CC", "Cocos (Keeling) Islands"), + ("CO", "Colombia"), + ("KM", "Comoros"), + ("CD", "Congo (Democratic Republic)"), + ("CG", "Congo"), + ("CK", "Cook Islands"), + ("CR", "Costa Rica"), + ("HR", "Croatia"), + ("CU", "Cuba"), + ("CW", "Curaçao"), + ("CY", "Cyprus"), + ("CZ", "Czechia"), + ("CI", "Ivory Coast"), + ("DK", "Denmark"), + ("DJ", "Djibouti"), + ("DM", "Dominica"), + ("DO", "Dominican Republic"), + ("EC", "Ecuador"), + ("EG", "Egypt"), + ("SV", "El Salvador"), + ("GQ", "Equatorial Guinea"), + ("ER", "Eritrea"), + ("EE", "Estonia"), + ("SZ", "Eswatini"), + ("ET", "Ethiopia"), + ("FK", "Falkland Islands"), + ("FO", "Faroe Islands"), + ("FJ", "Fiji"), + ("FI", "Finland"), + ("FR", "France"), + ("GF", "French Guiana"), + ("PF", "French Polynesia"), + ("TF", "French Southern Territories"), + ("GA", "Gabon"), + ("GM", "The Gambia"), + ("GE", "Georgia"), + ("DE", "Germany"), + ("GH", "Ghana"), + ("GI", "Gibraltar"), + ("GR", "Greece"), + ("GL", "Greenland"), + ("GD", "Grenada"), + ("GP", "Guadeloupe"), + ("GU", "Guam"), + ("GT", "Guatemala"), + ("GG", "United Kingdom"), + ("GN", "Guinea"), + ("GW", "Guinea-Bissau"), + ("GY", "Guyana"), + ("HT", "Haiti"), + ("HM", "Heard Island and McDonald Islands"), + ("VA", "Vatican City"), + ("HN", "Honduras"), + ("HK", "Hong Kong"), + ("HU", "Hungary"), + ("IS", "Iceland"), + ("IN", "India"), + ("ID", "Indonesia"), + ("IR", "Iran"), + ("IQ", "Iraq"), + ("IE", "Ireland"), + ("IM", "United Kingdom"), + ("IL", "Israel"), + ("IT", "Italy"), + ("JM", "Jamaica"), + ("JP", "Japan"), + ("JE", "United Kingdom"), + ("JO", "Jordan"), + ("KZ", "Kazakhstan"), + ("KE", "Kenya"), + ("KI", "Kiribati"), + ("KP", "North Korea"), + ("KR", "South Korea"), + ("KW", "Kuwait"), + ("KG", "Kyrgyzstan"), + ("LA", "Laos"), + ("LV", "Latvia"), + ("LB", "Lebanon"), + ("LS", "Lesotho"), + ("LR", "Liberia"), + ("LY", "Libya"), + ("LI", "Liechtenstein"), + ("LT", "Lithuania"), + ("LU", "Luxembourg"), + ("MO", "Macao"), + ("MG", "Madagascar"), + ("MW", "Malawi"), + ("MY", "Malaysia"), + ("MV", "Maldives"), + ("ML", "Mali"), + ("MT", "Malta"), + ("MH", "Marshall Islands"), + ("MQ", "Martinique"), + ("MR", "Mauritania"), + ("MU", "Mauritius"), + ("YT", "Mayotte"), + ("MX", "Mexico"), + ("FM", "Micronesia"), + ("MD", "Moldova"), + ("MC", "Monaco"), + ("MN", "Mongolia"), + ("ME", "Montenegro"), + ("MS", "Montserrat"), + ("MA", "Morocco"), + ("MZ", "Mozambique"), + ("MM", "Myanmar (Burma)"), + ("NA", "Namibia"), + ("NR", "Nauru"), + ("NP", "Nepal"), + ("NL", "Netherlands"), + ("NC", "New Caledonia"), + ("NZ", "New Zealand"), + ("NI", "United Kingdom"), # NI gets interpreted as ‘Northern Ireland’ + ("NE", "Niger"), + ("NG", "Nigeria"), + ("NU", "Niue"), + ("NF", "Norfolk Island"), + ("MK", "North Macedonia"), + ("MP", "Northern Mariana Islands"), + ("NO", "Norway"), + ("OM", "Oman"), + ("PK", "Pakistan"), + ("PW", "Palau"), + ("PS", "Occupied Palestinian Territories"), + ("PA", "Panama"), + ("PG", "Papua New Guinea"), + ("PY", "Paraguay"), + ("PE", "Peru"), + ("PH", "Philippines"), + ("PN", "Pitcairn, Henderson, Ducie and Oeno Islands"), + ("PL", "Poland"), + ("PT", "Portugal"), + ("PR", "Puerto Rico"), + ("QA", "Qatar"), + ("RO", "Romania"), + ("RU", "Russia"), + ("RW", "Rwanda"), + ("RE", "Réunion"), + ("BL", "Saint Barthélemy"), + _country_not_found("SH", "Saint Helena, Ascension and Tristan da Cunha"), + ("KN", "St Kitts and Nevis"), + ("LC", "St Lucia"), + ("MF", "Saint-Martin (French part)"), + ("PM", "Saint Pierre and Miquelon"), + ("VC", "St Vincent"), + ("WS", "Samoa"), + ("SM", "San Marino"), + ("ST", "Sao Tome and Principe"), + ("SA", "Saudi Arabia"), + ("SN", "Senegal"), + ("RS", "Serbia"), + ("SC", "Seychelles"), + ("SL", "Sierra Leone"), + ("SG", "Singapore"), + ("SX", "Sint Maarten (Dutch part)"), + ("SK", "Slovakia"), + ("SI", "Slovenia"), + ("SB", "Solomon Islands"), + ("SO", "Somalia"), + ("ZA", "South Africa"), + ("GS", "South Georgia and South Sandwich Islands"), + ("SS", "South Sudan"), + ("ES", "Spain"), + ("LK", "Sri Lanka"), + ("SD", "Sudan"), + ("SR", "Suriname"), + ("SJ", "Svalbard and Jan Mayen"), + ("SE", "Sweden"), + ("CH", "Switzerland"), + ("SY", "Syria"), + ("TW", "Taiwan"), + ("TJ", "Tajikistan"), + ("TZ", "Tanzania"), + ("TH", "Thailand"), + ("TL", "East Timor"), + ("TG", "Togo"), + ("TK", "Tokelau"), + ("TO", "Tonga"), + ("TT", "Trinidad and Tobago"), + ("TN", "Tunisia"), + ("TR", "Turkey"), + ("TM", "Turkmenistan"), + ("TC", "Turks and Caicos Islands"), + ("TV", "Tuvalu"), + ("UG", "Uganda"), + ("UA", "Ukraine"), + ("AE", "United Arab Emirates"), + ("GB", "United Kingdom"), + _country_not_found("UM", "United States Minor Outlying Islands"), + ("US", "United States"), + ("UY", "Uruguay"), + ("UZ", "Uzbekistan"), + ("VU", "Vanuatu"), + ("VE", "Venezuela"), + ("VN", "Vietnam"), + ("VG", "British Virgin Islands"), + ("VI", "United States Virgin Islands"), + ("WF", "Wallis and Futuna"), + ("EH", "Western Sahara"), + ("YE", "Yemen"), + ("ZM", "Zambia"), + ("ZW", "Zimbabwe"), + ("AX", "Åland Islands"), + ), +) +def test_iso_alpha_2_country_codes(alpha_2, expected_name): + assert Country(alpha_2).canonical_name == expected_name + + +@pytest.mark.parametrize( + "alpha_3, expected_name", + ( + _country_not_found("AFG", "Afghanistan"), + _country_not_found("ALB", "Albania"), + _country_not_found("DZA", "Algeria"), + _country_not_found("ASM", "American Samoa"), + _country_not_found("AND", "Andorra"), + _country_not_found("AGO", "Angola"), + _country_not_found("AIA", "Anguilla"), + _country_not_found("ATA", "Antarctica"), + _country_not_found("ATG", "Antigua and Barbuda"), + _country_not_found("ARG", "Argentina"), + _country_not_found("ARM", "Armenia"), + _country_not_found("ABW", "Aruba"), + _country_not_found("AUS", "Australia"), + _country_not_found("AUT", "Austria"), + _country_not_found("AZE", "Azerbaijan"), + _country_not_found("BHS", "The Bahamas"), + _country_not_found("BHR", "Bahrain"), + _country_not_found("BGD", "Bangladesh"), + _country_not_found("BRB", "Barbados"), + _country_not_found("BLR", "Belarus"), + _country_not_found("BEL", "Belgium"), + _country_not_found("BLZ", "Belize"), + _country_not_found("BEN", "Benin"), + _country_not_found("BMU", "Bermuda"), + _country_not_found("BTN", "Bhutan"), + _country_not_found("BOL", "Bolivia"), + _country_not_found("BES", "Bonaire, Sint Eustatius and Saba"), + ("BIH", "Bosnia and Herzegovina"), + _country_not_found("BWA", "Botswana"), + _country_not_found("BVT", "Bouvet Island"), + _country_not_found("BRA", "Brazil"), + ("IOT", "British Indian Ocean Territory"), + _country_not_found("BRN", "Brunei"), + _country_not_found("BGR", "Bulgaria"), + _country_not_found("BFA", "Burkina Faso"), + _country_not_found("BDI", "Burundi"), + _country_not_found("CPV", "Cape Verde"), + _country_not_found("KHM", "Cambodia"), + _country_not_found("CMR", "Cameroon"), + _country_not_found("CAN", "Canada"), + _country_not_found("CYM", "Cayman Islands"), + _country_not_found("CAF", "Central African Republic"), + _country_not_found("TCD", "Chad"), + _country_not_found("CHL", "Chile"), + _country_not_found("CHN", "China"), + _country_not_found("CXR", "Christmas Island"), + _country_not_found("CCK", "Cocos (Keeling) Islands"), + _country_not_found("COL", "Colombia"), + _country_not_found("COM", "Comoros"), + _country_not_found("COD", "Congo (Democratic Republic)"), + _country_not_found("COG", "Congo"), + _country_not_found("COK", "Cook Islands"), + _country_not_found("CRI", "Costa Rica"), + _country_not_found("HRV", "Croatia"), + _country_not_found("CUB", "Cuba"), + _country_not_found("CUW", "Curaçao"), + _country_not_found("CYP", "Cyprus"), + _country_not_found("CZE", "Czechia"), + _country_not_found("CIV", "Ivory Coast"), + _country_not_found("DNK", "Denmark"), + _country_not_found("DJI", "Djibouti"), + _country_not_found("DMA", "Dominica"), + _country_not_found("DOM", "Dominican Republic"), + _country_not_found("ECU", "Ecuador"), + _country_not_found("EGY", "Egypt"), + _country_not_found("SLV", "El Salvador"), + _country_not_found("GNQ", "Equatorial Guinea"), + _country_not_found("ERI", "Eritrea"), + _country_not_found("EST", "Estonia"), + _country_not_found("SWZ", "Eswatini"), + _country_not_found("ETH", "Ethiopia"), + _country_not_found("FLK", "Falkland Islands"), + _country_not_found("FRO", "Faroe Islands"), + _country_not_found("FJI", "Fiji"), + _country_not_found("FIN", "Finland"), + _country_not_found("FRA", "France"), + _country_not_found("GUF", "French Guiana"), + _country_not_found("PYF", "French Polynesia"), + _country_not_found("ATF", "French Southern Territories"), + _country_not_found("GAB", "Gabon"), + _country_not_found("GMB", "The Gambia"), + _country_not_found("GEO", "Georgia"), + _country_not_found("DEU", "Germany"), + _country_not_found("GHA", "Ghana"), + _country_not_found("GIB", "Gibraltar"), + _country_not_found("GRC", "Greece"), + _country_not_found("GRL", "Greenland"), + _country_not_found("GRD", "Grenada"), + _country_not_found("GLP", "Guadeloupe"), + _country_not_found("GUM", "Guam"), + _country_not_found("GTM", "Guatemala"), + _country_not_found("GGY", "United Kingdom"), + _country_not_found("GIN", "Guinea"), + _country_not_found("GNB", "Guinea-Bissau"), + _country_not_found("GUY", "Guyana"), + _country_not_found("HTI", "Haiti"), + _country_not_found("HMD", "Heard Island and McDonald Islands"), + _country_not_found("VAT", "Vatican City"), + _country_not_found("HND", "Honduras"), + _country_not_found("HKG", "Hong Kong"), + _country_not_found("HUN", "Hungary"), + _country_not_found("ISL", "Iceland"), + _country_not_found("IND", "India"), + _country_not_found("IDN", "Indonesia"), + _country_not_found("IRN", "Iran"), + _country_not_found("IRQ", "Iraq"), + _country_not_found("IRL", "Ireland"), + _country_not_found("IMN", "United Kingdom"), + _country_not_found("ISR", "Israel"), + _country_not_found("ITA", "Italy"), + _country_not_found("JAM", "Jamaica"), + _country_not_found("JPN", "Japan"), + _country_not_found("JEY", "United Kingdom"), + _country_not_found("JOR", "Jordan"), + _country_not_found("KAZ", "Kazakhstan"), + _country_not_found("KEN", "Kenya"), + _country_not_found("KIR", "Kiribati"), + ("PRK", "North Korea"), + _country_not_found("KOR", "Korea"), + _country_not_found("KWT", "Kuwait"), + _country_not_found("KGZ", "Kyrgyzstan"), + ("LAO", "Laos"), + _country_not_found("LVA", "Latvia"), + _country_not_found("LBN", "Lebanon"), + _country_not_found("LSO", "Lesotho"), + _country_not_found("LBR", "Liberia"), + _country_not_found("LBY", "Libya"), + _country_not_found("LIE", "Liechtenstein"), + _country_not_found("LTU", "Lithuania"), + _country_not_found("LUX", "Luxembourg"), + _country_not_found("MAC", "Macao"), + _country_not_found("MDG", "Madagascar"), + _country_not_found("MWI", "Malawi"), + _country_not_found("MYS", "Malaysia"), + _country_not_found("MDV", "Maldives"), + _country_not_found("MLI", "Mali"), + _country_not_found("MLT", "Malta"), + _country_not_found("MHL", "Marshall Islands"), + _country_not_found("MTQ", "Martinique"), + _country_not_found("MRT", "Mauritania"), + _country_not_found("MUS", "Mauritius"), + _country_not_found("MYT", "Mayotte"), + _country_not_found("MEX", "Mexico"), + _country_not_found("FSM", "Micronesia"), + _country_not_found("MDA", "Moldova"), + _country_not_found("MCO", "Monaco"), + _country_not_found("MNG", "Mongolia"), + _country_not_found("MNE", "Montenegro"), + _country_not_found("MSR", "Montserrat"), + _country_not_found("MAR", "Morocco"), + _country_not_found("MOZ", "Mozambique"), + _country_not_found("MMR", "Myanmar (Burma)"), + _country_not_found("NAM", "Namibia"), + _country_not_found("NRU", "Nauru"), + _country_not_found("NPL", "Nepal"), + _country_not_found("NLD", "Netherlands"), + _country_not_found("NCL", "New Caledonia"), + _country_not_found("NZL", "New Zealand"), + _country_not_found("NIC", "Nicaragua"), + _country_not_found("NER", "Niger"), + _country_not_found("NGA", "Nigeria"), + _country_not_found("NIU", "Niue"), + _country_not_found("NFK", "Norfolk Island"), + _country_not_found("MKD", "North Macedonia"), + _country_not_found("MNP", "Northern Mariana Islands"), + _country_not_found("NOR", "Norway"), + _country_not_found("OMN", "Oman"), + _country_not_found("PAK", "Pakistan"), + _country_not_found("PLW", "Palau"), + _country_not_found("PSE", "Occupied Palestinian Territories"), + _country_not_found("PAN", "Panama"), + ("PNG", "Papua New Guinea"), + _country_not_found("PRY", "Paraguay"), + _country_not_found("PER", "Peru"), + _country_not_found("PHL", "Philippines"), + _country_not_found("PCN", "Pitcairn, Henderson, Ducie and Oeno Islands"), + _country_not_found("POL", "Poland"), + _country_not_found("PRT", "Portugal"), + _country_not_found("PRI", "Puerto Rico"), + _country_not_found("QAT", "Qatar"), + _country_not_found("ROU", "Romania"), + _country_not_found("RUS", "Russian Federation"), + _country_not_found("RWA", "Rwanda"), + _country_not_found("REU", "Réunion"), + _country_not_found("BLM", "Saint Barthélemy"), + _country_not_found("SHN", "Saint Helena, Ascension and Tristan da Cunha"), + _country_not_found("KNA", "St Kitts and Nevis"), + _country_not_found("LCA", "St Lucia"), + _country_not_found("MAF", "Saint-Martin (French part)"), + _country_not_found("SPM", "Saint Pierre and Miquelon"), + _country_not_found("VCT", "Saint Vincent"), + _country_not_found("WSM", "Samoa"), + _country_not_found("SMR", "San Marino"), + _country_not_found("STP", "Sao Tome and Principe"), + _country_not_found("SAU", "Saudi Arabia"), + _country_not_found("SEN", "Senegal"), + _country_not_found("SRB", "Serbia"), + _country_not_found("SYC", "Seychelles"), + _country_not_found("SLE", "Sierra Leone"), + _country_not_found("SGP", "Singapore"), + _country_not_found("SXM", "Sint Maarten (Dutch part)"), + _country_not_found("SVK", "Slovakia"), + _country_not_found("SVN", "Slovenia"), + _country_not_found("SLB", "Solomon Islands"), + _country_not_found("SOM", "Somalia"), + _country_not_found("ZAF", "South Africa"), + _country_not_found("SGS", "South Georgia and South Sandwich Islands"), + _country_not_found("SSD", "South Sudan"), + _country_not_found("ESP", "Spain"), + _country_not_found("LKA", "Sri Lanka"), + _country_not_found("SDN", "Sudan"), + _country_not_found("SUR", "Suriname"), + _country_not_found("SJM", "Svalbard and Jan Mayen"), + _country_not_found("SWE", "Sweden"), + _country_not_found("CHE", "Switzerland"), + _country_not_found("SYR", "Syrian Arab Republic"), + _country_not_found("TWN", "Taiwan"), + _country_not_found("TJK", "Tajikistan"), + _country_not_found("TZA", "Tanzania"), + _country_not_found("THA", "Thailand"), + _country_not_found("TLS", "East Timor"), + _country_not_found("TGO", "Togo"), + _country_not_found("TKL", "Tokelau"), + _country_not_found("TON", "Tonga"), + _country_not_found("TTO", "Trinidad and Tobago"), + _country_not_found("TUN", "Tunisia"), + _country_not_found("TUR", "Turkey"), + _country_not_found("TKM", "Turkmenistan"), + _country_not_found("TCA", "Turks and Caicos Islands"), + _country_not_found("TUV", "Tuvalu"), + _country_not_found("UGA", "Uganda"), + _country_not_found("UKR", "Ukraine"), + _country_not_found("ARE", "United Arab Emirates"), + ("GBR", "United Kingdom"), + _country_not_found("UMI", "United States Minor Outlying Islands"), + ("USA", "United States"), + _country_not_found("URY", "Uruguay"), + _country_not_found("UZB", "Uzbekistan"), + _country_not_found("VUT", "Vanuatu"), + _country_not_found("VEN", "Venezuela"), + _country_not_found("VNM", "Vietnam"), + _country_not_found("VGB", "British Virgin Islands"), + _country_not_found("VIR", "United States Virgin Islands"), + _country_not_found("WLF", "Wallis and Futuna"), + _country_not_found("ESH", "Western Sahara"), + _country_not_found("YEM", "Yemen"), + _country_not_found("ZMB", "Zambia"), + _country_not_found("ZWE", "Zimbabwe"), + _country_not_found("ALA", "Åland Islands"), + ), +) +def test_iso_alpha_3_country_codes(alpha_3, expected_name): + assert Country(alpha_3).canonical_name == expected_name diff --git a/tests/notifications_utils/test_field.py b/tests/notifications_utils/test_field.py new file mode 100644 index 000000000..686556b68 --- /dev/null +++ b/tests/notifications_utils/test_field.py @@ -0,0 +1,311 @@ +import pytest + +from notifications_utils.field import Field, str2bool + + +@pytest.mark.parametrize( + "content", + [ + "", + "the quick brown fox", + """ + the + quick brown + + fox + """, + "the ((quick brown fox", + "the (()) brown fox", + ], +) +def test_returns_a_string_without_placeholders(content): + assert str(Field(content)) == content + + +@pytest.mark.parametrize( + "template_content,data,expected", + [ + ("((colour))", {"colour": "red"}, "red"), + ("the quick ((colour)) fox", {"colour": "brown"}, "the quick brown fox"), + ( + "((article)) quick ((colour)) ((animal))", + {"article": "the", "colour": "brown", "animal": "fox"}, + "the quick brown fox", + ), + ("the quick (((colour))) fox", {"colour": "brown"}, "the quick (brown) fox"), + ( + "the quick ((colour)) fox", + {"colour": ""}, + "the quick alert('foo') fox", + ), + ( + "before ((placeholder)) after", + {"placeholder": ""}, + "before after", + ), + ( + "before ((placeholder)) after", + {"placeholder": " "}, + "before after", + ), + ( + "before ((placeholder)) after", + {"placeholder": True}, + "before True after", + ), + ( + "before ((placeholder)) after", + {"placeholder": False}, + "before False after", + ), + ( + "before ((placeholder)) after", + {"placeholder": 0}, + "before 0 after", + ), + ( + "before ((placeholder)) after", + {"placeholder": 0.0}, + "before 0.0 after", + ), + ( + "before ((placeholder)) after", + {"placeholder": 123}, + "before 123 after", + ), + ( + "before ((placeholder)) after", + {"placeholder": 0.1 + 0.2}, + "before 0.30000000000000004 after", + ), + ( + "before ((placeholder)) after", + {"placeholder": {"key": "value"}}, + "before {'key': 'value'} after", + ), + ( + "((warning?))", + {"warning?": "This is not a conditional"}, + "This is not a conditional", + ), + ( + "((warning?warning))", + {"warning?warning": "This is not a conditional"}, + "This is not a conditional", + ), + ( + "((warning??This is a conditional warning))", + {"warning": True}, + "This is a conditional warning", + ), + ( + "((warning??This is a conditional warning\nwith line break))", + {"warning": True}, + "This is a conditional warning\nwith line break", + ), + ("((warning??This is a conditional warning))", {"warning": False}, ""), + ], +) +def test_replacement_of_placeholders(template_content, data, expected): + assert str(Field(template_content, data)) == expected + + +@pytest.mark.parametrize( + "template_content,data,expected", + [ + ( + "((code)) is your security code", + {"code": "12345"}, + "12345 is your security code", + ), + ( + "((code)) is your security code", + {}, + "hidden is your security code", + ), + ( + "Hey ((name)), click http://example.com/reset-password/?token=((token))", + {"name": "Example"}, + ( + "Hey Example, click " + "http://example.com/reset-password/?token=" + "hidden" + ), + ), + ], +) +def test_optional_redacting_of_missing_values(template_content, data, expected): + assert ( + str(Field(template_content, data, redact_missing_personalisation=True)) + == expected + ) + + +@pytest.mark.parametrize( + "content,expected", + [ + ("((colour))", "((colour))"), + ( + "the quick ((colour)) fox", + "the quick ((colour)) fox", + ), + ( + "((article)) quick ((colour)) ((animal))", + "((article)) quick ((colour)) ((animal))", # noqa + ), + ( + """ + ((article)) quick + ((colour)) + ((animal)) + """, + """ + ((article)) quick + ((colour)) + ((animal)) + """, + ), + ( + "the quick (((colour))) fox", + "the quick (((colour))) fox", + ), + ("((warning?))", "((warning?))"), + ( + "((warning? This is not a conditional))", + "((warning? This is not a conditional))", + ), + ( + "((warning?? This is a warning))", + "((warning?? This is a warning))", + ), + ( + "((warning?? This is a warning\n text after linebreak))", + "((warning?? This is a warning\n text after linebreak))", + ), + ], +) +def test_formatting_of_placeholders(content, expected): + assert str(Field(content)) == expected + + +@pytest.mark.parametrize( + "content, values, expected", + [ + ( + "((name)) ((colour))", + {"name": "Jo"}, + "Jo ((colour))", + ), + ( + "((name)) ((colour))", + {"name": "Jo", "colour": None}, + "Jo ((colour))", + ), + ( + "((show_thing??thing)) ((colour))", + {"colour": "red"}, + "((show_thing??thing)) red", + ), + ], +) +def test_handling_of_missing_values(content, values, expected): + assert str(Field(content, values)) == expected + + +@pytest.mark.parametrize( + "value", + [ + "0", + 0, + 2, + 99.99999, + "off", + "exclude", + "no" "any random string", + "false", + False, + [], + {}, + (), + ["true"], + {"True": True}, + (True, "true", 1), + ], +) +def test_what_will_not_trigger_conditional_placeholder(value): + assert str2bool(value) is False + + +@pytest.mark.parametrize( + "value", [1, "1", "yes", "y", "true", "True", True, "include", "show"] +) +def test_what_will_trigger_conditional_placeholder(value): + assert str2bool(value) is True + + +@pytest.mark.parametrize( + "values, expected, expected_as_markdown", + [ + ( + {"placeholder": []}, + "list: ", + "list: ", + ), + ( + {"placeholder": ["", ""]}, + "list: ", + "list: ", + ), + ( + {"placeholder": [" ", " \t ", "\u180E"]}, + "list: ", + "list: ", + ), + ( + {"placeholder": ["one"]}, + "list: one", + "list: \n\n* one", + ), + ( + {"placeholder": ["one", "two"]}, + "list: one and two", + "list: \n\n* one\n* two", + ), + ( + {"placeholder": ["one", "two", "three"]}, + "list: one, two and three", + "list: \n\n* one\n* two\n* three", + ), + ( + {"placeholder": ["one", None, None]}, + "list: one", + "list: \n\n* one", + ), + ( + {"placeholder": [""]}, + 'list: , alert("foo") and ', + 'list: \n\n* \n* alert("foo")\n* ', + ), + ( + {"placeholder": [1, {"two": 2}, "three", None]}, + "list: 1, {'two': 2} and three", + "list: \n\n* 1\n* {'two': 2}\n* three", + ), + ( + {"placeholder": [[1, 2], [3, 4]]}, + "list: [1, 2] and [3, 4]", + "list: \n\n* [1, 2]\n* [3, 4]", + ), + ( + {"placeholder": [0.1, True, False]}, + "list: 0.1, True and False", + "list: \n\n* 0.1\n* True\n* False", + ), + ], +) +def test_field_renders_lists_as_strings(values, expected, expected_as_markdown): + assert ( + str(Field("list: ((placeholder))", values, markdown_lists=True)) + == expected_as_markdown + ) + assert str(Field("list: ((placeholder))", values)) == expected diff --git a/tests/notifications_utils/test_field_html_handling.py b/tests/notifications_utils/test_field_html_handling.py new file mode 100644 index 000000000..d113f816c --- /dev/null +++ b/tests/notifications_utils/test_field_html_handling.py @@ -0,0 +1,71 @@ +import pytest + +from notifications_utils.field import Field + + +@pytest.mark.parametrize( + "content, values, expected_stripped, expected_escaped, expected_passthrough", + [ + ( + "string with html", + {}, + "string with html", + "string <em>with</em> html", + "string with html", + ), + ( + "string ((with)) html", + {}, + "string ((with)) html", + "string ((<em>with</em>)) html", + "string ((with)) html", + ), + ( + "string ((placeholder)) html", + {"placeholder": "without"}, + "string without html", + "string <em>without</em> html", + "string without html", + ), + ( + "string ((conditional??placeholder)) html", + {}, + "string ((conditional??placeholder)) html", + ( + "string " + "" + "((<em>conditional</em>??" + "<em>placeholder</em>)) " + "html" + ), + ( + "string " + "" + "((conditional??" + "placeholder)) " + "html" + ), + ), + ( + "string ((conditional??placeholder)) html", + {"conditional": True}, + "string placeholder html", + "string <em>placeholder</em> html", + "string placeholder html", + ), + ( + "string & entity", + {}, + "string & entity", + "string & entity", + "string & entity", + ), + ], +) +def test_field_handles_html( + content, values, expected_stripped, expected_escaped, expected_passthrough +): + assert str(Field(content, values)) == expected_stripped + assert str(Field(content, values, html="strip")) == expected_stripped + assert str(Field(content, values, html="escape")) == expected_escaped + assert str(Field(content, values, html="passthrough")) == expected_passthrough diff --git a/tests/notifications_utils/test_formatted_list.py b/tests/notifications_utils/test_formatted_list.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/notifications_utils/test_formatters.py b/tests/notifications_utils/test_formatters.py new file mode 100644 index 000000000..c1acac5dc --- /dev/null +++ b/tests/notifications_utils/test_formatters.py @@ -0,0 +1,577 @@ +import pytest +from markupsafe import Markup + +from notifications_utils.formatters import ( + autolink_urls, + escape_html, + formatted_list, + make_quotes_smart, + normalise_whitespace, + remove_smart_quotes_from_email_addresses, + remove_whitespace_before_punctuation, + replace_hyphens_with_en_dashes, + sms_encode, + strip_all_whitespace, + strip_and_remove_obscure_whitespace, + strip_unsupported_characters, + unlink_govuk_escaped, +) +from notifications_utils.template import ( + HTMLEmailTemplate, + PlainTextEmailTemplate, + SMSMessageTemplate, + SMSPreviewTemplate, +) + + +@pytest.mark.parametrize( + "url, expected_html", + [ + ( + """https://example.com/"onclick="alert('hi')""", + """https://example.com/"onclick="alert('hi')""", # noqa + ), + ( + """https://example.com/"style='text-decoration:blink'""", + """https://example.com/"style='text-decoration:blink'""", # noqa + ), + ], +) +def test_URLs_get_escaped_in_sms(url, expected_html): + assert expected_html in str( + SMSPreviewTemplate({"content": url, "template_type": "sms"}) + ) + + +def test_HTML_template_has_URLs_replaced_with_links(): + assert ( + '' + "https://service.example.com/accept_invite/a1b2c3d4" + "" + ) in str( + HTMLEmailTemplate( + { + "content": ( + "You’ve been invited to a service. Click this link:\n" + "https://service.example.com/accept_invite/a1b2c3d4\n" + "\n" + "Thanks\n" + ), + "subject": "", + "template_type": "email", + } + ) + ) + + +def test_escaping_govuk_in_email_templates(): + template_content = "GOV.UK" + expected = "GOV.\u200BUK" + assert unlink_govuk_escaped(template_content) == expected + template_json = { + "content": template_content, + "subject": "", + "template_type": "email", + } + assert expected in str(PlainTextEmailTemplate(template_json)) + assert expected in str(HTMLEmailTemplate(template_json)) + + +@pytest.mark.parametrize( + "template_content,expected", + [ + # Cases that we add the breaking space + ("GOV.UK", "GOV.\u200BUK"), + ("gov.uk", "gov.\u200Buk"), + ( + "content with space infront GOV.UK", + "content with space infront GOV.\u200BUK", + ), + ("content with tab infront\tGOV.UK", "content with tab infront\tGOV.\u200BUK"), + ( + "content with newline infront\nGOV.UK", + "content with newline infront\nGOV.\u200BUK", + ), + ("*GOV.UK", "*GOV.\u200BUK"), + ("#GOV.UK", "#GOV.\u200BUK"), + ("^GOV.UK", "^GOV.\u200BUK"), + (" #GOV.UK", " #GOV.\u200BUK"), + ("GOV.UK with CONTENT after", "GOV.\u200BUK with CONTENT after"), + ("#GOV.UK with CONTENT after", "#GOV.\u200BUK with CONTENT after"), + # Cases that we don't add the breaking space + ("https://gov.uk", "https://gov.uk"), + ("https://www.gov.uk", "https://www.gov.uk"), + ("www.gov.uk", "www.gov.uk"), + ("WWW.GOV.UK", "WWW.GOV.UK"), + ("WWW.GOV.UK.", "WWW.GOV.UK."), + ( + "https://www.gov.uk/?utm_source=gov.uk", + "https://www.gov.uk/?utm_source=gov.uk", + ), + ("mygov.uk", "mygov.uk"), + ("www.this-site-is-not-gov.uk", "www.this-site-is-not-gov.uk"), + ( + "www.gov.uk?websites=bbc.co.uk;gov.uk;nsh.scot", + "www.gov.uk?websites=bbc.co.uk;gov.uk;nsh.scot", + ), + ("reply to: xxxx@xxx.gov.uk", "reply to: xxxx@xxx.gov.uk"), + ("southwark.gov.uk", "southwark.gov.uk"), + ("data.gov.uk", "data.gov.uk"), + ("gov.uk/foo", "gov.uk/foo"), + ("*GOV.UK/foo", "*GOV.UK/foo"), + ("#GOV.UK/foo", "#GOV.UK/foo"), + ("^GOV.UK/foo", "^GOV.UK/foo"), + ("gov.uk#departments-and-policy", "gov.uk#departments-and-policy"), + # Cases that we know currently aren't supported by our regex and have a non breaking space added when they + # shouldn't however, we accept the fact that our regex isn't perfect as we think the chance of a user using a + # URL like this in their content is very small. + # We document these edge cases here + pytest.param("gov.uk.com", "gov.uk.com", marks=pytest.mark.xfail), + pytest.param("gov.ukandi.com", "gov.ukandi.com", marks=pytest.mark.xfail), + pytest.param("gov.uks", "gov.uks", marks=pytest.mark.xfail), + ], +) +def test_unlink_govuk_escaped(template_content, expected): + assert unlink_govuk_escaped(template_content) == expected + + +@pytest.mark.parametrize( + "prefix, body, expected", + [ + ("a", "b", "a: b"), + (None, "b", "b"), + ], +) +def test_sms_message_adds_prefix(prefix, body, expected): + template = SMSMessageTemplate({"content": body, "template_type": "sms"}) + template.prefix = prefix + template.sender = None + assert str(template) == expected + + +def test_sms_preview_adds_newlines(): + template = SMSPreviewTemplate( + { + "content": """ + the + quick + + brown fox + """, + "template_type": "sms", + } + ) + template.prefix = None + template.sender = None + assert "
    " in str(template) + + +def test_sms_encode(mocker): + sanitise_mock = mocker.patch("notifications_utils.formatters.SanitiseSMS") + assert sms_encode("foo") == sanitise_mock.encode.return_value + sanitise_mock.encode.assert_called_once_with("foo") + + +@pytest.mark.parametrize( + "items, kwargs, expected_output", + [ + ([1], {}, "‘1’"), + ([1, 2], {}, "‘1’ and ‘2’"), + ([1, 2, 3], {}, "‘1’, ‘2’ and ‘3’"), + ([1, 2, 3], {"prefix": "foo", "prefix_plural": "bar"}, "bar ‘1’, ‘2’ and ‘3’"), + ([1], {"prefix": "foo", "prefix_plural": "bar"}, "foo ‘1’"), + ([1, 2, 3], {"before_each": "a", "after_each": "b"}, "a1b, a2b and a3b"), + ([1, 2, 3], {"conjunction": "foo"}, "‘1’, ‘2’ foo ‘3’"), + (["&"], {"before_each": "", "after_each": ""}, "&"), + ( + [1, 2, 3], + {"before_each": "", "after_each": ""}, + "1, 2 and 3", + ), + ], +) +def test_formatted_list(items, kwargs, expected_output): + assert formatted_list(items, **kwargs) == expected_output + + +def test_formatted_list_returns_markup(): + assert isinstance(formatted_list([0]), Markup) + + +def test_bleach_doesnt_try_to_make_valid_html_before_cleaning(): + assert escape_html("") == ( + "<to cancel daily cat facts reply 'cancel'>" + ) + + +@pytest.mark.parametrize( + "content, expected_escaped", + ( + ("&?a;", "&?a;"), + ("&>a;", "&>a;"), + ("&*a;", "&*a;"), + ("&a?;", "&a?;"), + ("&x?xa;", "&x?xa;"), + # We need to be careful that query arguments don’t get turned into entities + ("×tamp=×", "&timestamp=×"), + ("×=1,2,3", "&times=1,2,3"), + # − should have a trailing semicolon according to the HTML5 + # spec but µ doesn’t need one + ("2−1", "2−1"), + ("200µg", "200µg"), + # …we ignore it when it’s ambiguous + ("2&minus1", "2&minus1"), + ("200µg", "200&microg"), + # …we still ignore when there’s a space afterwards + ("2 &minus 1", "2 &minus 1"), + ("200µ g", "200&micro g"), + # Things which aren’t real entities are ignored, not removed + ("This &isnotarealentity;", "This &isnotarealentity;"), + # We let users use   for backwards compatibility + ("Before after", "Before after"), + # We let users use & because it’s often pasted in URLs + ("?a=1&b=2", "?a=1&b=2"), + # We let users use ( and ) because otherwise it’s + # impossible to put brackets in the body of conditional placeholders + ("((var??(in brackets)))", "((var??(in brackets)))"), + ), +) +def test_escaping_html_entities( + content, + expected_escaped, +): + assert escape_html(content) == expected_escaped + + +@pytest.mark.parametrize( + "dirty, clean", + [ + ( + "Hello ((name)) ,\n\nThis is a message", + "Hello ((name)),\n\nThis is a message", + ), + ("Hello Jo ,\n\nThis is a message", "Hello Jo,\n\nThis is a message"), + ( + "\n \t , word", + "\n, word", + ), + ], +) +def test_removing_whitespace_before_commas(dirty, clean): + assert remove_whitespace_before_punctuation(dirty) == clean + + +@pytest.mark.parametrize( + "dirty, clean", + [ + ( + "Hello ((name)) .\n\nThis is a message", + "Hello ((name)).\n\nThis is a message", + ), + ("Hello Jo .\n\nThis is a message", "Hello Jo.\n\nThis is a message"), + ( + "\n \t . word", + "\n. word", + ), + ], +) +def test_removing_whitespace_before_full_stops(dirty, clean): + assert remove_whitespace_before_punctuation(dirty) == clean + + +@pytest.mark.parametrize( + "dumb, smart", + [ + ( + """And I said, "what about breakfast at Tiffany's"?""", + """And I said, “what about breakfast at Tiffany’s”?""", + ), + ( + """ + http://example.com?q='foo' + """, + """ + http://example.com?q='foo' + """, + ), + ], +) +def test_smart_quotes(dumb, smart): + assert make_quotes_smart(dumb) == smart + + +@pytest.mark.parametrize( + "nasty, nice", + [ + ( + ( + "The en dash - always with spaces in running text when, as " + "discussed in this section, indicating a parenthesis or " + "pause - and the spaced em dash both have a certain " + "technical advantage over the unspaced em dash. " + ), + ( + "The en dash \u2013 always with spaces in running text when, as " + "discussed in this section, indicating a parenthesis or " + "pause \u2013 and the spaced em dash both have a certain " + "technical advantage over the unspaced em dash. " + ), + ), + ( + "double -- dash", + "double \u2013 dash", + ), + ( + "triple --- dash", + "triple \u2013 dash", + ), + ( + "quadruple ---- dash", + "quadruple ---- dash", + ), + ( + "em — dash", + "em – dash", + ), + ( + "already\u0020–\u0020correct", # \u0020 is a normal space character + "already\u0020–\u0020correct", + ), + ( + "2004-2008", + "2004-2008", # no replacement + ), + ], +) +def test_en_dashes(nasty, nice): + assert replace_hyphens_with_en_dashes(nasty) == nice + + +def test_unicode_dash_lookup(): + en_dash_replacement_sequence = "\u0020\u2013" + hyphen = "-" + en_dash = "–" + space = " " + non_breaking_space = " " + assert en_dash_replacement_sequence == space + en_dash + assert non_breaking_space not in en_dash_replacement_sequence + assert hyphen not in en_dash_replacement_sequence + + +@pytest.mark.parametrize( + "value", + [ + "bar", + " bar ", + """ + \t bar + """, + " \u180E\u200B \u200C bar \u200D \u2060\uFEFF ", + ], +) +def test_strip_all_whitespace(value): + assert strip_all_whitespace(value) == "bar" + + +@pytest.mark.parametrize( + "value", + [ + "notifications-email", + " \tnotifications-email \x0c ", + "\rn\u200Coti\u200Dfi\u200Bcati\u2060ons-\u180Eemai\uFEFFl\uFEFF", + ], +) +def test_strip_and_remove_obscure_whitespace(value): + assert strip_and_remove_obscure_whitespace(value) == "notifications-email" + + +def test_strip_and_remove_obscure_whitespace_only_removes_normal_whitespace_from_ends(): + sentence = " words \n over multiple lines with \ttabs\t " + assert ( + strip_and_remove_obscure_whitespace(sentence) + == "words \n over multiple lines with \ttabs" + ) + + +def test_remove_smart_quotes_from_email_addresses(): + assert ( + remove_smart_quotes_from_email_addresses( + """ + line one’s quote + first.o’last@example.com is someone’s email address + line ‘three’ + """ + ) + == ( + """ + line one’s quote + first.o'last@example.com is someone’s email address + line ‘three’ + """ + ) + ) + + +def test_strip_unsupported_characters(): + assert strip_unsupported_characters("line one\u2028line two") == ( + "line oneline two" + ) + + +@pytest.mark.parametrize( + "value", + [ + "\u200C Your tax is\ndue\n\n", + " Your tax is due ", + # Non breaking spaces replaced by single spaces + "\u00A0Your\u00A0tax\u00A0 is\u00A0\u00A0due\u00A0", + # zero width spaces are removed + "\u180EYour \u200Btax\u200C is \u200D\u2060due \uFEFF", + # tabs are replaced by single spaces + "\tYour tax\tis due ", + ], +) +def test_normalise_whitespace(value): + assert normalise_whitespace(value) == "Your tax is due" + + +@pytest.mark.parametrize( + "content, expected_html", + ( + ( + "http://example.com", + 'http://example.com', + ), + ( + "https://example.com", + 'https://example.com', + ), + ( + "example.com", + 'example.com', + ), + ( + "www.foo.bar.example.com", + 'www.foo.bar.example.com', + ), + ( + "example.com/", + 'example.com/', + ), + ( + "www.foo.bar.example.com/", + 'www.foo.bar.example.com/', + ), + ( + "example.com/foo", + 'example.com/foo', + ), + ( + "example.com?foo", + 'example.com?foo', + ), + ( + "example.com#foo", + 'example.com#foo', + ), + ( + "Go to gov.uk/example.", + "Go to " 'gov.uk/example.', + ), + ( + "Go to gov.uk/example:", + "Go to " 'gov.uk/example:', + ), + ( + "Go to gov.uk/example;", + "Go to " 'gov.uk/example;', + ), + ( + "(gov.uk/example)", + "(" 'gov.uk/example)', + ), + ( + "(gov.uk/example)...", + "(" 'gov.uk/example)...', + ), + ( + "(gov.uk/example.)", + "(" 'gov.uk/example.)', + ), + ( + "(see example.com/foo_(bar))", + "(see " + 'example.com/foo_(bar))', + ), + ( + "example.com/foo(((((((bar", + 'example.com/foo(((((((bar', + ), + ( + "government website (gov.uk). Other websites…", + "government website (" + 'gov.uk). Other websites…', + ), + ( + "[gov.uk/example]", + "[" 'gov.uk/example]', + ), + ( + "gov.uk/foo, gov.uk/bar", + 'gov.uk/foo, ' + 'gov.uk/bar', + ), + ( + "

    gov.uk/foo

    ", + "

    " 'gov.uk/foo

    ', + ), + ( + "gov.uk?foo&", + 'gov.uk?foo&', + ), + ( + "a .service.gov.uk domain", + "a .service.gov.uk domain", + ), + ( + 'http://foo.com/"bar"?x=1#2', + 'http://foo.com/"bar"?x=1#2', + ), + ( + "firstname.lastname@example.com", + "firstname.lastname@example.com", + ), + ( + "with-subdomain@test.example.com", + "with-subdomain@test.example.com", + ), + ), +) +def test_autolink_urls_matches_correctly(content, expected_html): + assert autolink_urls(content) == expected_html + + +@pytest.mark.parametrize( + "extra_kwargs, expected_html", + ( + ( + {}, + 'http://example.com', + ), + ( + { + "classes": "govuk-link", + }, + 'http://example.com', + ), + ), +) +def test_autolink_urls_applies_correct_attributes(extra_kwargs, expected_html): + assert autolink_urls("http://example.com", **extra_kwargs) == expected_html + + +@pytest.mark.parametrize( + "content", ("without link", "with link to https://example.com") +) +def test_autolink_urls_returns_markup(content): + assert isinstance(autolink_urls(content), Markup) diff --git a/tests/notifications_utils/test_insensitive_dict.py b/tests/notifications_utils/test_insensitive_dict.py new file mode 100644 index 000000000..ae7f62f9c --- /dev/null +++ b/tests/notifications_utils/test_insensitive_dict.py @@ -0,0 +1,96 @@ +from functools import partial + +import pytest + +from notifications_utils.insensitive_dict import InsensitiveDict +from notifications_utils.recipients import Cell, Row + + +def test_columns_as_dict_with_keys(): + assert InsensitiveDict( + {"Date of Birth": "01/01/2001", "TOWN": "London"} + ).as_dict_with_keys({"date_of_birth", "town"}) == { + "date_of_birth": "01/01/2001", + "town": "London", + } + + +def test_columns_as_dict(): + assert dict(InsensitiveDict({"date of birth": "01/01/2001", "TOWN": "London"})) == { + "dateofbirth": "01/01/2001", + "town": "London", + } + + +def test_missing_data(): + partial_row = partial( + Row, + row_dict={}, + index=1, + error_fn=None, + recipient_column_headers=[], + placeholders=[], + template=None, + allow_international_letters=False, + ) + with pytest.raises(KeyError): + InsensitiveDict({})["foo"] + assert InsensitiveDict({}).get("foo") is None + assert InsensitiveDict({}).get("foo", "bar") == "bar" + assert partial_row()["foo"] == Cell() + assert partial_row().get("foo") == Cell() + assert partial_row().get("foo", "bar") == "bar" + + +@pytest.mark.parametrize( + "in_dictionary", + [ + {"foo": "bar"}, + {"F_O O": "bar"}, + ], +) +@pytest.mark.parametrize( + "key, should_be_present", + [ + ("foo", True), + ("f_o_o", True), + ("F O O", True), + ("bar", False), + ], +) +def test_lookup(key, should_be_present, in_dictionary): + assert (key in InsensitiveDict(in_dictionary)) == should_be_present + + +@pytest.mark.parametrize( + "key_in", + [ + "foo", + "F_O O", + ], +) +@pytest.mark.parametrize( + "lookup_key", + [ + "foo", + "f_o_o", + "F O O", + ], +) +def test_set_item(key_in, lookup_key): + columns = InsensitiveDict({}) + columns[key_in] = "bar" + assert columns[lookup_key] == "bar" + + +def test_maintains_insertion_order(): + d = InsensitiveDict( + { + "B": None, + "A": None, + "C": None, + } + ) + assert d.keys() == ["b", "a", "c"] + d["BB"] = None + assert d.keys() == ["b", "a", "c", "bb"] diff --git a/tests/notifications_utils/test_international_billing_rates.py b/tests/notifications_utils/test_international_billing_rates.py new file mode 100644 index 000000000..5e68d767b --- /dev/null +++ b/tests/notifications_utils/test_international_billing_rates.py @@ -0,0 +1,50 @@ +import pytest + +from notifications_utils.international_billing_rates import ( + COUNTRY_PREFIXES, + INTERNATIONAL_BILLING_RATES, +) +from notifications_utils.recipients import use_numeric_sender + + +def test_international_billing_rates_exists(): + assert INTERNATIONAL_BILLING_RATES["1"]["names"][0] == "Canada" + + +@pytest.mark.parametrize( + "country_prefix, values", sorted(INTERNATIONAL_BILLING_RATES.items()) +) +def test_international_billing_rates_are_in_correct_format(country_prefix, values): + assert isinstance(country_prefix, str) + # we don't want the prefixes to have + at the beginning for instance + assert country_prefix.isdigit() + + assert set(values.keys()) == {"attributes", "billable_units", "names"} + + assert isinstance(values["billable_units"], int) + assert 1 <= values["billable_units"] <= 3 + + assert isinstance(values["names"], list) + assert all(isinstance(country, str) for country in values["names"]) + + assert isinstance(values["attributes"], dict) + assert values["attributes"]["dlr"] is None or isinstance( + values["attributes"]["dlr"], str + ) + + +def test_country_codes(): + assert len(COUNTRY_PREFIXES) == 214 + + +@pytest.mark.parametrize( + "number, expected", + [ + ("+48123654789", False), # Poland alpha: Yes + ("+1-403-123-5687", True), # Canada alpha: No + ("+40123548897", False), # Romania alpha: REG + ("+60123451345", True), + ], +) # Malaysia alpha: NO +def test_use_numeric_sender(number, expected): + assert use_numeric_sender(number) == expected diff --git a/tests/notifications_utils/test_letter_timings.py b/tests/notifications_utils/test_letter_timings.py new file mode 100644 index 000000000..2a90fc641 --- /dev/null +++ b/tests/notifications_utils/test_letter_timings.py @@ -0,0 +1,269 @@ +from datetime import datetime + +import pytest +import pytz +from freezegun import freeze_time + +from notifications_utils.letter_timings import ( + get_letter_timings, + letter_can_be_cancelled, +) + + +@freeze_time("2017-07-14 13:59:59") # Friday, before print deadline (3PM EST) +@pytest.mark.parametrize( + "upload_time, expected_print_time, is_printed, first_class, expected_earliest, expected_latest", + [ + # EST + # ================================================================== + # First thing Monday + ( + "Monday 2017-07-10 00:00:01", + "Tuesday 2017-07-11 15:00", + True, + "Wednesday 2017-07-12 16:00", + "Thursday 2017-07-13 16:00", + "Friday 2017-07-14 16:00", + ), + # Monday at 17:29 EST (sent on monday) + ( + "Monday 2017-07-10 16:29:59", + "Tuesday 2017-07-11 15:00", + True, + "Wednesday 2017-07-12 16:00", + "Thursday 2017-07-13 16:00", + "Friday 2017-07-14 16:00", + ), + # Monday at 17:30 EST (sent on tuesday) + ( + "Monday 2017-07-10 16:30:01", + "Wednesday 2017-07-12 15:00", + True, + "Thursday 2017-07-13 16:00", + "Friday 2017-07-14 16:00", + "Saturday 2017-07-15 16:00", + ), + # Tuesday before 17:30 EST + ( + "Tuesday 2017-07-11 12:00:00", + "Wednesday 2017-07-12 15:00", + True, + "Thursday 2017-07-13 16:00", + "Friday 2017-07-14 16:00", + "Saturday 2017-07-15 16:00", + ), + # Wednesday before 17:30 EST + ( + "Wednesday 2017-07-12 12:00:00", + "Thursday 2017-07-13 15:00", + True, + "Friday 2017-07-14 16:00", + "Saturday 2017-07-15 16:00", + "Monday 2017-07-17 16:00", + ), + # Thursday before 17:30 EST + ( + "Thursday 2017-07-13 12:00:00", + "Friday 2017-07-14 15:00", + False, + "Saturday 2017-07-15 16:00", + "Monday 2017-07-17 16:00", + "Tuesday 2017-07-18 16:00", + ), + # Friday anytime + ( + "Friday 2017-07-14 00:00:00", + "Monday 2017-07-17 15:00", + False, + "Tuesday 2017-07-18 16:00", + "Wednesday 2017-07-19 16:00", + "Thursday 2017-07-20 16:00", + ), + ( + "Friday 2017-07-14 12:00:00", + "Monday 2017-07-17 15:00", + False, + "Tuesday 2017-07-18 16:00", + "Wednesday 2017-07-19 16:00", + "Thursday 2017-07-20 16:00", + ), + ( + "Friday 2017-07-14 22:00:00", + "Monday 2017-07-17 15:00", + False, + "Tuesday 2017-07-18 16:00", + "Wednesday 2017-07-19 16:00", + "Thursday 2017-07-20 16:00", + ), + # Saturday anytime + ( + "Saturday 2017-07-14 12:00:00", + "Monday 2017-07-17 15:00", + False, + "Tuesday 2017-07-18 16:00", + "Wednesday 2017-07-19 16:00", + "Thursday 2017-07-20 16:00", + ), + # Sunday before 1730 EST + ( + "Sunday 2017-07-15 15:59:59", + "Monday 2017-07-17 15:00", + False, + "Tuesday 2017-07-18 16:00", + "Wednesday 2017-07-19 16:00", + "Thursday 2017-07-20 16:00", + ), + # Sunday after 17:30 EST + ( + "Sunday 2017-07-16 16:30:01", + "Tuesday 2017-07-18 15:00", + False, + "Wednesday 2017-07-19 16:00", + "Thursday 2017-07-20 16:00", + "Friday 2017-07-21 16:00", + ), + # GMT + # ================================================================== + # Monday at 17:29 GMT + ( + "Monday 2017-01-02 17:29:59", + "Tuesday 2017-01-03 15:00", + True, + "Wednesday 2017-01-04 16:00", + "Thursday 2017-01-05 16:00", + "Friday 2017-01-06 16:00", + ), + # Monday at 17:00 GMT + ( + "Monday 2017-01-02 17:30:01", + "Wednesday 2017-01-04 15:00", + True, + "Thursday 2017-01-05 16:00", + "Friday 2017-01-06 16:00", + "Saturday 2017-01-07 16:00", + ), + ], +) +@pytest.mark.skip(reason="Letters being developed later") +def test_get_estimated_delivery_date_for_letter( + upload_time, + expected_print_time, + is_printed, + first_class, + expected_earliest, + expected_latest, +): + # remove the day string from the upload_time, which is purely informational + + def format_dt(x): + return x.astimezone(pytz.timezone("America/New_York")).strftime( + "%A %Y-%m-%d %H:%M" + ) + + upload_time = upload_time.split(" ", 1)[1] + + timings = get_letter_timings(upload_time, postage="second") + + assert format_dt(timings.printed_by) == expected_print_time + assert timings.is_printed == is_printed + assert format_dt(timings.earliest_delivery) == expected_earliest + assert format_dt(timings.latest_delivery) == expected_latest + + first_class_timings = get_letter_timings(upload_time, postage="first") + + assert format_dt(first_class_timings.printed_by) == expected_print_time + assert first_class_timings.is_printed == is_printed + assert format_dt(first_class_timings.earliest_delivery) == first_class + assert format_dt(first_class_timings.latest_delivery) == first_class + + +@pytest.mark.parametrize("status", ["sending", "pending"]) +def test_letter_cannot_be_cancelled_if_letter_status_is_not_created_or_pending_virus_check( + status, +): + notification_created_at = datetime.utcnow() + + assert not letter_can_be_cancelled(status, notification_created_at) + + +@freeze_time("2018-7-7 16:00:00") +@pytest.mark.parametrize( + "notification_created_at", + [ + datetime(2018, 7, 6, 18, 0), # created yesterday after 1730 + datetime(2018, 7, 7, 12, 0), # created today + ], +) +@pytest.mark.skip(reason="Letters not part of release") +def test_letter_can_be_cancelled_if_before_1730_and_letter_created_before_1730( + notification_created_at, +): + notification_status = "pending-virus-check" + + assert letter_can_be_cancelled(notification_status, notification_created_at) + + +@freeze_time("2017-12-12 17:30:00") +@pytest.mark.parametrize( + "notification_created_at", + [ + datetime(2017, 12, 12, 17, 0), + datetime(2017, 12, 12, 17, 30), + ], +) +@pytest.mark.skip(reason="Letters not part of release") +def test_letter_cannot_be_cancelled_if_1730_exactly_and_letter_created_at_or_before_1730( + notification_created_at, +): + notification_status = "pending-virus-check" + + assert not letter_can_be_cancelled(notification_status, notification_created_at) + + +@freeze_time("2018-7-7 19:00:00") +@pytest.mark.parametrize( + "notification_created_at", + [ + datetime(2018, 7, 6, 18, 0), # created yesterday after 1730 + datetime(2018, 7, 7, 12, 0), # created today before 1730 + ], +) +@pytest.mark.skip(reason="Letters not part of release") +def test_letter_cannot_be_cancelled_if_after_1730_and_letter_created_before_1730( + notification_created_at, +): + notification_status = "created" + + assert not letter_can_be_cancelled(notification_status, notification_created_at) + + +@freeze_time("2018-7-7 15:00:00") +@pytest.mark.skip(reason="Letters not part of release") +def test_letter_cannot_be_cancelled_if_before_1730_and_letter_created_before_1730_yesterday(): + notification_status = "created" + + assert not letter_can_be_cancelled(notification_status, datetime(2018, 7, 6, 14, 0)) + + +@freeze_time("2018-7-7 15:00:00") +@pytest.mark.skip(reason="Letters not part of release") +def test_letter_cannot_be_cancelled_if_before_1730_and_letter_created_after_1730_two_days_ago(): + notification_status = "created" + + assert not letter_can_be_cancelled(notification_status, datetime(2018, 7, 5, 19, 0)) + + +@freeze_time("2018-7-7 19:00:00") +@pytest.mark.parametrize( + "notification_created_at", + [ + datetime(2018, 7, 7, 18, 30), + datetime(2018, 7, 7, 19, 0), + ], +) +def test_letter_can_be_cancelled_if_after_1730_and_letter_created_at_1730_today_or_later( + notification_created_at, +): + notification_status = "created" + + assert letter_can_be_cancelled(notification_status, notification_created_at) diff --git a/tests/notifications_utils/test_logging.py b/tests/notifications_utils/test_logging.py new file mode 100644 index 000000000..cc7f5ee34 --- /dev/null +++ b/tests/notifications_utils/test_logging.py @@ -0,0 +1,51 @@ +import json +import logging as builtin_logging + +from notifications_utils import logging + + +def test_get_handlers_sets_up_logging_appropriately_with_debug(): + class App: + config = {"NOTIFY_APP_NAME": "bar", "NOTIFY_LOG_LEVEL": "ERROR"} + debug = True + + app = App() + + handlers = logging.get_handlers(app) + + assert len(handlers) == 1 + assert isinstance(handlers[0], builtin_logging.StreamHandler) + assert isinstance(handlers[0].formatter, builtin_logging.Formatter) + + +def test_get_handlers_sets_up_logging_appropriately_without_debug(): + class App: + config = {"NOTIFY_APP_NAME": "bar", "NOTIFY_LOG_LEVEL": "ERROR"} + debug = False + + app = App() + + handlers = logging.get_handlers(app) + + assert len(handlers) == 1 + assert isinstance(handlers[0], builtin_logging.StreamHandler) + assert isinstance(handlers[0].formatter, logging.JSONFormatter) + + +def test_base_json_formatter_contains_service_id(): + record = builtin_logging.LogRecord( + name="log thing", + level="info", + pathname="path", + lineno=123, + msg="message to log", + exc_info=None, + args=None, + ) + + service_id_filter = logging.ServiceIdFilter() + assert ( + json.loads(logging.BaseJSONFormatter().format(record))["message"] + == "message to log" + ) + assert service_id_filter.filter(record).service_id == "no-service-id" diff --git a/tests/notifications_utils/test_markdown.py b/tests/notifications_utils/test_markdown.py new file mode 100644 index 000000000..550f56b58 --- /dev/null +++ b/tests/notifications_utils/test_markdown.py @@ -0,0 +1,667 @@ +import pytest + +from notifications_utils.markdown import ( + notify_email_markdown, + notify_letter_preview_markdown, + notify_plain_text_email_markdown, +) +from notifications_utils.template import HTMLEmailTemplate + + +@pytest.mark.parametrize( + "url", + [ + "http://example.com", + "http://www.gov.uk/", + "https://www.gov.uk/", + "http://service.gov.uk", + "http://service.gov.uk/blah.ext?q=a%20b%20c&order=desc#fragment", + pytest.param( + "http://service.gov.uk/blah.ext?q=one two three", marks=pytest.mark.xfail + ), + ], +) +def test_makes_links_out_of_URLs(url): + link = '{}'.format( + url, url + ) + assert notify_email_markdown(url) == ( + '

    ' + "{}" + "

    " + ).format(link) + + +@pytest.mark.parametrize( + "input, output", + [ + ( + ("this is some text with a link http://example.com in the middle"), + ( + "this is some text with a link " + 'http://example.com' + " in the middle" + ), + ), + ( + ("this link is in brackets (http://example.com)"), + ( + "this link is in brackets " + '(http://example.com)' + ), + ), + ], +) +def test_makes_links_out_of_URLs_in_context(input, output): + assert notify_email_markdown(input) == ( + '

    ' + "{}" + "

    " + ).format(output) + + +@pytest.mark.parametrize( + "url", + [ + "example.com", + "www.example.com", + "ftp://example.com", + "test@example.com", + "mailto:test@example.com", + 'Example', + ], +) +def test_doesnt_make_links_out_of_invalid_urls(url): + assert notify_email_markdown(url) == ( + '

    ' + "{}" + "

    " + ).format(url) + + +def test_handles_placeholders_in_urls(): + assert notify_email_markdown( + "http://example.com/?token=((token))&key=1" + ) == ( + '

    ' + '' + "http://example.com/?token=" + "" + "((token))&key=1" + "

    " + ) + + +@pytest.mark.parametrize( + "url, expected_html, expected_html_in_template", + [ + ( + """https://example.com"onclick="alert('hi')""", + """https://example.com"onclick="alert('hi')""", # noqa + """https://example.com"onclick="alert('hi‘)""", # noqa + ), + ( + """https://example.com"style='text-decoration:blink'""", + """https://example.com"style='text-decoration:blink'""", # noqa + """https://example.com"style='text-decoration:blink’""", # noqa + ), + ], +) +def test_URLs_get_escaped(url, expected_html, expected_html_in_template): + assert notify_email_markdown(url) == ( + '

    ' + "{}" + "

    " + ).format(expected_html) + assert expected_html_in_template in str( + HTMLEmailTemplate( + { + "content": url, + "subject": "", + "template_type": "email", + } + ) + ) + + +@pytest.mark.parametrize( + "markdown_function, expected_output", + [ + ( + notify_email_markdown, + ( + '

    ' + '' + "https://example.com" + "" + "

    " + '

    ' + "Next paragraph" + "

    " + ), + ), + ( + notify_plain_text_email_markdown, + ("\n" "\nhttps://example.com" "\n" "\nNext paragraph"), + ), + ], +) +def test_preserves_whitespace_when_making_links(markdown_function, expected_output): + assert ( + markdown_function("https://example.com\n" "\n" "Next paragraph") + == expected_output + ) + + +@pytest.mark.parametrize( + "markdown_function, expected", + ( + [notify_letter_preview_markdown, 'print("hello")'], + [notify_email_markdown, 'print("hello")'], + [notify_plain_text_email_markdown, 'print("hello")'], + ), +) +def test_block_code(markdown_function, expected): + assert markdown_function('```\nprint("hello")\n```') == expected + + +@pytest.mark.parametrize( + "markdown_function, expected", + ( + [notify_letter_preview_markdown, ("

    inset text

    ")], + [ + notify_email_markdown, + ( + "
    ' + '

    inset text

    ' + "
    " + ), + ], + [ + notify_plain_text_email_markdown, + ("\n" "\ninset text"), + ], + ), +) +def test_block_quote(markdown_function, expected): + assert markdown_function("^ inset text") == expected + + +@pytest.mark.parametrize( + "heading", + ( + "# heading", + "#heading", + ), +) +@pytest.mark.parametrize( + "markdown_function, expected", + ( + [notify_letter_preview_markdown, "

    heading

    \n"], + [ + notify_email_markdown, + ( + '

    ' + "heading" + "

    " + ), + ], + [ + notify_plain_text_email_markdown, + ( + "\n" + "\n" + "\nheading" + "\n-----------------------------------------------------------------" + ), + ], + ), +) +def test_level_1_header(markdown_function, heading, expected): + assert markdown_function(heading) == expected + + +@pytest.mark.parametrize( + "markdown_function, expected", + ( + [notify_letter_preview_markdown, "

    inset text

    "], + [ + notify_email_markdown, + '

    inset text

    ', + ], + [ + notify_plain_text_email_markdown, + ("\n" "\ninset text"), + ], + ), +) +def test_level_2_header(markdown_function, expected): + assert markdown_function("## inset text") == (expected) + + +@pytest.mark.parametrize( + "markdown_function, expected", + ( + [ + notify_letter_preview_markdown, + ("

    a

    " '
     
    ' "

    b

    "), + ], + [ + notify_email_markdown, + ( + '

    a

    ' + '
    ' + '

    b

    ' + ), + ], + [ + notify_plain_text_email_markdown, + ( + "\n" + "\na" + "\n" + "\n=================================================================" + "\n" + "\nb" + ), + ], + ), +) +def test_hrule(markdown_function, expected): + assert markdown_function("a\n\n***\n\nb") == expected + assert markdown_function("a\n\n---\n\nb") == expected + + +@pytest.mark.parametrize( + "markdown_function, expected", + ( + [ + notify_letter_preview_markdown, + ("
      \n" "
    1. one
    2. \n" "
    3. two
    4. \n" "
    5. three
    6. \n" "
    \n"), + ], + [ + notify_email_markdown, + ( + '' + "" + '" + "" + "
    ' + '
      ' + '
    1. one
    2. ' + '
    3. two
    4. ' + '
    5. three
    6. ' + "
    " + "
    " + ), + ], + [ + notify_plain_text_email_markdown, + ("\n" "\n1. one" "\n2. two" "\n3. three"), + ], + ), +) +def test_ordered_list(markdown_function, expected): + assert markdown_function("1. one\n" "2. two\n" "3. three\n") == expected + assert markdown_function("1.one\n" "2.two\n" "3.three\n") == expected + + +@pytest.mark.parametrize( + "markdown", + ( + ("*one\n" "*two\n" "*three\n"), # no space + ("* one\n" "* two\n" "* three\n"), # single space + ("* one\n" "* two\n" "* three\n"), # two spaces + ("* one\n" "* two\n" "* three\n"), # tab + ("- one\n" "- two\n" "- three\n"), # dash as bullet + pytest.param( + ("+ one\n" "+ two\n" "+ three\n"), # plus as bullet + marks=pytest.mark.xfail(raises=AssertionError), + ), + ("• one\n" "• two\n" "• three\n"), # bullet as bullet + ), +) +@pytest.mark.parametrize( + "markdown_function, expected", + ( + [ + notify_letter_preview_markdown, + ("
      \n" "
    • one
    • \n" "
    • two
    • \n" "
    • three
    • \n" "
    \n"), + ], + [ + notify_email_markdown, + ( + '' + "" + '" + "" + "
    ' + '
      ' + '
    • one
    • ' + '
    • two
    • ' + '
    • three
    • ' + "
    " + "
    " + ), + ], + [ + notify_plain_text_email_markdown, + ("\n" "\n• one" "\n• two" "\n• three"), + ], + ), +) +def test_unordered_list(markdown, markdown_function, expected): + assert markdown_function(markdown) == expected + + +@pytest.mark.parametrize( + "markdown_function, expected", + ( + [ + notify_letter_preview_markdown, + "

    + one

    + two

    + three

    ", + ], + [ + notify_email_markdown, + ( + '

    + one

    ' + '

    + two

    ' + '

    + three

    ' + ), + ], + [ + notify_plain_text_email_markdown, + ("\n\n+ one" "\n\n+ two" "\n\n+ three"), + ], + ), +) +def test_pluses_dont_render_as_lists(markdown_function, expected): + assert markdown_function("+ one\n" "+ two\n" "+ three\n") == expected + + +@pytest.mark.parametrize( + "markdown_function, expected", + ( + [ + notify_letter_preview_markdown, + ("

    " "line one
    " "line two" "

    " "

    " "new paragraph" "

    "), + ], + [ + notify_email_markdown, + ( + '

    line one
    ' + "line two

    " + '

    new paragraph

    ' + ), + ], + [ + notify_plain_text_email_markdown, + ("\n" "\nline one" "\nline two" "\n" "\nnew paragraph"), + ], + ), +) +def test_paragraphs(markdown_function, expected): + assert markdown_function("line one\n" "line two\n" "\n" "new paragraph") == expected + + +@pytest.mark.parametrize( + "markdown_function, expected", + ( + [notify_letter_preview_markdown, ("

    before

    " "

    after

    ")], + [ + notify_email_markdown, + ( + '

    before

    ' + '

    after

    ' + ), + ], + [ + notify_plain_text_email_markdown, + ("\n" "\nbefore" "\n" "\nafter"), + ], + ), +) +def test_multiple_newlines_get_truncated(markdown_function, expected): + assert markdown_function("before\n\n\n\n\n\nafter") == expected + + +@pytest.mark.parametrize( + "markdown_function", + ( + notify_letter_preview_markdown, + notify_email_markdown, + notify_plain_text_email_markdown, + ), +) +def test_table(markdown_function): + assert markdown_function("col | col\n" "----|----\n" "val | val\n") == ("") + + +@pytest.mark.parametrize( + "markdown_function, link, expected", + ( + [ + notify_letter_preview_markdown, + "http://example.com", + "

    example.com

    ", + ], + [ + notify_email_markdown, + "http://example.com", + ( + '

    ' + 'http://example.com' + "

    " + ), + ], + [ + notify_email_markdown, + """https://example.com"onclick="alert('hi')""", + ( + '

    ' + '' + 'https://example.com"onclick="alert(\'hi' + "')" + "

    " + ), + ], + [ + notify_plain_text_email_markdown, + "http://example.com", + ("\n" "\nhttp://example.com"), + ], + ), +) +def test_autolink(markdown_function, link, expected): + assert markdown_function(link) == expected + + +@pytest.mark.parametrize( + "markdown_function, expected", + ( + [notify_letter_preview_markdown, "

    variable called `thing`

    "], + [ + notify_email_markdown, + '

    variable called `thing`

    ', # noqa E501 + ], + [ + notify_plain_text_email_markdown, + "\n\nvariable called `thing`", + ], + ), +) +def test_codespan(markdown_function, expected): + assert markdown_function("variable called `thing`") == expected + + +@pytest.mark.parametrize( + "markdown_function, expected", + ( + [notify_letter_preview_markdown, "

    something **important**

    "], + [ + notify_email_markdown, + '

    something **important**

    ', # noqa E501 + ], + [ + notify_plain_text_email_markdown, + "\n\nsomething **important**", + ], + ), +) +def test_double_emphasis(markdown_function, expected): + assert markdown_function("something **important**") == expected + + +@pytest.mark.parametrize( + "markdown_function, text, expected", + ( + [ + notify_letter_preview_markdown, + "something *important*", + "

    something *important*

    ", + ], + [ + notify_email_markdown, + "something *important*", + '

    something *important*

    ', # noqa E501 + ], + [ + notify_plain_text_email_markdown, + "something *important*", + "\n\nsomething *important*", + ], + [ + notify_plain_text_email_markdown, + "something _important_", + "\n\nsomething _important_", + ], + [ + notify_plain_text_email_markdown, + "before*after", + "\n\nbefore*after", + ], + [ + notify_plain_text_email_markdown, + "before_after", + "\n\nbefore_after", + ], + ), +) +def test_emphasis(markdown_function, text, expected): + assert markdown_function(text) == expected + + +@pytest.mark.parametrize( + "markdown_function, expected", + ( + [ + notify_email_markdown, + '

    foo ****** bar

    ', + ], + [ + notify_plain_text_email_markdown, + "\n\nfoo ****** bar", + ], + ), +) +def test_nested_emphasis(markdown_function, expected): + assert markdown_function("foo ****** bar") == expected + + +@pytest.mark.parametrize( + "markdown_function", + ( + notify_letter_preview_markdown, + notify_email_markdown, + notify_plain_text_email_markdown, + ), +) +def test_image(markdown_function): + assert markdown_function("![alt text](http://example.com/image.png)") == ("") + + +@pytest.mark.parametrize( + "markdown_function, expected", + ( + [ + notify_letter_preview_markdown, + ("

    Example: example.com

    "), + ], + [ + notify_email_markdown, + ( + '

    ' + 'Example' + "

    " + ), + ], + [ + notify_plain_text_email_markdown, + ("\n" "\nExample: http://example.com"), + ], + ), +) +def test_link(markdown_function, expected): + assert markdown_function("[Example](http://example.com)") == expected + + +@pytest.mark.parametrize( + "markdown_function, expected", + ( + [ + notify_letter_preview_markdown, + ("

    Example: example.com

    "), + ], + [ + notify_email_markdown, + ( + '

    ' + '' + "Example" + "" + "

    " + ), + ], + [ + notify_plain_text_email_markdown, + ("\n" "\nExample (An example URL): http://example.com"), + ], + ), +) +def test_link_with_title(markdown_function, expected): + assert ( + markdown_function('[Example](http://example.com "An example URL")') == expected + ) + + +@pytest.mark.parametrize( + "markdown_function, expected", + ( + [notify_letter_preview_markdown, "

    ~~Strike~~

    "], + [ + notify_email_markdown, + '

    ~~Strike~~

    ', + ], + [notify_plain_text_email_markdown, "\n\n~~Strike~~"], + ), +) +def test_strikethrough(markdown_function, expected): + assert markdown_function("~~Strike~~") == expected + + +def test_footnotes(): + # Can’t work out how to test this + pass diff --git a/tests/notifications_utils/test_placeholders.py b/tests/notifications_utils/test_placeholders.py new file mode 100644 index 000000000..7ac11c3c9 --- /dev/null +++ b/tests/notifications_utils/test_placeholders.py @@ -0,0 +1,66 @@ +import re + +import pytest + +from notifications_utils.field import Placeholder + + +@pytest.mark.parametrize( + "body, expected", + [ + ("((with-brackets))", "with-brackets"), + ("without-brackets", "without-brackets"), + ], +) +def test_placeholder_returns_name(body, expected): + assert Placeholder(body).name == expected + + +@pytest.mark.parametrize( + "body, is_conditional", + [ + ("not a conditional", False), + ("not? a conditional", False), + ("a?? conditional", True), + ], +) +def test_placeholder_identifies_conditional(body, is_conditional): + assert Placeholder(body).is_conditional() == is_conditional + + +@pytest.mark.parametrize( + "body, conditional_text", + [ + ("a??b", "b"), + ("a?? b ", " b "), + ("a??b??c", "b??c"), + ], +) +def test_placeholder_gets_conditional_text(body, conditional_text): + assert Placeholder(body).conditional_text == conditional_text + + +def test_placeholder_raises_if_accessing_conditional_text_on_non_conditional(): + with pytest.raises(ValueError): + Placeholder("hello").conditional_text + + +@pytest.mark.parametrize( + "body, value, result", + [ + ("a??b", "Yes", "b"), + ("a??b", "No", ""), + ], +) +def test_placeholder_gets_conditional_body(body, value, result): + assert Placeholder(body).get_conditional_body(value) == result + + +def test_placeholder_raises_if_getting_conditional_body_on_non_conditional(): + with pytest.raises(ValueError): + Placeholder("hello").get_conditional_body("Yes") + + +def test_placeholder_can_be_constructed_from_regex_match(): + match = re.search(r"\(\(.*\)\)", "foo ((bar)) baz") + assert Placeholder.from_match(match).name == "bar" diff --git a/tests/notifications_utils/test_postal_address.py b/tests/notifications_utils/test_postal_address.py new file mode 100644 index 000000000..f854944cd --- /dev/null +++ b/tests/notifications_utils/test_postal_address.py @@ -0,0 +1,777 @@ +import pytest + +from notifications_utils.countries import Country +from notifications_utils.countries.data import Postage +from notifications_utils.insensitive_dict import InsensitiveDict +from notifications_utils.postal_address import ( + PostalAddress, + format_postcode_for_printing, + is_a_real_uk_postcode, + normalise_postcode, +) + + +def test_raw_address(): + raw_address = "a\n\n\tb\r c " + assert PostalAddress(raw_address).raw_address == raw_address + + +@pytest.mark.parametrize( + "address, expected_country", + ( + ( + """ + 123 Example Street + City of Town + SW1A 1AA + """, + Country("United Kingdom"), + ), + ( + """ + 123 Example Street + City of Town + SW1A 1AA + United Kingdom + """, + Country("United Kingdom"), + ), + ( + """ + 123 Example Street + City of Town + Wales + """, + Country("United Kingdom"), + ), + ( + """ + 123 Example Straße + Deutschland + """, + Country("Germany"), + ), + ), +) +def test_country(address, expected_country): + assert PostalAddress(address).country == expected_country + + +@pytest.mark.parametrize( + "address, enough_lines_expected", + ( + ( + "", + False, + ), + ( + """ + 123 Example Street + City of Town + SW1A 1AA + """, + True, + ), + ( + """ + 123 Example Street + City of Town + United Kingdom + """, + False, + ), + ( + """ + 123 Example Street + + + City of Town + """, + False, + ), + ( + """ + 1 + 2 + 3 + 4 + 5 + 6 + 7 + 8 + """, + True, + ), + ), +) +def test_has_enough_lines(address, enough_lines_expected): + assert PostalAddress(address).has_enough_lines is enough_lines_expected + + +@pytest.mark.parametrize( + "address, too_many_lines_expected", + ( + ( + "", + False, + ), + ( + """ + Line 1 + Line 2 + Line 3 + Line 4 + Line 5 + Line 6 + Line 7 + """, + False, + ), + ( + """ + Line 1 + + Line 2 + + Line 3 + + Line 4 + + Line 5 + + Line 6 + + Line 7 + """, + False, + ), + ( + """ + Line 1 + Line 2 + Line 3 + Line 4 + Line 5 + Line 6 + Line 7 + Scotland + """, + False, + ), + ( + """ + Line 1 + Line 2 + Line 3 + Line 4 + Line 5 + Line 6 + Line 7 + Line 8 + """, + True, + ), + ), +) +def test_has_too_many_lines(address, too_many_lines_expected): + assert PostalAddress(address).has_too_many_lines is too_many_lines_expected + + +@pytest.mark.parametrize( + "address, expected_postcode", + ( + ( + "", + None, + ), + ( + """ + 123 Example Street + City of Town + SW1A 1AA + """, + "SW1A 1AA", + ), + ( + """ + 123 Example Street + City of Town + S W1 A 1 AA + """, + "SW1A 1AA", + ), + ( + """ + 123 Example Straße + Deutschland + """, + None, + ), + ( + """ + 123 Example Straße + SW1A 1AA + Deutschland + """, + None, + ), + ), +) +def test_postcode(address, expected_postcode): + assert PostalAddress(address).has_valid_postcode is bool(expected_postcode) + assert PostalAddress(address).postcode == expected_postcode + + +@pytest.mark.parametrize( + "address, expected_result", + [ + ( + "", + False, + ), + ( + """ + 1[23 Example Street) + C@ity of Town + SW1A 1AA + """, + False, + ), + ( + """ + [123 Example Street + (ity of Town + ]S W1 A 1 AA + """, + True, + ), + ( + r""" + 123 Example Straße + SW1A 1AA + \Deutschland + """, + True, + ), + ( + r""" + >123 Example Straße + SW1A 1AA + Deutschland + """, + True, + ), + ( + """ + ~123 Example Street + City of Town + SW1 A 1 AA + """, + True, + ), + ], +) +def test_has_invalid_characters(address, expected_result): + assert PostalAddress(address).has_invalid_characters is expected_result + + +@pytest.mark.parametrize( + "address, expected_international", + ( + ( + "", + False, + ), + ( + """ + 123 Example Street + City of Town + SW1A 1AA + """, + False, + ), + ( + """ + 123 Example Street + City of Town + United Kingdom + """, + False, + ), + ( + """ + 123 Example Street + City of Town + Guernsey + """, + False, + ), + ( + """ + 123 Example Straße + Deutschland + """, + True, + ), + ), +) +def test_international(address, expected_international): + assert PostalAddress(address).international is expected_international + + +@pytest.mark.parametrize( + "address, expected_normalised, expected_as_single_line", + ( + ( + "", + "", + "", + ), + ( + """ + 123 Example St . + City of Town + + S W1 A 1 AA + """, + ("123 Example St.\n" "City of Town\n" "SW1A 1AA"), + ("123 Example St., City of Town, SW1A 1AA"), + ), + ( + ( + "123 Example St. \t , \n" + ", , , , , , ,\n" + "City of Town, Region,\n" + "SW1A 1AA,,\n" + ), + ("123 Example St.\n" "City of Town, Region\n" "SW1A 1AA"), + ("123 Example St., City of Town, Region, SW1A 1AA"), + ), + ( + """ + 123 Example Straße + Deutschland + + + """, + ("123 Example Straße\n" "Germany"), + ("123 Example Straße, Germany"), + ), + ), +) +def test_normalised(address, expected_normalised, expected_as_single_line): + assert PostalAddress(address).normalised == expected_normalised + assert PostalAddress(address).as_single_line == expected_as_single_line + + +@pytest.mark.parametrize( + "address, expected_postage", + ( + ( + "", + Postage.UK, + ), + ( + """ + 123 Example Street + City of Town + SW1A 1AA + """, + Postage.UK, + ), + ( + """ + 123 Example Street + City of Town + Scotland + """, + Postage.UK, + ), + ( + """ + 123 Example Straße + Deutschland + """, + Postage.EUROPE, + ), + ( + """ + 123 Rue Example + Côte d'Ivoire + """, + Postage.REST_OF_WORLD, + ), + ), +) +def test_postage(address, expected_postage): + assert PostalAddress(address).postage == expected_postage + + +@pytest.mark.parametrize( + "personalisation", + ( + { + "address_line_1": "123 Example Street", + "address_line_3": "City of Town", + "address_line_4": "", + "postcode": "SW1A1AA", + "ignore me": "ignore me", + }, + { + "address_line_1": "123 Example Street", + "address_line_3": "City of Town", + "address_line_4": "SW1A1AA", + }, + { + "address_line_2": "123 Example Street", + "address_line_5": "City of Town", + "address_line_7": "SW1A1AA", + }, + { + "address_line_1": "123 Example Street", + "address_line_3": "City of Town", + "address_line_7": "SW1A1AA", + "postcode": "ignored if address line 7 provided", + }, + InsensitiveDict( + { + "address line 1": "123 Example Street", + "ADDRESS_LINE_2": "City of Town", + "Address-Line-7": "Sw1a 1aa", + } + ), + ), +) +def test_from_personalisation(personalisation): + assert PostalAddress.from_personalisation(personalisation).normalised == ( + "123 Example Street\n" "City of Town\n" "SW1A 1AA" + ) + + +def test_from_personalisation_handles_int(): + personalisation = { + "address_line_1": 123, + "address_line_2": "Example Street", + "address_line_3": "City of Town", + "address_line_4": "SW1A1AA", + } + assert PostalAddress.from_personalisation(personalisation).normalised == ( + "123\n" "Example Street\n" "City of Town\n" "SW1A 1AA" + ) + + +@pytest.mark.parametrize( + "address, expected_personalisation", + ( + ( + "", + { + "address_line_1": "", + "address_line_2": "", + "address_line_3": "", + "address_line_4": "", + "address_line_5": "", + "address_line_6": "", + "address_line_7": "", + "postcode": "", + }, + ), + ( + """ + 123 Example Street + City of Town + SW1A1AA + """, + { + "address_line_1": "123 Example Street", + "address_line_2": "City of Town", + "address_line_3": "", + "address_line_4": "", + "address_line_5": "", + "address_line_6": "", + "address_line_7": "SW1A 1AA", + "postcode": "SW1A 1AA", + }, + ), + ( + """ + One + Two + Three + Four + Five + Six + Seven + Eight + """, + { + "address_line_1": "One", + "address_line_2": "Two", + "address_line_3": "Three", + "address_line_4": "Four", + "address_line_5": "Five", + "address_line_6": "Six", + "address_line_7": "Eight", + "postcode": "Eight", + }, + ), + ), +) +def test_as_personalisation(address, expected_personalisation): + assert PostalAddress(address).as_personalisation == expected_personalisation + + +@pytest.mark.parametrize( + "address, expected_bool", + ( + ("", False), + (" ", False), + ("\n\n \n", False), + ("a", True), + ), +) +def test_bool(address, expected_bool): + assert bool(PostalAddress(address)) is expected_bool + + +@pytest.mark.parametrize( + "postcode, normalised_postcode", + [ + ("SW1 3EF", "SW13EF"), + ("SW13EF", "SW13EF"), + ("sw13ef", "SW13EF"), + ("Sw13ef", "SW13EF"), + ("sw1 3ef", "SW13EF"), + (" SW1 3EF ", "SW13EF"), + ], +) +def test_normalise_postcode(postcode, normalised_postcode): + assert normalise_postcode(postcode) == normalised_postcode + + +@pytest.mark.parametrize( + "postcode, result", + [ + # real standard UK poscodes + ("SW1 3EF", True), + ("SW13EF", True), + ("SE1 63EF", True), + ("N5 1AA", True), + ("SO14 6WB", True), + ("so14 6wb", True), + ("so14\u00A06wb", True), + # invalida / incomplete postcodes + ("N5", False), + ("SO144 6WB", False), + ("SO14 6WBA", False), + ("", False), + ("Bad postcode", False), + # valid British Forces postcodes + ("BFPO1234", True), + ("BFPO C/O 1234", True), + ("BFPO 1234", True), + ("BFPO1", True), + # invalid British Forces postcodes + ("BFPO", False), + ("BFPO12345", False), + # Giro Bank valid postcode and invalid postcode + ("GIR0AA", True), + ("GIR0AB", False), + ], +) +def test_if_postcode_is_a_real_uk_postcode(postcode, result): + assert is_a_real_uk_postcode(postcode) is result + + +def test_if_postcode_is_a_real_uk_postcode_normalises_before_checking_postcode(mocker): + normalise_postcode_mock = mocker.patch( + "notifications_utils.postal_address.normalise_postcode" + ) + normalise_postcode_mock.return_value = "SW11AA" + assert is_a_real_uk_postcode("sw1 1aa") is True + + +@pytest.mark.parametrize( + "postcode, postcode_with_space", + [ + ("SW13EF", "SW1 3EF"), + ("SW1 3EF", "SW1 3EF"), + ("N5 3EF", "N5 3EF"), + ("N5 3EF", "N5 3EF"), + ("N53EF ", "N5 3EF"), + ("n53Ef", "N5 3EF"), + ("n5 \u00A0 \t 3Ef", "N5 3EF"), + ("SO146WB", "SO14 6WB"), + ("BFPO2", "BFPO 2"), + ("BFPO232", "BFPO 232"), + ("BFPO 2432", "BFPO 2432"), + ("BFPO C/O 2", "BFPO C/O 2"), + ("BFPO c/o 232", "BFPO C/O 232"), + ("GIR0AA", "GIR 0AA"), + ], +) +def test_format_postcode_for_printing(postcode, postcode_with_space): + assert format_postcode_for_printing(postcode) == postcode_with_space + + +@pytest.mark.parametrize( + "address, international, expected_valid", + ( + ( + """ + UK address + Service can’t send internationally + SW1A 1AA + """, + False, + True, + ), + ( + """ + UK address + Service can send internationally + SW1A 1AA + """, + True, + True, + ), + ( + """ + Overseas address + Service can’t send internationally + Guinea-Bissau + """, + False, + False, + ), + ( + """ + Overseas address + Service can send internationally + Guinea-Bissau + """, + True, + True, + ), + ( + """ + Overly long address + 2 + 3 + 4 + 5 + 6 + 7 + 8 + """, + True, + False, + ), + ( + """ + Address too short + 2 + """, + True, + False, + ), + ( + """ + No postcode or country + Service can’t send internationally + 3 + """, + False, + False, + ), + ( + """ + No postcode or country + Service can send internationally + 3 + """, + True, + False, + ), + ( + """ + Postcode and country + Service can’t send internationally + SW1 1AA + France + """, + False, + False, + ), + ), +) +def test_valid_with_international_parameter(address, international, expected_valid): + postal_address = PostalAddress( + address, + allow_international_letters=international, + ) + assert postal_address.valid is expected_valid + assert postal_address.has_valid_last_line is expected_valid + + +@pytest.mark.parametrize( + "address", + ( + """ + Too short, valid postcode + SW1A 1AA + """, + """ + Too short, valid country + Bhutan + """, + """ + Too long, valid postcode + 2 + 3 + 4 + 5 + 6 + 7 + SW1A 1AA + """, + """ + Too long, valid country + 2 + 3 + 4 + 5 + 6 + 7 + Bhutan + """, + ), +) +def test_valid_last_line_too_short_too_long(address): + postal_address = PostalAddress(address, allow_international_letters=True) + assert postal_address.valid is False + assert postal_address.has_valid_last_line is True + + +def test_valid_with_invalid_characters(): + address = "Valid\nExcept\n[For one character\nBhutan\n" + assert PostalAddress(address, allow_international_letters=True).valid is False + + +@pytest.mark.parametrize( + "international, expected_valid", + ( + (False, False), + (True, True), + ), +) +def test_valid_from_personalisation_with_international_parameter( + international, expected_valid +): + assert ( + PostalAddress.from_personalisation( + {"address_line_1": "A", "address_line_2": "B", "address_line_3": "Chad"}, + allow_international_letters=international, + ).valid + is expected_valid + ) diff --git a/tests/notifications_utils/test_recipient_csv.py b/tests/notifications_utils/test_recipient_csv.py new file mode 100644 index 000000000..dab7a28d3 --- /dev/null +++ b/tests/notifications_utils/test_recipient_csv.py @@ -0,0 +1,1356 @@ +import itertools +import string +import unicodedata +from functools import partial +from random import choice, randrange +from unittest.mock import Mock + +import pytest +from ordered_set import OrderedSet + +from notifications_utils import SMS_CHAR_COUNT_LIMIT +from notifications_utils.countries import Country +from notifications_utils.formatters import strip_and_remove_obscure_whitespace +from notifications_utils.recipients import ( + Cell, + RecipientCSV, + Row, + first_column_headings, +) +from notifications_utils.template import ( + EmailPreviewTemplate, + LetterImageTemplate, + SMSMessageTemplate, +) + + +def _sample_template(template_type, content="foo"): + return { + "email": EmailPreviewTemplate( + {"content": content, "subject": "bar", "template_type": "email"} + ), + "sms": SMSMessageTemplate({"content": content, "template_type": "sms"}), + "letter": LetterImageTemplate( + {"content": content, "subject": "bar", "template_type": "letter"}, + image_url="https://example.com", + page_count=1, + ), + }.get(template_type) + + +def _index_rows(rows): + return set(row.index for row in rows) + + +@pytest.mark.parametrize( + "template_type, expected", + ( + ("email", ["email address"]), + ("sms", ["phone number"]), + ( + "letter", + [ + "address line 1", + "address line 2", + "address line 3", + "address line 4", + "address line 5", + "address line 6", + "postcode", + "address line 7", + ], + ), + ), +) +def test_recipient_column_headers(template_type, expected): + recipients = RecipientCSV("", template=_sample_template(template_type)) + assert ( + (recipients.recipient_column_headers) + == (first_column_headings[template_type]) + == (expected) + ) + + +@pytest.mark.parametrize( + "file_contents,template_type,expected", + [ + ( + "", + "sms", + [], + ), + ( + "phone number", + "sms", + [], + ), + ( + """ + phone number,name + +44 123, test1 + +44 456,test2 + """, + "sms", + [ + [("phone number", "+44 123"), ("name", "test1")], + [("phone number", "+44 456"), ("name", "test2")], + ], + ), + ( + """ + phone number,name + +44 123, + +44 456 + """, + "sms", + [ + [("phone number", "+44 123"), ("name", None)], + [("phone number", "+44 456"), ("name", None)], + ], + ), + ( + """ + email address,name + test@example.com,test1 + test2@example.com, test2 + """, + "email", + [ + [("email address", "test@example.com"), ("name", "test1")], + [("email address", "test2@example.com"), ("name", "test2")], + ], + ), + ( + """ + email address + test@example.com,test1,red + test2@example.com, test2,blue + """, + "email", + [ + [("email address", "test@example.com"), (None, ["test1", "red"])], + [("email address", "test2@example.com"), (None, ["test2", "blue"])], + ], + ), + ( + """ + email address,name + test@example.com,"test1" + test2@example.com," test2 " + test3@example.com," test3" + """, + "email", + [ + [("email address", "test@example.com"), ("name", "test1")], + [("email address", "test2@example.com"), ("name", "test2")], + [("email address", "test3@example.com"), ("name", "test3")], + ], + ), + ( + """ + email address,date,name + test@example.com,"Nov 28, 2016",test1 + test2@example.com,"Nov 29, 2016",test2 + """, + "email", + [ + [ + ("email address", "test@example.com"), + ("date", "Nov 28, 2016"), + ("name", "test1"), + ], + [ + ("email address", "test2@example.com"), + ("date", "Nov 29, 2016"), + ("name", "test2"), + ], + ], + ), + ( + """ + address_line_1 + Alice + Bob + """, + "letter", + [[("address_line_1", "Alice")], [("address_line_1", "Bob")]], + ), + ( + """ + address line 1,address line 2,address line 5,address line 6,postcode,name,thing + A. Name,,,,XM4 5HQ,example,example + """, + "letter", + [ + [ + ("addressline1", "A. Name"), + ("addressline2", None), + # optional address rows 3 and 4 not in file + ("addressline5", None), + ("addressline5", None), + ("postcode", "XM4 5HQ"), + ("name", "example"), + ("thing", "example"), + ] + ], + ), + ( + """ + phone number, list, list, list + 07900900001, cat, rat, gnat + 07900900002, dog, hog, frog + 07900900003, elephant + """, + "sms", + [ + [("phone number", "07900900001"), ("list", ["cat", "rat", "gnat"])], + [("phone number", "07900900002"), ("list", ["dog", "hog", "frog"])], + [("phone number", "07900900003"), ("list", ["elephant", None, None])], + ], + ), + ], +) +def test_get_rows(file_contents, template_type, expected): + rows = list( + RecipientCSV(file_contents, template=_sample_template(template_type)).rows + ) + if not expected: + assert rows == expected + for index, row in enumerate(expected): + assert len(rows[index].items()) == len(row) + for key, value in row: + assert rows[index].get(key).data == value + + +def test_get_rows_does_no_error_checking_of_rows_or_cells(mocker): + has_error_mock = mocker.patch.object(Row, "has_error") + has_bad_recipient_mock = mocker.patch.object(Row, "has_bad_recipient") + has_missing_data_mock = mocker.patch.object(Row, "has_missing_data") + cell_recipient_error_mock = mocker.patch.object(Cell, "recipient_error") + + recipients = RecipientCSV( + """ + email address, name + a@b.com, + a@b.com, My Name + a@b.com, + + + """, + template=_sample_template("email", "hello ((name))"), + max_errors_shown=3, + ) + + rows = recipients.get_rows() + for _ in range(3): + assert next(rows).recipient == "a@b.com" + + assert has_error_mock.called is False + assert has_bad_recipient_mock.called is False + assert has_missing_data_mock.called is False + assert cell_recipient_error_mock.called is False + + +def test_get_rows_only_iterates_over_file_once(mocker): + row_mock = mocker.patch("notifications_utils.recipients.Row") + + recipients = RecipientCSV( + """ + email address, name + a@b.com, + a@b.com, My Name + a@b.com, + + + """, + template=_sample_template("email", "hello ((name))"), + ) + + rows = recipients.get_rows() + for _ in range(3): + next(rows) + + assert row_mock.call_count == 3 + assert recipients.rows_as_list is None + + +@pytest.mark.parametrize( + "file_contents,template_type,expected", + [ + ( + """ + phone number,name + 2348675309, test1 + +1234-867-5301,test2 + , + """, + "sms", + [ + {"index": 0, "message_too_long": False}, + {"index": 1, "message_too_long": False}, + ], + ), + ( + """ + email address,name,colour + test@example.com,test1,blue + test2@example.com, test2,red + """, + "email", + [ + {"index": 0, "message_too_long": False}, + {"index": 1, "message_too_long": False}, + ], + ), + ], +) +def test_get_annotated_rows(file_contents, template_type, expected): + recipients = RecipientCSV( + file_contents, + template=_sample_template(template_type, "hello ((name))"), + max_initial_rows_shown=1, + ) + for index, expected_row in enumerate(expected): + annotated_row = list(recipients.rows)[index] + assert annotated_row.index == expected_row["index"] + assert annotated_row.message_too_long == expected_row["message_too_long"] + assert len(list(recipients.rows)) == 2 + assert len(list(recipients.initial_rows)) == 1 + assert not recipients.has_errors + + +def test_get_rows_with_errors(): + recipients = RecipientCSV( + """ + email address, name + a@b.com, + a@b.com, + a@b.com, + a@b.com, + a@b.com, + a@b.com, + + + """, + template=_sample_template("email", "hello ((name))"), + max_errors_shown=3, + ) + assert len(list(recipients.rows_with_errors)) == 6 + assert len(list(recipients.initial_rows_with_errors)) == 3 + assert recipients.has_errors + + +@pytest.mark.parametrize( + "template_type, row_count, header, filler, row_with_error", + [ + ( + "email", + 500, + "email address\n", + "test@example.com\n", + "test at example dot com", + ), + ("sms", 500, "phone number\n", "2348675309\n", "12345"), + ], +) +def test_big_list_validates_right_through( + template_type, row_count, header, filler, row_with_error +): + big_csv = RecipientCSV( + header + (filler * (row_count - 1) + row_with_error), + template=_sample_template(template_type), + max_errors_shown=100, + max_initial_rows_shown=3, + ) + assert len(list(big_csv.rows)) == row_count + assert _index_rows(big_csv.rows_with_bad_recipients) == {row_count - 1} # 0 indexed + assert _index_rows(big_csv.rows_with_errors) == {row_count - 1} + assert len(list(big_csv.initial_rows_with_errors)) == 1 + assert big_csv.has_errors + + +@pytest.mark.parametrize( + "template_type, row_count, header, filler", + [ + ("email", 50, "email address\n", "test@example.com\n"), + ("sms", 50, "phone number\n", "07900900123\n"), + ], +) +def test_check_if_message_too_long_for_sms_but_not_email_in_CSV( + mocker, template_type, row_count, header, filler +): + # we do not validate email size for CSVs to avoid performance issues + RecipientCSV( + header + filler * row_count, + template=_sample_template(template_type), + max_errors_shown=100, + max_initial_rows_shown=3, + ) + is_message_too_long = mocker.patch( + "notifications_utils.template.Template.is_message_too_long", side_effect=False + ) + if template_type == "email": + is_message_too_long.assert_not_called + else: + is_message_too_long.called + + +def test_overly_big_list_stops_processing_rows_beyond_max(mocker): + mock_strip_and_remove_obscure_whitespace = mocker.patch( + "notifications_utils.recipients.strip_and_remove_obscure_whitespace", + wraps=strip_and_remove_obscure_whitespace, + ) + mock_insert_or_append_to_dict = mocker.patch( + "notifications_utils.recipients.insert_or_append_to_dict" + ) + + big_csv = RecipientCSV( + "phonenumber,name\n" + ("2348675309,example\n" * 123), + template=_sample_template("sms", content="hello ((name))"), + ) + big_csv.max_rows = 10 + + # Our CSV has lots of rows… + assert big_csv.too_many_rows + assert len(big_csv) == 123 + + # …but we’ve only called the expensive whitespace function on each + # of the 2 cells in the first 10 rows + assert len(mock_strip_and_remove_obscure_whitespace.call_args_list) == 20 + + # …and we’ve only called the function which builds the internal data + # structure once for each of the first 10 rows + assert len(mock_insert_or_append_to_dict.call_args_list) == 10 + + +def test_file_with_lots_of_empty_columns(): + process = Mock() + + lots_of_commas = "," * 10_000 + + for row in RecipientCSV( + f"phone_number{lots_of_commas}\n" + (f"07900900900{lots_of_commas}\n" * 100), + template=_sample_template("sms"), + ): + assert [(key, cell.data) for key, cell in row.items()] == [ + # Note that we haven’t stored any of the empty cells + ("phonenumber", "07900900900") + ] + process() + + assert process.call_count == 100 + + +def test_empty_column_names(): + recipient_csv = RecipientCSV( + """ + phone_number,,,name + 07900900123,foo,bar,baz + """, + template=_sample_template("sms"), + ) + + assert recipient_csv[0]["phone_number"].data == "07900900123" + assert recipient_csv[0][""].data == ["foo", "bar"] + assert recipient_csv[0]["name"].data == "baz" + + +@pytest.mark.parametrize( + "file_contents,template,expected_recipients,expected_personalisation", + [ + ( + """ + phone number,name, date + +44 123,test1,today + +44456, ,tomorrow + ,, + , , + """, + _sample_template("sms", "hello ((name))"), + ["+44 123", "+44456"], + [{"name": "test1"}, {"name": None}], + ), + ( + """ + email address,name,colour + test@example.com,test1,red + testatexampledotcom,test2,blue + """, + _sample_template("email", "((colour))"), + ["test@example.com", "testatexampledotcom"], + [{"colour": "red"}, {"colour": "blue"}], + ), + ( + """ + email address + test@example.com,test1,red + testatexampledotcom,test2,blue + """, + _sample_template("email"), + ["test@example.com", "testatexampledotcom"], + [], + ), + ], +) +def test_get_recipient( + file_contents, template, expected_recipients, expected_personalisation +): + recipients = RecipientCSV(file_contents, template=template) + + for index, row in enumerate(expected_personalisation): + for key, value in row.items(): + assert recipients[index].recipient == expected_recipients[index] + assert recipients[index].personalisation.get(key) == value + + +@pytest.mark.parametrize( + "file_contents,template,expected_recipients,expected_personalisation", + [ + ( + """ + email address,test + test@example.com,test1,red + testatexampledotcom,test2,blue + """, + _sample_template("email", "((test))"), + [(0, "test@example.com"), (1, "testatexampledotcom")], + [ + {"emailaddress": "test@example.com", "test": "test1"}, + {"emailaddress": "testatexampledotcom", "test": "test2"}, + ], + ) + ], +) +def test_get_recipient_respects_order( + file_contents, template, expected_recipients, expected_personalisation +): + recipients = RecipientCSV(file_contents, template=template) + + for row, email in expected_recipients: + assert ( + recipients[row].index, + recipients[row].recipient, + recipients[row].personalisation, + ) == ( + row, + email, + expected_personalisation[row], + ) + + +@pytest.mark.parametrize( + "file_contents,template_type,expected,expected_missing", + [ + ("", "sms", [], set(["phone number", "name"])), + ( + """ + phone number,name + 2348675309,test1 + 2348675309,test1 + 2348675309,test1 + """, + "sms", + ["phone number", "name"], + set(), + ), + ( + """ + email address,name,colour + """, + "email", + ["email address", "name", "colour"], + set(), + ), + ( + """ + address_line_1, address_line_2, postcode, name + """, + "letter", + ["address_line_1", "address_line_2", "postcode", "name"], + set(), + ), + ( + """ + email address,colour + """, + "email", + ["email address", "colour"], + set(["name"]), + ), + ( + """ + address_line_1, address_line_2, name + """, + "letter", + ["address_line_1", "address_line_2", "name"], + set(), + ), + ( + """ + phone number,list,list,name,list + """, + "sms", + ["phone number", "list", "name"], + set(), + ), + ], +) +def test_column_headers(file_contents, template_type, expected, expected_missing): + recipients = RecipientCSV( + file_contents, template=_sample_template(template_type, "((name))") + ) + assert recipients.column_headers == expected + assert recipients.missing_column_headers == expected_missing + assert recipients.has_errors == bool(expected_missing) + + +@pytest.mark.parametrize( + "content", + [ + "hello", + "hello ((name))", + ], +) +@pytest.mark.parametrize( + "file_contents,template_type", + [ + pytest.param("", "sms", marks=pytest.mark.xfail), + pytest.param("name", "sms", marks=pytest.mark.xfail), + pytest.param("email address", "sms", marks=pytest.mark.xfail), + pytest.param( + "address_line_1", + "letter", + marks=pytest.mark.xfail, + ), + pytest.param( + "address_line_1, address_line_2", + "letter", + marks=pytest.mark.xfail, + ), + pytest.param( + "address_line_6, postcode", + "letter", + marks=pytest.mark.xfail, + ), + pytest.param( + "address_line_1, postcode, address_line_7", + "letter", + marks=pytest.mark.xfail, + ), + ("phone number", "sms"), + ("phone number,name", "sms"), + ("email address", "email"), + ("email address,name", "email"), + ("PHONENUMBER", "sms"), + ("email_address", "email"), + ("address_line_1, address_line_2, postcode", "letter"), + ("address_line_1, address_line_2, address_line_7", "letter"), + ("address_line_1, address_line_2, address_line_3", "letter"), + ("address_line_4, address_line_5, address_line_6", "letter"), + ( + "address_line_1, address_line_2, address_line_3, address_line_4, address_line_5, address_line_6, postcode", + "letter", + ), + ], +) +def test_recipient_column(content, file_contents, template_type): + assert RecipientCSV( + file_contents, template=_sample_template(template_type, content) + ).has_recipient_columns + + +@pytest.mark.parametrize( + "file_contents,template_type,rows_with_bad_recipients,rows_with_missing_data", + [ + ( + """ + phone number,name,date + 2348675309,test1,test1 + 2348675309,test1 + +44 123,test1,test1 + 2348675309,test1,test1 + 2348675309,test1 + +1644000000,test1,test1 + ,test1,test1 + """, + "sms", + {2, 5}, + {1, 4, 6}, + ), + ( + """ + phone number,name + 2348675309,test1,test2 + """, + "sms", + set(), + set(), + ), + ( + """ + """, + "sms", + set(), + set(), + ), + ( + # missing postcode + """ + address_line_1,address_line_2,address_line_3,address_line_4,address_line_5,postcode,date + name, building, street, town, county, SE1 7LS,today + name, building, street, town, county, , today + """, + "letter", + {1}, + set(), + ), + ( + # not enough address fields + """ + address_line_1, postcode, date + name, SE1 7LS, today + """, + "letter", + {0}, + set(), + ), + ( + # optional address fields not filled in + """ + address_line_1,address_line_2,address_line_3,address_line_4,address_line_5,postcode,date + name ,123 fake st. , , , ,SE1 7LS,today + name , , , , ,SE1 7LS,today + """, + "letter", + {1}, + set(), + ), + ( + # Can use any address columns + """ + address_line_3, address_line_4, address_line_7, date + name , 123 fake st., SE1 7LS, today + """, + "letter", + set(), + set(), + ), + ], +) +@pytest.mark.parametrize( + "partial_instance", + [ + partial(RecipientCSV), + partial(RecipientCSV, allow_international_sms=False), + ], +) +def test_bad_or_missing_data( + file_contents, + template_type, + rows_with_bad_recipients, + rows_with_missing_data, + partial_instance, +): + recipients = partial_instance( + file_contents, template=_sample_template(template_type, "((date))") + ) + assert _index_rows(recipients.rows_with_bad_recipients) == rows_with_bad_recipients + assert _index_rows(recipients.rows_with_missing_data) == rows_with_missing_data + if rows_with_bad_recipients or rows_with_missing_data: + assert recipients.has_errors is True + + +@pytest.mark.parametrize( + "file_contents,rows_with_bad_recipients", + [ + ( + """ + phone number + +800000000000 + 1234 + +447900123 + """, + {0, 1}, + ), + ( + """ + phone number, country + 1-202-234-0104, USA + +12022340104, USA + +23051234567, Mauritius + """, + set(), + ), + ], +) +def test_international_recipients(file_contents, rows_with_bad_recipients): + recipients = RecipientCSV( + file_contents, + template=_sample_template("sms"), + allow_international_sms=True, + ) + assert _index_rows(recipients.rows_with_bad_recipients) == rows_with_bad_recipients + + +def test_errors_when_too_many_rows(): + recipients = RecipientCSV( + "email address\n" + ("a@b.com\n" * 101), + template=_sample_template("email"), + ) + + # Confirm the normal max_row limit + assert recipients.max_rows == 100_000 + # Override to make this test faster + recipients.max_rows = 100 + + assert recipients.too_many_rows is True + assert recipients.has_errors is True + assert recipients.rows[99]["email_address"].data == "a@b.com" + # We stop processing subsequent rows + assert recipients.rows[100] is None + + +@pytest.mark.parametrize( + "file_contents,template_type,guestlist,count_of_rows_with_errors", + [ + ( + """ + phone number + 2348675309 + 2348675301 + 2348675302 + 2348675303 + """, + "sms", + ["+12348675309"], # Same as first phone number but in different format + 3, + ), + ( + """ + phone number + 12348675309 + 2348675301 + 2348675302 + """, + "sms", + [ + "2348675309", + "12348675301", + "2348675302", + "2341231234", + "test@example.com", + ], + 0, + ), + ( + """ + email address + IN_GUESTLIST@EXAMPLE.COM + not_in_guestlist@example.com + """, + "email", + [ + "in_guestlist@example.com", + "2348675309", + ], # Email case differs to the one in the CSV + 1, + ), + ], +) +def test_recipient_guestlist( + file_contents, template_type, guestlist, count_of_rows_with_errors +): + recipients = RecipientCSV( + file_contents, template=_sample_template(template_type), guestlist=guestlist + ) + + if count_of_rows_with_errors: + assert not recipients.allowed_to_send_to + else: + assert recipients.allowed_to_send_to + + # Make sure the guestlist isn’t emptied by reading it. If it’s an iterator then + # there’s a risk that it gets emptied after being read once + recipients.guestlist = ( + str(fake_number) for fake_number in range(7700900888, 7700900898) + ) + list(recipients.guestlist) + assert not recipients.allowed_to_send_to + assert recipients.has_errors + + # An empty guestlist is treated as no guestlist at all + recipients.guestlist = [] + assert recipients.allowed_to_send_to + recipients.guestlist = itertools.chain() + assert recipients.allowed_to_send_to + + +def test_detects_rows_which_result_in_overly_long_messages(): + template = SMSMessageTemplate( + {"content": "((placeholder))", "template_type": "sms"}, + sender=None, + prefix=None, + ) + recipients = RecipientCSV( + """ + phone number,placeholder + 2348675309,1 + 2348675301,{one_under} + 2348675302,{exactly} + 2348675303,{one_over} + """.format( + one_under="a" * (SMS_CHAR_COUNT_LIMIT - 1), + exactly="a" * SMS_CHAR_COUNT_LIMIT, + one_over="a" * (SMS_CHAR_COUNT_LIMIT + 1), + ), + template=template, + ) + assert _index_rows(recipients.rows_with_errors) == {3} + assert _index_rows(recipients.rows_with_message_too_long) == {3} + assert recipients.has_errors + assert recipients[0].has_error_spanning_multiple_cells is False + assert recipients[1].has_error_spanning_multiple_cells is False + assert recipients[2].has_error_spanning_multiple_cells is False + assert recipients[3].has_error_spanning_multiple_cells is True + + +def test_detects_rows_which_result_in_empty_messages(): + template = SMSMessageTemplate( + {"content": "((show??content))", "template_type": "sms"}, + sender=None, + prefix=None, + ) + recipients = RecipientCSV( + """ + phone number,show + 2348675309,yes + 2348675301,no + 2348675302,yes + """, + template=template, + ) + assert _index_rows(recipients.rows_with_errors) == {1} + assert _index_rows(recipients.rows_with_empty_message) == {1} + assert recipients.has_errors + assert recipients[0].has_error_spanning_multiple_cells is False + assert recipients[1].has_error_spanning_multiple_cells is True + assert recipients[2].has_error_spanning_multiple_cells is False + + +@pytest.mark.parametrize( + "key, expected", + sum( + [ + [(key, expected) for key in group] + for expected, group in [ + ( + "2348675309", + ( + "phone number", + " PHONENUMBER", + "phone_number", + "phone-number", + "phoneNumber", + ), + ), + ( + "Jo", + ( + "FIRSTNAME", + "first name", + "first_name ", + "first-name", + "firstName", + ), + ), + ( + "Bloggs", + ( + "Last Name", + "LASTNAME", + " last_name", + "last-name", + "lastName ", + ), + ), + ] + ], + [], + ), +) +def test_ignores_spaces_and_case_in_placeholders(key, expected): + recipients = RecipientCSV( + """ + phone number,FIRSTNAME, Last Name + 2348675309, Jo, Bloggs + """, + template=_sample_template( + "sms", content="((phone_number)) ((First Name)) ((lastname))" + ), + ) + first_row = recipients[0] + assert first_row.get(key).data == expected + assert first_row[key].data == expected + assert first_row.recipient == "2348675309" + assert len(first_row.items()) == 3 + assert not recipients.has_errors + + assert recipients.missing_column_headers == set() + recipients.placeholders = {"one", "TWO", "Thirty_Three"} + assert recipients.missing_column_headers == {"one", "TWO", "Thirty_Three"} + assert recipients.has_errors + + +@pytest.mark.parametrize( + "character, name", + ( + (" ", "SPACE"), + # these ones don’t have unicode names + ("\n", None), # newline + ("\r", None), # carriage return + ("\t", None), # tab + ("\u180E", "MONGOLIAN VOWEL SEPARATOR"), + ("\u200B", "ZERO WIDTH SPACE"), + ("\u200C", "ZERO WIDTH NON-JOINER"), + ("\u200D", "ZERO WIDTH JOINER"), + ("\u2060", "WORD JOINER"), + ("\uFEFF", "ZERO WIDTH NO-BREAK SPACE"), + # all the things + (" \n\r\t\u000A\u000D\u180E\u200B\u200C\u200D\u2060\uFEFF", None), + ), +) +def test_ignores_leading_whitespace_in_file(character, name): + if name is not None: + assert unicodedata.name(character) == name + + recipients = RecipientCSV( + "{}emailaddress\ntest@example.com".format(character), + template=_sample_template("email"), + ) + first_row = recipients[0] + + assert recipients.column_headers == ["emailaddress"] + assert recipients.recipient_column_headers == ["email address"] + assert recipients.missing_column_headers == set() + assert recipients.placeholders == ["email address"] + + assert first_row.get("email address").data == "test@example.com" + assert first_row["email address"].data == "test@example.com" + assert first_row.recipient == "test@example.com" + + assert not recipients.has_errors + + +def test_error_if_too_many_recipients(): + recipients = RecipientCSV( + "phone number,\n2348675309,\n2348675309,\n2348675309,", + template=_sample_template("sms"), + remaining_messages=2, + ) + assert recipients.has_errors + assert recipients.more_rows_than_can_send + + +def test_dont_error_if_too_many_recipients_not_specified(): + recipients = RecipientCSV( + "phone number,\n2348675309,\n2348675309,\n2348675309,", + template=_sample_template("sms"), + ) + assert not recipients.has_errors + assert not recipients.more_rows_than_can_send + + +@pytest.mark.parametrize( + "index, expected_row", + [ + ( + 0, + { + "phone number": "07700 90000 1", + "colour": "red", + }, + ), + ( + 1, + { + "phone_number": "07700 90000 2", + "COLOUR": "green", + }, + ), + ( + 2, + {"p h o n e n u m b e r": "07700 90000 3", " colour ": "blue"}, + ), + pytest.param( + 3, + {"phone number": "foo"}, + marks=pytest.mark.xfail(raises=IndexError), + ), + ( + -1, + {"p h o n e n u m b e r": "07700 90000 3", " colour ": "blue"}, + ), + ], +) +def test_recipients_can_be_accessed_by_index(index, expected_row): + recipients = RecipientCSV( + """ + phone number, colour + 07700 90000 1, red + 07700 90000 2, green + 07700 90000 3, blue + """, + template=_sample_template("sms"), + ) + for key, value in expected_row.items(): + assert recipients[index][key].data == value + + +@pytest.mark.parametrize("international_sms", (True, False)) +def test_multiple_sms_recipient_columns(international_sms): + recipients = RecipientCSV( + """ + phone number, phone number, phone_number, foo + 234-867-5301, 234-867-5302, 234-867-5309, bar + """, + template=_sample_template("sms"), + allow_international_sms=international_sms, + ) + assert recipients.column_headers == ["phone number", "phone_number", "foo"] + assert ( + recipients.column_headers_as_column_keys == dict(phonenumber="", foo="").keys() + ) + assert recipients.rows[0].get("phone number").data == ("234-867-5309") + assert recipients.rows[0].get("phone_number").data == ("234-867-5309") + assert recipients.rows[0].get("phone number").error is None + assert recipients.duplicate_recipient_column_headers == OrderedSet( + ["phone number", "phone_number"] + ) + assert recipients.has_errors + + +@pytest.mark.parametrize( + "column_name", + ( + "phone_number", + "phonenumber", + "phone number", + "phone-number", + "p h o n e n u m b e r", + ), +) +def test_multiple_sms_recipient_columns_with_missing_data(column_name): + recipients = RecipientCSV( + """ + names, phone number, {} + "Joanna and Steve", 07900 900111 + """.format( + column_name + ), + template=_sample_template("sms"), + allow_international_sms=True, + ) + expected_column_headers = ["names", "phone number"] + if column_name != "phone number": + expected_column_headers.append(column_name) + assert recipients.column_headers == expected_column_headers + assert ( + recipients.column_headers_as_column_keys + == dict(phonenumber="", names="").keys() + ) + # A piece of weirdness uncovered: since rows are created before spaces in column names are normalised, when + # there are duplicate recipient columns and there is data for only one of the columns, if the columns have the same + # spacing, phone number data will be a list of this one phone number and None, while if the spacing style differs + # between two duplicate column names, the phone number data will be None. If there are no duplicate columns + # then our code finds the phone number well regardless of the spacing, so this should not affect our users. + phone_number_data = None + if column_name == "phone number": + phone_number_data = ["07900 900111", None] + assert recipients.rows[0]["phonenumber"].data == phone_number_data + assert recipients.rows[0].get("phone number").error is None + expected_duplicated_columns = ["phone number"] + if column_name != "phone number": + expected_duplicated_columns.append(column_name) + assert recipients.duplicate_recipient_column_headers == OrderedSet( + expected_duplicated_columns + ) + assert recipients.has_errors + + +def test_multiple_email_recipient_columns(): + recipients = RecipientCSV( + """ + EMAILADDRESS, email_address, foo + one@two.com, two@three.com, bar + """, + template=_sample_template("email"), + ) + assert recipients.rows[0].get("email address").data == ("two@three.com") + assert recipients.rows[0].get("email address").error is None + assert recipients.has_errors + assert recipients.duplicate_recipient_column_headers == OrderedSet( + ["EMAILADDRESS", "email_address"] + ) + assert recipients.has_errors + + +def test_multiple_letter_recipient_columns(): + recipients = RecipientCSV( + """ + address line 1, Address Line 2, address line 1, address_line_2 + 1,2,3,4 + """, + template=_sample_template("letter"), + ) + assert recipients.rows[0].get("addressline1").data == ("3") + assert recipients.rows[0].get("addressline1").error is None + assert recipients.has_errors + assert recipients.duplicate_recipient_column_headers == OrderedSet( + ["address line 1", "Address Line 2", "address line 1", "address_line_2"] + ) + assert recipients.has_errors + + +def test_displayed_rows_when_some_rows_have_errors(): + recipients = RecipientCSV( + """ + email address, name + a@b.com, + a@b.com, + a@b.com, My Name + a@b.com, + a@b.com, + """, + template=_sample_template("email", "((name))"), + max_errors_shown=3, + ) + + assert len(list(recipients.displayed_rows)) == 3 + + +def test_displayed_rows_when_there_are_no_rows_with_errors(): + recipients = RecipientCSV( + """ + email address, name + a@b.com, My Name + a@b.com, My Name + a@b.com, My Name + a@b.com, My Name + """, + template=_sample_template("email", "((name))"), + max_errors_shown=3, + ) + + assert len(list(recipients.displayed_rows)) == 4 + + +def test_multi_line_placeholders_work(): + recipients = RecipientCSV( + """ + email address, data + a@b.com, "a\nb\n\nc" + """, + template=_sample_template("email", "((data))"), + ) + + assert recipients.rows[0].personalisation["data"] == "a\nb\n\nc" + + +@pytest.mark.parametrize( + "extra_args, expected_errors, expected_bad_rows", + ( + ({}, True, {0}), + ({"allow_international_letters": False}, True, {0}), + ({"allow_international_letters": True}, False, set()), + ), +) +def test_accepts_international_addresses_when_allowed( + extra_args, expected_errors, expected_bad_rows +): + recipients = RecipientCSV( + """ + address line 1, address line 2, address line 3 + First Lastname, 123 Example St, Fiji + First Lastname, 123 Example St, SW1A 1AA + """, + template=_sample_template("letter"), + **extra_args, + ) + assert recipients.has_errors is expected_errors + assert _index_rows(recipients.rows_with_bad_recipients) == expected_bad_rows + # Prove that the error isn’t because the given country is unknown + assert recipients[0].as_postal_address.country == Country("Fiji") + + +def test_address_validation_speed(): + # We should be able to validate 1000 lines of address data in about + # a second – if it starts to get slow, something is inefficient + number_of_lines = 1000 + uk_addresses_with_valid_postcodes = "\n".join( + ( + "{n} Example Street, London, {a}{b} {c}{d}{e}".format( + n=randrange(1000), + a=choice(["n", "e", "sw", "se", "w"]), + b=choice(range(1, 10)), + c=choice(range(1, 10)), + d=choice("ABDefgHJLNPqrstUWxyZ"), + e=choice("ABDefgHJLNPqrstUWxyZ"), + ) + for i in range(number_of_lines) + ) + ) + recipients = RecipientCSV( + "address line 1, address line 2, address line 3\n" + + (uk_addresses_with_valid_postcodes), + template=_sample_template("letter"), + allow_international_letters=False, + ) + for row in recipients: + assert not row.has_bad_postal_address + + +def test_email_validation_speed(): + email_addresses = "\n".join( + ( + "{a}{b}@example-{n}.com,Example,Thursday".format( + n=randrange(1000), + a=choice(string.ascii_letters), + b=choice(string.ascii_letters), + ) + for i in range(1000) + ) + ) + recipients = RecipientCSV( + "email address,name,day\n" + email_addresses, + template=_sample_template( + "email", + content=f""" + hello ((name)) today is ((day)) + here’s the letter ‘a’ 1000 times: + {'a' * 1000} + """, + ), + ) + for row in recipients: + assert not row.has_error + + +@pytest.mark.parametrize("should_validate", [True, False]) +def test_recipient_csv_checks_should_validate_flag(should_validate): + template = _sample_template("sms") + template.is_message_empty = Mock(return_value=False) + + recipients = RecipientCSV( + """phone number,name + 2348675309, test1 + +447700 900 460,test2""", + template=template, + should_validate=should_validate, + ) + + recipients._get_error_for_field = Mock(return_value=None) + + list(recipients.get_rows()) + + assert template.is_message_empty.called is should_validate + assert recipients._get_error_for_field.called is should_validate diff --git a/tests/notifications_utils/test_recipient_validation.py b/tests/notifications_utils/test_recipient_validation.py new file mode 100644 index 000000000..fbd615e0e --- /dev/null +++ b/tests/notifications_utils/test_recipient_validation.py @@ -0,0 +1,424 @@ +import pytest + +from notifications_utils.recipients import ( + InvalidEmailError, + InvalidPhoneError, + allowed_to_send_to, + format_phone_number_human_readable, + format_recipient, + get_international_phone_info, + international_phone_info, + is_us_phone_number, + try_validate_and_format_phone_number, + validate_and_format_phone_number, + validate_email_address, + validate_phone_number, +) + +valid_us_phone_numbers = [ + "1-202-555-0104", + "+12025550104", + "12025550104", + "2025550104", + "(202) 555-0104", +] + + +valid_international_phone_numbers = [ + "+71234567890", # Russia + "+447123456789", # UK + "+4407123456789", # UK + "+4407123 456789", # UK + "+4407123-456-789", # UK + "+23051234567", # Mauritius, + "+682 12345", # Cook islands + "+3312345678", + "+9-2345-12345-12345", # 15 digits +] + + +valid_phone_numbers = valid_us_phone_numbers + valid_international_phone_numbers + + +invalid_us_phone_numbers = sum( + [ + [(phone_number, error) for phone_number in group] + for error, group in [ + ( + "Too many digits", + ( + "55512345678", + "+155512345678", + "(555) 1234-5678", + ), + ), + ( + "Not enough digits", + ( + "555123123", + "(555) 123-123", + "7890x32109", + "07123 ☟☜⬇⬆☞☝", + "07123☟☜⬇⬆☞☝", + ), + ), + ("Phone number range is not in use", ("1555123123",)), + ("Phone number is not possible", ("07123 456789...",)), + ( + "The string supplied did not seem to be a phone number.", + ( + '07";DROP TABLE;"', + "ALPHANUM3R1C", + ), + ), + ] + ], + [], +) + + +invalid_phone_numbers = [ + ("+80233456789", "Not a valid country prefix"), + ("1234567", "Not enough digits"), + ("+682 1234", "Not enough digits"), # Cook Islands phone numbers can be 5 digits + ("+12345 12345 12345 6", "Too many digits"), +] + + +valid_email_addresses = ( + "email@domain.com", + "email@domain.COM", + "firstname.lastname@domain.com", + "firstname.o'lastname@domain.com", + "email@subdomain.domain.com", + "firstname+lastname@domain.com", + "1234567890@domain.com", + "email@domain-one.com", + "_______@domain.com", + "email@domain.name", + "email@domain.superlongtld", + "email@domain.co.jp", + "firstname-lastname@domain.com", + "info@german-financial-services.vermögensberatung", + "info@german-financial-services.reallylongarbitrarytldthatiswaytoohugejustincase", + "japanese-info@例え.テスト", + "email@double--hyphen.com", +) +invalid_email_addresses = ( + "email@123.123.123.123", + "email@[123.123.123.123]", + "plainaddress", + "@no-local-part.com", + "Outlook Contact ", + "no-at.domain.com", + "no-tld@domain", + ";beginning-semicolon@domain.co.uk", + "middle-semicolon@domain.co;uk", + "trailing-semicolon@domain.com;", + '"email+leading-quotes@domain.com', + 'email+middle"-quotes@domain.com', + '"quoted-local-part"@domain.com', + '"quoted@domain.com"', + "lots-of-dots@domain..gov..uk", + "two-dots..in-local@domain.com", + "multiple@domains@domain.com", + "spaces in local@domain.com", + "spaces-in-domain@dom ain.com", + "underscores-in-domain@dom_ain.com", + "pipe-in-domain@example.com|gov.uk", + "comma,in-local@gov.uk", + "comma-in-domain@domain,gov.uk", + "pound-sign-in-local£@domain.com", + "local-with-’-apostrophe@domain.com", + "local-with-”-quotes@domain.com", + "domain-starts-with-a-dot@.domain.com", + "brackets(in)local@domain.com", + "email-too-long-{}@example.com".format("a" * 320), + "incorrect-punycode@xn---something.com", +) + + +@pytest.mark.parametrize("phone_number", valid_international_phone_numbers) +def test_detect_international_phone_numbers(phone_number): + assert is_us_phone_number(phone_number) is False + + +@pytest.mark.parametrize("phone_number", valid_us_phone_numbers) +def test_detect_us_phone_numbers(phone_number): + assert is_us_phone_number(phone_number) is True + + +@pytest.mark.parametrize( + "phone_number, expected_info", + [ + ( + "+4407900900123", + international_phone_info( + international=True, + country_prefix="44", # UK + billable_units=1, + ), + ), + ( + "+4407700900123", + international_phone_info( + international=True, + country_prefix="44", # Number in TV range + billable_units=1, + ), + ), + ( + "+4407700800123", + international_phone_info( + international=True, + country_prefix="44", # UK Crown dependency, so prefix same as UK + billable_units=1, + ), + ), + ( + "+20-12-1234-1234", + international_phone_info( + international=True, + country_prefix="20", # Egypt + billable_units=1, + ), + ), + ( + "+201212341234", + international_phone_info( + international=True, + country_prefix="20", # Egypt + billable_units=1, + ), + ), + ( + "+1 664-491-3434", + international_phone_info( + international=True, + country_prefix="1664", # Montserrat + billable_units=1, + ), + ), + ( + "+71234567890", + international_phone_info( + international=True, + country_prefix="7", # Russia + billable_units=1, + ), + ), + ( + "1-202-555-0104", + international_phone_info( + international=False, + country_prefix="1", # USA + billable_units=1, + ), + ), + ( + "202-555-0104", + international_phone_info( + international=False, + country_prefix="1", # USA + billable_units=1, + ), + ), + ( + "+23051234567", + international_phone_info( + international=True, + country_prefix="230", # Mauritius + billable_units=1, + ), + ), + ], +) +def test_get_international_info(phone_number, expected_info): + assert get_international_phone_info(phone_number) == expected_info + + +@pytest.mark.parametrize( + "phone_number", + [ + "+21 4321 0987", + "+00997 1234 7890", + "+801234-7890", + "+8-0-1234-78901", + ], +) +def test_get_international_info_raises(phone_number): + with pytest.raises(InvalidPhoneError) as error: + get_international_phone_info(phone_number) + assert str(error.value) == "Not a valid country prefix" + + +@pytest.mark.parametrize("phone_number", valid_us_phone_numbers) +@pytest.mark.parametrize( + "extra_args", + [ + {}, + {"international": False}, + ], +) +def test_phone_number_accepts_valid_values(extra_args, phone_number): + try: + validate_phone_number(phone_number, **extra_args) + except InvalidPhoneError: + pytest.fail("Unexpected InvalidPhoneError") + + +@pytest.mark.parametrize("phone_number", valid_phone_numbers) +def test_phone_number_accepts_valid_international_values(phone_number): + try: + validate_phone_number(phone_number, international=True) + except InvalidPhoneError: + pytest.fail("Unexpected InvalidPhoneError") + + +@pytest.mark.parametrize("phone_number", valid_us_phone_numbers) +def test_valid_us_phone_number_can_be_formatted_consistently(phone_number): + assert validate_and_format_phone_number(phone_number) == "+12025550104" + + +@pytest.mark.parametrize( + "phone_number, expected_formatted", + [ + ("+44071234567890", "+4471234567890"), + ("1-202-555-0104", "+12025550104"), + ("+12025550104", "+12025550104"), + ("12025550104", "+12025550104"), + ("+12025550104", "+12025550104"), + ("+23051234567", "+23051234567"), + ], +) +def test_valid_international_phone_number_can_be_formatted_consistently( + phone_number, expected_formatted +): + assert ( + validate_and_format_phone_number(phone_number, international=True) + == expected_formatted + ) + + +@pytest.mark.parametrize("phone_number, error_message", invalid_us_phone_numbers) +@pytest.mark.parametrize( + "extra_args", + [ + {}, + {"international": False}, + ], +) +def test_phone_number_rejects_invalid_values(extra_args, phone_number, error_message): + with pytest.raises(InvalidPhoneError) as e: + validate_phone_number(phone_number, **extra_args) + assert error_message == str(e.value) + + +@pytest.mark.parametrize("phone_number, error_message", invalid_phone_numbers) +def test_phone_number_rejects_invalid_international_values(phone_number, error_message): + with pytest.raises(InvalidPhoneError) as e: + validate_phone_number(phone_number, international=True) + assert error_message == str(e.value) + + +@pytest.mark.parametrize("email_address", valid_email_addresses) +def test_validate_email_address_accepts_valid(email_address): + try: + assert validate_email_address(email_address) == email_address + except InvalidEmailError: + pytest.fail("Unexpected InvalidEmailError") + + +@pytest.mark.parametrize( + "email", + [ + " email@domain.com ", + "\temail@domain.com", + "\temail@domain.com\n", + "\u200Bemail@domain.com\u200B", + ], +) +def test_validate_email_address_strips_whitespace(email): + assert validate_email_address(email) == "email@domain.com" + + +@pytest.mark.parametrize("email_address", invalid_email_addresses) +def test_validate_email_address_raises_for_invalid(email_address): + with pytest.raises(InvalidEmailError) as e: + validate_email_address(email_address) + assert str(e.value) == "Not a valid email address" + + +@pytest.mark.parametrize("phone_number", valid_us_phone_numbers) +def test_validates_against_guestlist_of_phone_numbers(phone_number): + assert allowed_to_send_to( + phone_number, ["2025550104", "2025550105", "test@example.com"] + ) + assert not allowed_to_send_to( + phone_number, ["2025550105", "2028675309", "test@example.com"] + ) + + +@pytest.mark.parametrize( + "recipient_number, allowlist_number", + [ + ["+4407123-456-789", "+4407123456789"], + ["+4407123456789", "+4407123-456-789"], + ], +) +def test_validates_against_guestlist_of_international_phone_numbers( + recipient_number, allowlist_number +): + assert allowed_to_send_to(recipient_number, [allowlist_number]) + + +@pytest.mark.parametrize("email_address", valid_email_addresses) +def test_validates_against_guestlist_of_email_addresses(email_address): + assert not allowed_to_send_to( + email_address, ["very_special_and_unique@example.com"] + ) + + +@pytest.mark.parametrize( + "phone_number, expected_formatted", + [ + ("+4407900900123", "+44 7900 900123"), # UK + ("+44(0)7900900123", "+44 7900 900123"), # UK + ("+447900900123", "+44 7900 900123"), # UK + # ("+20-12-1234-1234", "+20 121 234 1234"), # Egypt + # ("+201212341234", "+20 121 234 1234"), # Egypt + ("+1 664 491-3434", "+1 664-491-3434"), # Montserrat + ("+7 499 1231212", "+7 499 123-12-12"), # Moscow (Russia) + ("1-202-555-0104", "(202) 555-0104"), # Washington DC (USA) + ("+23051234567", "+230 5123 4567"), # Mauritius + ("+33(0)1 12345678", "+33 1 12 34 56 78"), # Paris (France) + ], +) +def test_format_us_and_international_phone_numbers(phone_number, expected_formatted): + assert format_phone_number_human_readable(phone_number) == expected_formatted + + +@pytest.mark.parametrize( + "recipient, expected_formatted", + [ + (True, ""), + (False, ""), + (0, ""), + (0.1, ""), + (None, ""), + ("foo", "foo"), + ("TeSt@ExAmPl3.com", "test@exampl3.com"), + ("+4407900 900 123", "+447900900123"), + ("+1 800 555 5555", "+18005555555"), + ], +) +def test_format_recipient(recipient, expected_formatted): + assert format_recipient(recipient) == expected_formatted + + +def test_try_format_recipient_doesnt_throw(): + assert try_validate_and_format_phone_number("ALPHANUM3R1C") == "ALPHANUM3R1C" + + +def test_format_phone_number_human_readable_doenst_throw(): + assert format_phone_number_human_readable("ALPHANUM3R1C") == "ALPHANUM3R1C" diff --git a/tests/notifications_utils/test_request_header_authentication.py b/tests/notifications_utils/test_request_header_authentication.py new file mode 100644 index 000000000..f595619ce --- /dev/null +++ b/tests/notifications_utils/test_request_header_authentication.py @@ -0,0 +1,61 @@ +import pytest +from werkzeug.test import EnvironBuilder + +from notifications_utils.request_helper import NotifyRequest, _check_proxy_header_secret + + +@pytest.mark.parametrize( + "header,secrets,expected", + [ + ( + {"X-Custom-Forwarder": "right_key"}, + ["right_key", "old_key"], + (True, "Key used: 1"), + ), + ({"X-Custom-Forwarder": "right_key"}, ["right_key"], (True, "Key used: 1")), + ({"X-Custom-Forwarder": "right_key"}, ["right_key", ""], (True, "Key used: 1")), + ({"My-New-Header": "right_key"}, ["right_key", ""], (True, "Key used: 1")), + ({"X-Custom-Forwarder": "right_key"}, ["", "right_key"], (True, "Key used: 2")), + ( + {"X-Custom-Forwarder": "right_key"}, + ["", "old_key", "right_key"], + (True, "Key used: 3"), + ), + ( + {"X-Custom-Forwarder": ""}, + ["right_key", "old_key"], + (False, "Header exists but is empty"), + ), + ( + {"X-Custom-Forwarder": "right_key"}, + ["", None], + (False, "Secrets are not configured"), + ), + ( + {"X-Custom-Forwarder": "wrong_key"}, + ["right_key", "old_key"], + (False, "Header didn't match any keys"), + ), + ], +) +def test_request_header_authorization(header, secrets, expected): + builder = EnvironBuilder() + builder.headers.extend(header) + request = NotifyRequest(builder.get_environ()) + + res = _check_proxy_header_secret(request, secrets, list(header.keys())[0]) + assert res == expected + + +@pytest.mark.parametrize( + "secrets,expected", + [ + (["old_key", "right_key"], (False, "Header missing")), + ], +) +def test_request_header_authorization_missing_header(secrets, expected): + builder = EnvironBuilder() + request = NotifyRequest(builder.get_environ()) + + res = _check_proxy_header_secret(request, secrets) + assert res == expected diff --git a/tests/notifications_utils/test_request_id.py b/tests/notifications_utils/test_request_id.py new file mode 100644 index 000000000..fee5e7d87 --- /dev/null +++ b/tests/notifications_utils/test_request_id.py @@ -0,0 +1,32 @@ +from notifications_utils import request_helper + + +def test_request_id_is_set_on_response(app): + request_helper.init_app(app) + client = app.test_client() + + with app.app_context(): + response = client.get( + "/", headers={"X-B3-TraceId": "generated", "X-B3-SpanId": "generated"} + ) + assert response.headers["X-B3-TraceId"] == "generated" + assert response.headers["X-B3-SpanId"] == "generated" + + +def test_request_id_is_set_on_error_response(app): + request_helper.init_app(app) + client = app.test_client() + # turn off DEBUG so that the flask default error handler gets triggered + app.config["DEBUG"] = False + + @app.route("/") + def error_route(): + raise Exception() + + with app.app_context(): + response = client.get( + "/", headers={"X-B3-TraceId": "generated", "X-B3-SpanId": "generated"} + ) + assert response.status_code == 500 + assert response.headers["X-B3-TraceId"] == "generated" + assert response.headers["X-B3-SpanId"] == "generated" diff --git a/tests/notifications_utils/test_s3.py b/tests/notifications_utils/test_s3.py new file mode 100644 index 000000000..46b863c4f --- /dev/null +++ b/tests/notifications_utils/test_s3.py @@ -0,0 +1,108 @@ +from urllib.parse import parse_qs + +import botocore +import pytest + +from notifications_utils.s3 import S3ObjectNotFound, s3download, s3upload + +contents = "some file data" +region = "eu-west-1" +bucket = "some_bucket" +location = "some_file_location" +content_type = "binary/octet-stream" + + +def test_s3upload_save_file_to_bucket(mocker): + mocked = mocker.patch("notifications_utils.s3.Session.resource") + s3upload( + filedata=contents, region=region, bucket_name=bucket, file_location=location + ) + mocked_put = mocked.return_value.Object.return_value.put + mocked_put.assert_called_once_with( + Body=contents, + ServerSideEncryption="AES256", + ContentType=content_type, + ) + + +def test_s3upload_save_file_to_bucket_with_contenttype(mocker): + content_type = "image/png" + mocked = mocker.patch("notifications_utils.s3.Session.resource") + s3upload( + filedata=contents, + region=region, + bucket_name=bucket, + file_location=location, + content_type=content_type, + ) + mocked_put = mocked.return_value.Object.return_value.put + mocked_put.assert_called_once_with( + Body=contents, + ServerSideEncryption="AES256", + ContentType=content_type, + ) + + +def test_s3upload_raises_exception(app, mocker): + mocked = mocker.patch("notifications_utils.s3.Session.resource") + response = {"Error": {"Code": 500}} + exception = botocore.exceptions.ClientError(response, "Bad exception") + mocked.return_value.Object.return_value.put.side_effect = exception + with pytest.raises(botocore.exceptions.ClientError): + s3upload( + filedata=contents, + region=region, + bucket_name=bucket, + file_location="location", + ) + + +def test_s3upload_save_file_to_bucket_with_urlencoded_tags(mocker): + mocked = mocker.patch("notifications_utils.s3.Session.resource") + s3upload( + filedata=contents, + region=region, + bucket_name=bucket, + file_location=location, + tags={"a": "1/2", "b": "x y"}, + ) + mocked_put = mocked.return_value.Object.return_value.put + + # make sure tags were a urlencoded query string + encoded_tags = mocked_put.call_args[1]["Tagging"] + assert parse_qs(encoded_tags) == {"a": ["1/2"], "b": ["x y"]} + + +def test_s3upload_save_file_to_bucket_with_metadata(mocker): + mocked = mocker.patch("notifications_utils.s3.Session.resource") + s3upload( + filedata=contents, + region=region, + bucket_name=bucket, + file_location=location, + metadata={"status": "valid", "pages": "5"}, + ) + mocked_put = mocked.return_value.Object.return_value.put + + metadata = mocked_put.call_args[1]["Metadata"] + assert metadata == {"status": "valid", "pages": "5"} + + +def test_s3download_gets_file(mocker): + mocked = mocker.patch("notifications_utils.s3.Session.resource") + mocked_object = mocked.return_value.Object + mocked_get = mocked.return_value.Object.return_value.get + s3download("bucket", "location.file") + mocked_object.assert_called_once_with("bucket", "location.file") + mocked_get.assert_called_once_with() + + +def test_s3download_raises_on_error(mocker): + mocked = mocker.patch("notifications_utils.s3.Session.resource") + mocked.return_value.Object.side_effect = botocore.exceptions.ClientError( + {"Error": {"Code": 404}}, + "Bad exception", + ) + + with pytest.raises(S3ObjectNotFound): + s3download("bucket", "location.file") diff --git a/tests/notifications_utils/test_safe_string.py b/tests/notifications_utils/test_safe_string.py new file mode 100644 index 000000000..a0bf3360f --- /dev/null +++ b/tests/notifications_utils/test_safe_string.py @@ -0,0 +1,47 @@ +import pytest + +from notifications_utils.safe_string import ( + make_string_safe_for_email_local_part, + make_string_safe_for_id, +) + + +@pytest.mark.parametrize( + "unsafe_string, expected_safe", + [ + ("name with spaces", "name.with.spaces"), + ("singleword", "singleword"), + ("UPPER CASE", "upper.case"), + ("Service - with dash", "service.with.dash"), + ("lots of spaces", "lots.of.spaces"), + ("name.with.dots", "name.with.dots"), + ("name-with-other-delimiters", "namewithotherdelimiters"), + (".leading", "leading"), + ("trailing.", "trailing"), + ("üńïçödë wördś", "unicode.words"), + ], +) +def test_email_safe_return_dot_separated_email_local_part(unsafe_string, expected_safe): + assert make_string_safe_for_email_local_part(unsafe_string) == expected_safe + + +@pytest.mark.parametrize( + "unsafe_string, expected_safe", + [ + ("name with spaces", "name-with-spaces"), + ("singleword", "singleword"), + ("UPPER CASE", "upper-case"), + ("Service - with dash", "service---with-dash"), + ("lots of spaces", "lots-of-spaces"), + ("name.with.dots", "namewithdots"), + ("name-with-dashes", "name-with-dashes"), + ("N. London", "n-london"), + (".leading", "leading"), + ("-leading", "-leading"), + ("trailing.", "trailing"), + ("trailing-", "trailing-"), + ("üńïçödë wördś", "unicode-words"), + ], +) +def test_id_safe_return_dash_separated_string(unsafe_string, expected_safe): + assert make_string_safe_for_id(unsafe_string) == expected_safe diff --git a/tests/notifications_utils/test_sanitise_text.py b/tests/notifications_utils/test_sanitise_text.py new file mode 100644 index 000000000..062f280e0 --- /dev/null +++ b/tests/notifications_utils/test_sanitise_text.py @@ -0,0 +1,313 @@ +import pytest + +from notifications_utils.sanitise_text import SanitiseASCII, SanitiseSMS, SanitiseText + +params, ids = zip( + (("a", "a"), "ascii char (a)"), + # ascii control char (not in GSM) + (("\t", " "), "ascii control char not in gsm (tab)"), + # TODO we support lots of languages now not in the GSM charset so maybe make this 'downgrading' go away + # TODO for now comment out this line because it directly conflicts with support for Turkish + # these are not in GSM charset so are downgraded + # (("ç", "c"), "decomposed unicode char (C with cedilla)"), + # these unicode chars should change to something completely different for compatibility + # (("–", "-"), "compatibility transform unicode char (EN DASH (U+2013)"), + # (("—", "-"), "compatibility transform unicode char (EM DASH (U+2014)"), + ( + ("…", "..."), + "compatibility transform unicode char (HORIZONTAL ELLIPSIS (U+2026)", + ), + (("\u200B", ""), "compatibility transform unicode char (ZERO WIDTH SPACE (U+200B)"), + ( + ("‘", "'"), + "compatibility transform unicode char (LEFT SINGLE QUOTATION MARK (U+2018)", + ), + ( + ("’", "'"), + "compatibility transform unicode char (RIGHT SINGLE QUOTATION MARK (U+2019)", + ), + # Conflict with Chinese quotes + # ( + # ("“", '"'), + # "compatibility transform unicode char (LEFT DOUBLE QUOTATION MARK (U+201C) ", + # ), + # ( + # ("”", '"'), + # "compatibility transform unicode char (RIGHT DOUBLE QUOTATION MARK (U+201D)", + # ), + (("\xa0", " "), "nobreak transform unicode char (NO-BREAK SPACE (U+00A0))"), + # this unicode char is not decomposable + (("😬", "?"), "undecomposable unicode char (grimace emoji)"), + (("↉", "?"), "vulgar fraction (↉) that we do not try decomposing"), +) + + +@pytest.mark.parametrize("char, expected", params, ids=ids) +@pytest.mark.parametrize("cls", [SanitiseSMS, SanitiseASCII]) +def test_encode_chars_the_same_for_ascii_and_sms(char, expected, cls): + assert cls.encode_char(char) == expected + + +params, ids = zip( + # ascii control chars are allowed in GSM but not in ASCII + (("\n", "\n", "?"), "ascii control char in gsm (newline)"), + (("\r", "\r", "?"), "ascii control char in gsm (return)"), + # These characters are present in GSM but not in ascii + (("à", "à", "a"), "non-ascii gsm char (a with accent)"), + (("€", "€", "?"), "non-ascii gsm char (euro)"), + # These characters are Welsh characters that are not present in GSM + (("â", "â", "a"), "non-gsm Welsh char (a with hat)"), + (("Ŷ", "Ŷ", "Y"), "non-gsm Welsh char (capital y with hat)"), + (("ë", "ë", "e"), "non-gsm Welsh char (e with dots)"), + # (("Ò", "Ò", "O"), "non-gsm Welsh char (capital O with grave accent)"), # conflicts with Vietnamese + (("í", "í", "i"), "non-gsm Welsh char (i with accent)"), +) + + +@pytest.mark.parametrize("char, expected_sms, expected_ascii", params, ids=ids) +def test_encode_chars_different_between_ascii_and_sms( + char, expected_sms, expected_ascii +): + assert SanitiseSMS.encode_char(char) == expected_sms + assert SanitiseASCII.encode_char(char) == expected_ascii + + +@pytest.mark.parametrize( + "codepoint, char", + [ + ("0041", "A"), + ("0061", "a"), + ], +) +def test_get_unicode_char_from_codepoint(codepoint, char): + assert SanitiseText.get_unicode_char_from_codepoint(codepoint) == char + + +@pytest.mark.parametrize( + "bad_input", ["", "GJ", "00001", '0001";import sys;sys.exit(0)"'] +) +def test_get_unicode_char_from_codepoint_rejects_bad_input(bad_input): + with pytest.raises(ValueError): + SanitiseText.get_unicode_char_from_codepoint(bad_input) + + +@pytest.mark.parametrize( + "content, expected", + [ + ("Łōdź", "?odz"), + ( + "The quick brown fox jumps over the lazy dog", + "The quick brown fox jumps over the lazy dog", + ), + ], +) +def test_encode_string(content, expected): + assert SanitiseSMS.encode(content) == expected + assert SanitiseASCII.encode(content) == expected + + +@pytest.mark.parametrize( + "content, cls, expected", + [ + ("The quick brown fox jumps over the lazy dog", SanitiseSMS, set()), + ( + "The “quick” brown fox has some downgradable characters\xa0", + SanitiseSMS, + set(), + ), + ("Need more 🐮🔔", SanitiseSMS, {"🐮", "🔔"}), + ("Ŵêlsh chârâctêrs ârê cômpâtîblê wîth SanitiseSMS", SanitiseSMS, set()), + ("Lots of GSM chars that arent ascii compatible:\n\r€", SanitiseSMS, set()), + ( + "Lots of GSM chars that arent ascii compatible:\n\r€", + SanitiseASCII, + {"\n", "\r", "€"}, + ), + ("Αυτό είναι ένα τεστ", SanitiseSMS, set()), + ("。、“”():;?!", SanitiseSMS, set()), # Chinese punctuation + ], +) +def test_sms_encoding_get_non_compatible_characters(content, cls, expected): + assert cls.get_non_compatible_characters(content) == expected + + +@pytest.mark.parametrize( + "content, expected", + [ + ("이것은 테스트입니다", True), # Korean + ("Αυτό είναι ένα τεστ", True), # Greek + ("Это проверка", True), # Russian + ("นี่คือการทดสอบ", True), # Thai + ("இது ஒரு சோதனை", True), # Tamil + ("これはテストです", True), # Japanese + ("Đây là một bài kiểm tra", True), # Vietnamese + ("𐤓𐤓𐤓𐤈𐤆", False), # Phoenician + ("这是一次测试", True), # Mandarin (Simplified) + ("Bunda Türkçe karakterler var", True), # Turkish + ( + "盾牌镍币是第一种采用白铜制作的5美分硬币,由詹姆斯·B·朗埃克设计,从1866年发行到1883年再由自由女神头像镍币取代。", + True, + ), # Chinese from wikipedia 1 + ( + "国际志愿者日為每年的12月5日,它是由联合国大会在1985年12月17日通过的A/RES/40/212决议[1]上确定的[2]。", + True, + ), # Chinese from wikipedia 2 + ( + "哪一種多邊形內部至少存在一個可以看見多邊形所有邊界和所有內部區域的點?", + True, + ), # Chinese from wikipedia 3 + ( + """都柏林在官方城市邊界內的人口是大約495,000人(愛爾蘭中央統計處2002年人口調查), + 然而這種統計已經沒有什麼太大的意義,因為都柏林的市郊地區和衛星城鎮已經大幅地發展與擴張。""", + True, + ), # Chinese from wikipedia 4 + ( + "一名是Dubh Linn(愛爾蘭語,意為「黑色的水池」)的英國習語。當然也有人質疑這語源。", + True, + ), # Chinese from wikipedia 5 + ( + "都柏林拥有世界闻名的文学历史,曾经产生过许多杰出的文学家,例如诺贝尔文学奖得主威廉·巴特勒·叶芝、蕭伯納和塞繆爾·貝克特。", + True, + ), # Chinese from wikipedia 6 + ( + "愛爾蘭國家博物館的四个分馆中有三個分館都位於都柏林:考古学分馆在基尔代尔街,装饰艺术和历史分馆在柯林斯军营,而自然史分馆在梅林街[12]。", + True, + ), # Chinese from wikipedia 7 + ( + "從17世紀開始,城市在寬闊街道事務委員會的幫助下開始迅速擴張。乔治亚都柏林曾一度是大英帝國僅次於倫敦的第二大城市。", + True, + ), # Chinese from wikipedia 8 + ( + "一些著名的都柏林街道建築仍以倒閉前在此經營的酒吧和商業公司命名。", + True, + ), # Chinese from wikipedia 9 + ( + "1922年,隨著愛爾蘭的分裂,都柏林成為愛爾蘭自由邦(1922年–1937年)的首都。現在則為愛爾蘭共和國的首都。", + True, + ), # Chinese from wikipedia 10 + ( + """Dưới đây là danh sách tất cả các tên người dùng hiện đang có + tại Wikipedia, hoặc những tên người dùng trong một nhóm chỉ định. """, + True, + ), # Vietnamese from wikipedia 1 + ( + """Các bảo quản viên đảm nhận những trách nhiệm này với tư cách là tình + nguyện viên sau khi trải qua quá trình xem xét của cộng đồng. """, + True, + ), # Vietnamese from wikipedia 2 + ( + """Họ không bao giờ được yêu cầu sử dụng các công cụ của mình và không bao + giờ được sử dụng chúng để giành lợi thế trong một cuộc tranh chấp mà họ có + tham gia. Không nên nhầm lẫn bảo quản viên với quản trị viên hệ + thống của Wikimedia ("sysadmins").""", + True, + ), # Vietnamese from wikipedia 3 + ( + "Để đạt được mục tiêu chung đó, Wikipedia đề ra một số quy định và hướng dẫn. ", + True, + ), # Vietnamese from wikipedia 4 + ("Wikipedia là một bách khoa toàn thư. ", True), # Vietnamese from wikipedia 5 + ( + "Phải đảm bảo bài viết mang lại ích lợi cho độc giả (coi độc giả là yếu tố quan trọng khi viết bài)", + True, + ), # Vietnamese from wikipedia 6 + ( + """Bài viết ở Wikipedia có thể chứa đựng từ ngữ và hình ảnh gây khó chịu + nhưng chỉ vì mục đích tốt đẹp. Không cần thêm vào phủ định trách nhiệm.""", + True, + ), # Vietnamese from wikipedia 7 + ( + "Đừng sử dụng hình ảnh mà chỉ có thể xem được chính xác với công cụ 3D.", + True, + ), # Vietnamese from wikipedia 8 + ( + """Trích dẫn bất cứ nôi dung tranh luận gốc nào cũng nên có liên quan + đến tranh luận đó (hoặc minh họa cho phong cách) và chỉ nên dài vừa đủ.""", + True, + ), # Vietnamese from wikipedia 9 + ( + """Không tung tin vịt, thông tin sai lệch hoặc nội dung không kiểm chứng được vào bài viết. + Tuy nhiên, những bài viết về những tin vịt nổi bật được chấp nhận.""", + True, + ), # Vietnamese from wikipedia 10 + ( + "수록되어 있으며, 넘겨주기를 포함한 일반 문서 수는 1,434,776개。", + True, + ), # Korean from wikipedia includes circle-period + ( + "日本語表記にも対応するようになり[1]、徐々に日本人のユーザーも増大していった、と述べられている。", + True, + ), # Japanese from wikipedia includes circle-period + ( + "DSHS:我们发现您的账户存在潜在欺诈行为。请致电您的 EBT 卡背面的号码废止或前往当地办公室获取一个新账户。回复 “STOP(退订)” 退订", + True, + ), # State of Washington Chinese Simplified + ( + """DSHS៖ ប ើងោនកត់សម្គា ល់ប ើញក្ដរបោកប្រោស់ជាសក្ដា នុពលបៅបលើគណនីរបស់អ្នក។ សូមបៅបៅបលខ #បៅបលើខនងក្ដត + EBT របស់អ្នក ប ើមបីបោោះបង់ ឬក៏បៅក្ដន់ក្ដរយាិ ល័ បៅកនុងតំបន់របស់អ្នក + ប ើមបីបសនើសុំក្ដតថ្មី។ ប្លើ តបជាអ្កសរ ឈប់ ប ើមបីបញ្ឈប់""", + True, + ), # State of Washington Khmer + ( + """DSHS: 귀하의 계정 상에 사기가 일어났을 가능성이 포착되었습니다. 귀하의 EBT 카드 뒷면에있는 + 번호로 전화를 걸어 취소하거나 현지 사무소로 가서 새 것을 발급 받으세요. 중단하려면중단이라고 회신하세요.""", + True, + ), # State of WA Korean + ( + """ຂ ຄໍ້ ວາມການສໍ້ໂກງທອາດເປັນໄປໄດ ໍ້ DSHS: ພວກເຮາົໄດສໍ້ງັເກດເຫນັການສໂກງທີ່ອາດເປັນໄປໄດໃໍ້ນບນັຊຂ ອງທີ່ານ. + ໂທຫາ # ທ ຢີ່ ດາໍ້ນຫ ງັຂອງບດັ EBT ຂອງທີ່ານເພອຍກົ ເລກ ຫ ໄປຍງັຫອໍ້ງການປະຈາ ທອໍ້ງຖ ນຂອງທີ່ານ ເພີ່ອຂ + ບດັ ໃຫມີ່ . ຕອບກບັດວໍ້ ຍ STOP (ຢຸດເຊາົ) ເພອຢຸດເຊາົ""", + True, + ), # State of WA Lao + ( + """Fariin Khiyaamo Suurtogal ah DSHS: Waxaanu ka ogaanay khiyaamo suurtogal ah akoonkaaga. + Wax # ee ku yaal xaga danbe ee kadadhka + EBT si aad u joojisid ama u aadid xafiiska deegaanka uguna dalbatid a new one (mid cusub). + Ku jawaab JOOJI si aad u joojisid""", + True, + ), # State of WA Somali + ( + "إدارة الخدمات الاجتماعية والصحية في ولاية واشنطن (Washington State Department of Social and Health Services, WA DSHS): ستُجرى المقابلة الهاتفية معك المعنية بمراقبة جودة الطعام يوم xx/xx/xx الساعة 00:00 صباحًا/مساءً. قد يؤدي الفشل إلى إغلاق مخصصاتك. اتصل بالرقم 1-800-473-5661 إذا كانت لديك أسئلة.", # noqa + True, + ), # State of WA Arabic + ( + "WA DSHS: ਤੁਹਾਡੀ ਗੁਣਵੱਤਾ ਨਿਯੰਤਰਣ ਭੋਜਨ ਫ਼ੋਨ ਇੰਟਰਵਿਊ xx/xx/xx 'ਤੇ ਸਵੇਰੇ 00:00 ਵਜੇ/ਸ਼ਾਮ 'ਤੇ ਹੈ। ਅਸਫਲਤਾ ਤੁਹਾਡੇ ਲਾਭਾਂ ਨੂੰ ਬੰਦ ਕਰਨ ਦਾ ਕਾਰਨ ਬਣ ਸਕਦੀ ਹੈ। ਸਵਾਲਾਂ ਨਾਲ 1-800-473-5661 'ਤੇ ਕਾਲ ਕਰੋ।", # noqa + True, + ), # State of WA Punjabi + ( + "WA DSHS: ਵਿਅਕਤੀਗਤ ਭੋਜਨ ਵਿੱਚ ਤੁਹਾਡਾ ਗੁਣਵੱਤਾ ਨਿਯੰਤਰਣ ਇੰਟਰਵਿਊ xx/xx/xx 'ਤੇ ਸਵੇਰੇ 00:00 ਵਜੇ /ਸ਼ਾਮ 00:00 ਵਜੇ ਹੈ। ਅਸਫਲਤਾ ਤੁਹਾਡੇ ਲਾਭਾਂ ਨੂੰ ਬੰਦ ਕਰਨ ਦਾ ਕਾਰਨ ਬਣ ਸਕਦੀ ਹੈ। 1-800-473-5661 'ਤੇ w/ਸਵਾਲ ਨਾਲ ਕਾਲ ਕਰੋ।", # noqa + True, + ), # State of WA Punjabi + ], +) +def test_sms_supporting_additional_languages(content, expected): + assert SanitiseSMS.is_extended_language(content) is expected + + +@pytest.mark.parametrize( + "content, expected", + [ + ("이것은 테스트입니다", set()), # Korean + ("Αυτό είναι ένα τεστ", set()), # Greek + ("Это проверка", set()), # Russian + ("นี่คือการทดสอบ", set()), # Thai + ("இது ஒரு சோதனை", set()), # Tamil + ("これはテストです", set()), # Japanese + ("Đây là một bài kiểm tra", set()), # Vietnamese + ("𐤓𐤓𐤓𐤈𐤆", {"𐤆", "𐤈", "𐤓"}), # Phoenician + ("这是一次测试", set()), # Mandarin (Simplified) + ("Bunda Türkçe karakterler var", set()), # Turkish + ("。、“”():;?!", set()), # Chinese punctuation + (" ُ ُ", set()), # Arabic diacritics + ( + "WA DSHS: ਤੁਹਾਡੀ ਗੁਣਵੱਤਾ ਨਿਯੰਤਰਣ ਭੋਜਨ ਫ਼ੋਨ ਇੰਟਰਵਿਊ xx/xx/xx 'ਤੇ ਸਵੇਰੇ 00:00 ਵਜੇ/ਸ਼ਾਮ 'ਤੇ ਹੈ। ਅਸਫਲਤਾ ਤੁਹਾਡੇ ਲਾਭਾਂ ਨੂੰ ਬੰਦ ਕਰਨ ਦਾ ਕਾਰਨ ਬਣ ਸਕਦੀ ਹੈ। ਸਵਾਲਾਂ ਨਾਲ 1-800-473-5661 'ਤੇ ਕਾਲ ਕਰੋ।", # noqa + set(), + ), # Punjabi + ( + "WA DSHS: ਵਿਅਕਤੀਗਤ ਭੋਜਨ ਵਿੱਚ ਤੁਹਾਡਾ ਗੁਣਵੱਤਾ ਨਿਯੰਤਰਣ ਇੰਟਰਵਿਊ xx/xx/xx 'ਤੇ ਸਵੇਰੇ 00:00 ਵਜੇ /ਸ਼ਾਮ 00:00 ਵਜੇ ਹੈ। ਅਸਫਲਤਾ ਤੁਹਾਡੇ ਲਾਭਾਂ ਨੂੰ ਬੰਦ ਕਰਨ ਦਾ ਕਾਰਨ ਬਣ ਸਕਦੀ ਹੈ। 1-800-473-5661 'ਤੇ w/ਸਵਾਲ ਨਾਲ ਕਾਲ ਕਰੋ।", # noqa + set(), + ), # more Punjabi + ], +) +def test_get_non_compatible_characters(content, expected): + assert SanitiseSMS.get_non_compatible_characters(content) == expected diff --git a/tests/notifications_utils/test_serialised_model.py b/tests/notifications_utils/test_serialised_model.py new file mode 100644 index 000000000..b83a4d1e5 --- /dev/null +++ b/tests/notifications_utils/test_serialised_model.py @@ -0,0 +1,220 @@ +import sys + +import pytest + +from notifications_utils.serialised_model import ( + SerialisedModel, + SerialisedModelCollection, +) + + +def test_cant_be_instatiated_with_abstract_properties(): + class Custom(SerialisedModel): + pass + + class CustomCollection(SerialisedModelCollection): + pass + + with pytest.raises(TypeError) as e: + SerialisedModel() + + if sys.version_info < (3, 9): + assert str(e.value) == ( + "Can't instantiate abstract class SerialisedModel with abstract methods ALLOWED_PROPERTIES" + ) + else: + assert "Can't instantiate abstract class SerialisedModel with abstract method ALLOWED_PROPERTIES" + + with pytest.raises(TypeError) as e: + Custom() + + if sys.version_info < (3, 9): + assert str(e.value) == ( + "Can't instantiate abstract class Custom with abstract methods ALLOWED_PROPERTIES" + ) + else: + assert str(e.value) == ( + "Can't instantiate abstract class Custom without an implementation for abstract method 'ALLOWED_PROPERTIES'" + ) + + with pytest.raises(TypeError) as e: + SerialisedModelCollection() + + if sys.version_info < (3, 9): + assert str(e.value) == ( + "Can't instantiate abstract class SerialisedModelCollection with abstract methods model" + ) + else: + assert str(e.value).startswith( + "Can't instantiate abstract class SerialisedModelCollection without an implementation" + ) + + with pytest.raises(TypeError) as e: + CustomCollection() + + if sys.version_info < (3, 9): + assert str(e.value) == ( + "Can't instantiate abstract class CustomCollection with abstract methods model" + ) + else: + assert str(e.value) == ( + "Can't instantiate abstract class CustomCollection without an implementation for abstract method 'model'" + ) + + +def test_looks_up_from_dict(): + class Custom(SerialisedModel): + ALLOWED_PROPERTIES = {"foo"} + + assert Custom({"foo": "bar"}).foo == "bar" + + +def test_cant_override_custom_property_from_dict(): + class Custom(SerialisedModel): + ALLOWED_PROPERTIES = {"foo"} + + @property + def foo(self): + return "bar" + + with pytest.raises(AttributeError) as e: + assert Custom({"foo": "NOPE"}).foo == "bar" + assert ( + str(e.value) + == "property 'foo' of 'test_cant_override_custom_property_from_dict..Custom' object has no setter" + ) + + +@pytest.mark.parametrize( + "json_response", + ( + {}, + {"foo": "bar"}, # Should still raise an exception + ), +) +def test_model_raises_for_unknown_attributes(json_response): + class Custom(SerialisedModel): + ALLOWED_PROPERTIES = set() + + model = Custom(json_response) + + assert model.ALLOWED_PROPERTIES == set() + + with pytest.raises(AttributeError) as e: + model.foo + + assert str(e.value) == ("'Custom' object has no attribute 'foo'") + + +def test_model_raises_keyerror_if_item_missing_from_dict(): + class Custom(SerialisedModel): + ALLOWED_PROPERTIES = {"foo"} + + with pytest.raises(KeyError) as e: + Custom({}).foo + + assert str(e.value) == "'foo'" + + +@pytest.mark.parametrize( + "json_response", + ( + {}, + {"foo": "bar"}, # Should be ignored + ), +) +def test_model_doesnt_swallow_attribute_errors(json_response): + class Custom(SerialisedModel): + ALLOWED_PROPERTIES = set() + + @property + def foo(self): + raise AttributeError("Something has gone wrong") + + with pytest.raises(AttributeError) as e: + Custom(json_response).foo + + assert str(e.value) == "Something has gone wrong" + + +def test_dynamic_properties_are_introspectable(): + class Custom(SerialisedModel): + ALLOWED_PROPERTIES = {"foo", "bar", "baz"} + + instance = Custom({"foo": "", "bar": "", "baz": ""}) + + assert dir(instance)[-3:] == ["bar", "baz", "foo"] + + +def test_empty_serialised_model_collection(): + class CustomCollection(SerialisedModelCollection): + model = None + + instance = CustomCollection([]) + + assert not instance + assert len(instance) == 0 + + +def test_serialised_model_collection_returns_models_from_list(): + class Custom(SerialisedModel): + ALLOWED_PROPERTIES = {"x"} + + class CustomCollection(SerialisedModelCollection): + model = Custom + + instance = CustomCollection( + [ + {"x": "foo"}, + {"x": "bar"}, + {"x": "baz"}, + ] + ) + + assert instance + assert len(instance) == 3 + + assert instance[0].x == "foo" + assert instance[1].x == "bar" + assert instance[2].x == "baz" + + assert [item.x for item in instance] == [ + "foo", + "bar", + "baz", + ] + + assert [type(item) for item in instance + [1, 2, 3]] == [ + Custom, + Custom, + Custom, + int, + int, + int, + ] + + instance_2 = CustomCollection( + [ + {"x": "red"}, + {"x": "green"}, + {"x": "blue"}, + ] + ) + + assert [item.x for item in instance + instance_2] == [ + "foo", + "bar", + "baz", + "red", + "green", + "blue", + ] + + assert [item.x for item in instance_2 + instance] == [ + "red", + "green", + "blue", + "foo", + "bar", + "baz", + ] diff --git a/tests/notifications_utils/test_take.py b/tests/notifications_utils/test_take.py new file mode 100644 index 000000000..7ec5218c1 --- /dev/null +++ b/tests/notifications_utils/test_take.py @@ -0,0 +1,19 @@ +from notifications_utils.take import Take + + +def _uppercase(value): + return value.upper() + + +def _append(value, to_append): + return value + to_append + + +def _prepend_with_service_name(value, service_name=None): + return "{}: {}".format(service_name, value) + + +def test_take(): + assert "Service name: HELLO WORLD!" == Take("hello world").then(_uppercase).then( + _append, "!" + ).then(_prepend_with_service_name, service_name="Service name") diff --git a/tests/notifications_utils/test_template_change.py b/tests/notifications_utils/test_template_change.py new file mode 100644 index 000000000..4cef30f70 --- /dev/null +++ b/tests/notifications_utils/test_template_change.py @@ -0,0 +1,134 @@ +import pytest + +from notifications_utils.template_change import TemplateChange +from tests.notifications_utils.test_base_template import ConcreteTemplate + + +@pytest.mark.parametrize( + "old_template, new_template, should_differ", + [ + ( + ConcreteTemplate({"content": "((1)) ((2)) ((3))"}), + ConcreteTemplate({"content": "((1)) ((2)) ((3))"}), + False, + ), + ( + ConcreteTemplate({"content": "((1)) ((2)) ((3))"}), + ConcreteTemplate({"content": "((3)) ((2)) ((1))"}), + False, + ), + ( + ConcreteTemplate({"content": "((1)) ((2)) ((3))"}), + ConcreteTemplate({"content": "((1)) ((1)) ((2)) ((2)) ((3)) ((3))"}), + False, + ), + ( + ConcreteTemplate({"content": "((1))"}), + ConcreteTemplate({"content": "((1)) ((2))"}), + True, + ), + ( + ConcreteTemplate({"content": "((1)) ((2))"}), + ConcreteTemplate({"content": "((1))"}), + True, + ), + ( + ConcreteTemplate({"content": "((a)) ((b))"}), + ConcreteTemplate({"content": "((A)) (( B_ ))"}), + False, + ), + ], +) +def test_checking_for_difference_between_templates( + old_template, new_template, should_differ +): + assert ( + TemplateChange(old_template, new_template).has_different_placeholders + == should_differ + ) + + +@pytest.mark.parametrize( + "old_template, new_template, placeholders_added", + [ + ( + ConcreteTemplate({"content": "((1)) ((2)) ((3))"}), + ConcreteTemplate({"content": "((1)) ((2)) ((3))"}), + set(), + ), + ( + ConcreteTemplate({"content": "((1)) ((2)) ((3))"}), + ConcreteTemplate({"content": "((1)) ((1)) ((2)) ((2)) ((3)) ((3))"}), + set(), + ), + ( + ConcreteTemplate({"content": "((1)) ((2)) ((3))"}), + ConcreteTemplate({"content": "((1))"}), + set(), + ), + ( + ConcreteTemplate({"content": "((1))"}), + ConcreteTemplate({"content": "((1)) ((2)) ((3))"}), + set(["2", "3"]), + ), + ( + ConcreteTemplate({"content": "((a))"}), + ConcreteTemplate({"content": "((A)) ((B)) ((C))"}), + set(["B", "C"]), + ), + ], +) +def test_placeholders_added(old_template, new_template, placeholders_added): + assert ( + TemplateChange(old_template, new_template).placeholders_added + == placeholders_added + ) + + +@pytest.mark.parametrize( + "old_template, new_template, placeholders_removed", + [ + ( + ConcreteTemplate({"content": "((1)) ((2)) ((3))"}), + ConcreteTemplate({"content": "((1)) ((2)) ((3))"}), + set(), + ), + ( + ConcreteTemplate({"content": "((1)) ((2)) ((3))"}), + ConcreteTemplate({"content": "((1)) ((1)) ((2)) ((2)) ((3)) ((3))"}), + set(), + ), + ( + ConcreteTemplate({"content": "((1))"}), + ConcreteTemplate({"content": "((1)) ((2)) ((3))"}), + set(), + ), + ( + ConcreteTemplate({"content": "((1)) ((2)) ((3))"}), + ConcreteTemplate({"content": "((1))"}), + set(["2", "3"]), + ), + ( + ConcreteTemplate({"content": "((a)) ((b)) ((c))"}), + ConcreteTemplate({"content": "((A))"}), + set(["b", "c"]), + ), + ], +) +def test_placeholders_removed(old_template, new_template, placeholders_removed): + assert ( + TemplateChange(old_template, new_template).placeholders_removed + == placeholders_removed + ) + + +def test_ordering_of_placeholders_is_preserved(): + before = ConcreteTemplate({"content": "((dog)) ((cat)) ((rat))"}) + after = ConcreteTemplate({"content": "((platypus)) ((echidna)) ((quokka))"}) + change = TemplateChange(before, after) + assert change.placeholders_removed == ["dog", "cat", "rat"] == before.placeholders + assert ( + change.placeholders_added + == ["platypus", "echidna", "quokka"] + == after.placeholders + ) diff --git a/tests/notifications_utils/test_template_types.py b/tests/notifications_utils/test_template_types.py new file mode 100644 index 000000000..1209d4dc9 --- /dev/null +++ b/tests/notifications_utils/test_template_types.py @@ -0,0 +1,3388 @@ +import os +import sys +from functools import partial +from time import process_time +from unittest import mock + +import pytest +from bs4 import BeautifulSoup +from freezegun import freeze_time +from markupsafe import Markup +from ordered_set import OrderedSet + +from notifications_utils.formatters import unlink_govuk_escaped +from notifications_utils.template import ( + BaseBroadcastTemplate, + BaseEmailTemplate, + BaseLetterTemplate, + BroadcastMessageTemplate, + BroadcastPreviewTemplate, + EmailPreviewTemplate, + HTMLEmailTemplate, + LetterImageTemplate, + LetterPreviewTemplate, + LetterPrintTemplate, + PlainTextEmailTemplate, + SMSBodyPreviewTemplate, + SMSMessageTemplate, + SMSPreviewTemplate, + SubjectMixin, + Template, +) + + +@pytest.mark.parametrize( + "template_class, expected_error", + ( + pytest.param( + Template, + ("Can't instantiate abstract class Template with abstract method __str__"), + marks=pytest.mark.skipif( + sys.version_info >= (3, 9), reason="‘methods’ will be singular" + ), + ), + pytest.param( + Template, + ( + "Can't instantiate abstract class Template without an implementation for abstract method '__str__'" + ), + marks=pytest.mark.skipif( + sys.version_info < (3, 9), reason="‘method’ will be pluralised" + ), + ), + pytest.param( + BaseEmailTemplate, + ( + "Can't instantiate abstract class BaseEmailTemplate with abstract methods __str__" + ), + marks=pytest.mark.skipif( + sys.version_info >= (3, 9), reason="‘methods’ will be singular" + ), + ), + pytest.param( + BaseEmailTemplate, + ( + "Can't instantiate abstract class BaseEmailTemplate without an implementation for abstract method" + ), + marks=pytest.mark.skipif( + sys.version_info < (3, 9), reason="‘method’ will be pluralised" + ), + ), + pytest.param( + BaseLetterTemplate, + ( + "Can't instantiate abstract class BaseLetterTemplate with abstract methods __str__" + ), + marks=pytest.mark.skipif( + sys.version_info >= (3, 9), reason="‘methods’ will be singular" + ), + ), + pytest.param( + BaseLetterTemplate, + ( + "Can't instantiate abstract class BaseLetterTemplate without an implementation for abstract method" + ), + marks=pytest.mark.skipif( + sys.version_info < (3, 9), reason="‘method’ will be pluralised" + ), + ), + pytest.param( + BaseBroadcastTemplate, + ( + "Can't instantiate abstract class BaseBroadcastTemplate with abstract methods __str__" + ), + marks=pytest.mark.skipif( + sys.version_info >= (3, 9), reason="‘methods’ will be singular" + ), + ), + pytest.param( + BaseBroadcastTemplate, + ( + "Can't instantiate abstract class BaseBroadcastTemplate without an implementation for abstract method" + ), + marks=pytest.mark.skipif( + sys.version_info < (3, 9), reason="‘method’ will be pluralised" + ), + ), + ), +) +def test_abstract_classes_cant_be_instantiated(template_class, expected_error): + with pytest.raises(TypeError) as error: + template_class({}) + # assert str(error.value) == expected_error + assert expected_error in str(error.value) + + +@pytest.mark.parametrize( + "template_class, expected_error", + ( + ( + HTMLEmailTemplate, + ("Cannot initialise HTMLEmailTemplate with sms template_type"), + ), + ( + LetterPreviewTemplate, + ("Cannot initialise LetterPreviewTemplate with sms template_type"), + ), + ( + BroadcastPreviewTemplate, + ("Cannot initialise BroadcastPreviewTemplate with sms template_type"), + ), + ), +) +def test_errors_for_incompatible_template_type(template_class, expected_error): + with pytest.raises(TypeError) as error: + template_class({"content": "", "subject": "", "template_type": "sms"}) + assert str(error.value) == expected_error + + +def test_html_email_inserts_body(): + assert "the <em>quick</em> brown fox" in str( + HTMLEmailTemplate( + { + "content": "the quick brown fox", + "subject": "", + "template_type": "email", + } + ) + ) + + +@pytest.mark.parametrize( + "content", ("DOCTYPE", "html", "body", "beta.notify.gov", "hello world") +) +def test_default_template(content): + assert content in str( + HTMLEmailTemplate( + { + "content": "hello world", + "subject": "", + "template_type": "email", + } + ) + ) + + +@pytest.mark.parametrize("show_banner", (True, False)) +def test_govuk_banner(show_banner): + email = HTMLEmailTemplate( + { + "content": "hello world", + "subject": "", + "template_type": "email", + } + ) + email.govuk_banner = show_banner + if show_banner: + assert "beta.notify.gov" in str(email) + else: + assert "beta.notify.gov" not in str(email) + + +def test_brand_banner_shows(): + email = str( + HTMLEmailTemplate( + {"content": "hello world", "subject": "", "template_type": "email"}, + brand_banner=True, + govuk_banner=False, + ) + ) + assert ('') not in email + assert ( + 'role="presentation" width="100%" style="border-collapse: collapse;min-width: 100%;width: 100% !important;"' + ) in email + + +@pytest.mark.parametrize( + "brand_logo, brand_text, brand_colour", + [ + ("http://example.com/image.png", "Example", "red"), + ("http://example.com/image.png", "Example", "#f00"), + ("http://example.com/image.png", "Example", None), + ("http://example.com/image.png", "", "#f00"), + (None, "Example", "#f00"), + ], +) +def test_brand_data_shows(brand_logo, brand_text, brand_colour): + email = str( + HTMLEmailTemplate( + {"content": "hello world", "subject": "", "template_type": "email"}, + brand_banner=True, + govuk_banner=False, + brand_logo=brand_logo, + brand_text=brand_text, + brand_colour=brand_colour, + ) + ) + + assert "GOV.UK" not in email + if brand_logo: + assert brand_logo in email + if brand_text: + assert brand_text in email + if brand_colour: + assert 'bgcolor="{}"'.format(brand_colour) in email + + +def test_alt_text_with_brand_text_and_govuk_banner_shown(): + email = str( + HTMLEmailTemplate( + {"content": "hello world", "subject": "", "template_type": "email"}, + govuk_banner=True, + brand_logo="http://example.com/image.png", + brand_text="Example", + brand_banner=True, + brand_name="Notify Logo", + ) + ) + assert 'alt=""' in email + assert 'alt="Notify Logo"' not in email + + +def test_alt_text_with_no_brand_text_and_govuk_banner_shown(): + email = str( + HTMLEmailTemplate( + {"content": "hello world", "subject": "", "template_type": "email"}, + govuk_banner=True, + brand_logo="http://example.com/image.png", + brand_text=None, + brand_banner=True, + brand_name="Notify Logo", + ) + ) + assert 'alt=""' not in email + assert 'alt="Notify Logo"' in email + + +@pytest.mark.parametrize( + "brand_banner, brand_text, expected_alt_text", + [ + (True, None, 'alt="Notify Logo"'), + (True, "Example", 'alt=""'), + (False, "Example", 'alt=""'), + (False, None, 'alt="Notify Logo"'), + ], +) +def test_alt_text_with_no_govuk_banner(brand_banner, brand_text, expected_alt_text): + email = str( + HTMLEmailTemplate( + {"content": "hello world", "subject": "", "template_type": "email"}, + govuk_banner=False, + brand_logo="http://example.com/image.png", + brand_text=brand_text, + brand_banner=brand_banner, + brand_name="Notify Logo", + ) + ) + + assert expected_alt_text in email + + +@pytest.mark.parametrize("complete_html", (True, False)) +@pytest.mark.parametrize( + "branding_should_be_present, brand_logo, brand_text, brand_colour", + [ + (True, "http://example.com/image.png", "Example", "#f00"), + (True, "http://example.com/image.png", "Example", None), + (True, "http://example.com/image.png", "", None), + (False, None, "Example", "#f00"), + (False, "http://example.com/image.png", None, "#f00"), + ], +) +@pytest.mark.parametrize("content", ("DOCTYPE", "html", "body")) +def test_complete_html( + complete_html, + branding_should_be_present, + brand_logo, + brand_text, + brand_colour, + content, +): + email = str( + HTMLEmailTemplate( + {"content": "hello world", "subject": "", "template_type": "email"}, + complete_html=complete_html, + brand_logo=brand_logo, + brand_text=brand_text, + brand_colour=brand_colour, + ) + ) + + if complete_html: + assert content in email + else: + assert content not in email + + if branding_should_be_present: + assert brand_logo in email + assert brand_text in email + + if brand_colour: + assert brand_colour in email + assert "##" not in email + + +def test_subject_is_page_title(): + email = BeautifulSoup( + str( + HTMLEmailTemplate( + { + "content": "", + "subject": "this is the subject", + "template_type": "email", + }, + ) + ), + features="html.parser", + ) + assert email.select_one("title").text == "this is the subject" + + +def test_preheader_is_at_start_of_html_emails(): + assert ( + '\n' + "\n" + 'content…' + ) in str( + HTMLEmailTemplate( + {"content": "content", "subject": "subject", "template_type": "email"} + ) + ) + + +@pytest.mark.parametrize( + "content, values, expected_preheader", + [ + ( + ( + "Hello (( name ))\n" + "\n" + '# This - is a "heading"\n' + "\n" + "My favourite websites' URLs are:\n" + "- GOV.UK\n" + "- https://www.example.com\n" + ), + {"name": "Jo"}, + "Hello Jo This – is a “heading” My favourite websites’ URLs are: • GOV.​UK • https://www.example.com", + ), + ( + ("[Markdown link](https://www.example.com)\n"), + {}, + "Markdown link", + ), + ( + """ + Lorem Ipsum is simply dummy text of the printing and + typesetting industry. + + Lorem Ipsum has been the industry’s standard dummy text + ever since the 1500s, when an unknown printer took a galley + of type and scrambled it to make a type specimen book. + + Lorem Ipsum is simply dummy text of the printing and + typesetting industry. + + Lorem Ipsum has been the industry’s standard dummy text + ever since the 1500s, when an unknown printer took a galley + of type and scrambled it to make a type specimen book. + """, + {}, + ( + "Lorem Ipsum is simply dummy text of the printing and " + "typesetting industry. Lorem Ipsum has been the industry’s " + "standard dummy text ever since the 1500s, when an unknown " + "printer took a galley of type and scrambled it to make a " + "type specimen book. Lorem Ipsu" + ), + ), + ( + "short email", + {}, + "short email", + ), + ], +) +@mock.patch( + "notifications_utils.template.HTMLEmailTemplate.jinja_template.render", + return_value="mocked", +) +def test_content_of_preheader_in_html_emails( + mock_jinja_template, + content, + values, + expected_preheader, +): + assert ( + str( + HTMLEmailTemplate( + {"content": content, "subject": "subject", "template_type": "email"}, + values, + ) + ) + == "mocked" + ) + assert mock_jinja_template.call_args[0][0]["preheader"] == expected_preheader + + +@pytest.mark.parametrize( + "template_class, template_type, extra_args, result, markdown_renderer", + [ + [ + HTMLEmailTemplate, + "email", + {}, + ("the quick brown fox\n" "\n" "jumped over the lazy dog\n"), + "notifications_utils.template.notify_email_markdown", + ], + [ + LetterPreviewTemplate, + "letter", + {}, + ("the quick brown fox\n" "\n" "jumped over the lazy dog\n"), + "notifications_utils.template.notify_letter_preview_markdown", + ], + ], +) +def test_markdown_in_templates( + template_class, + template_type, + extra_args, + result, + markdown_renderer, +): + with mock.patch(markdown_renderer, return_value="") as mock_markdown_renderer: + str( + template_class( + { + "content": ( + "the quick ((colour)) ((animal))\n" + "\n" + "jumped over the lazy dog" + ), + "subject": "animal story", + "template_type": template_type, + }, + {"animal": "fox", "colour": "brown"}, + **extra_args, + ) + ) + mock_markdown_renderer.assert_called_once_with(result) + + +@pytest.mark.parametrize( + "template_class, template_type, extra_attributes", + [ + (HTMLEmailTemplate, "email", 'style="word-wrap: break-word; color: #1D70B8;"'), + ( + EmailPreviewTemplate, + "email", + 'style="word-wrap: break-word; color: #1D70B8;"', + ), + (SMSPreviewTemplate, "sms", 'class="govuk-link govuk-link--no-visited-state"'), + ( + BroadcastPreviewTemplate, + "broadcast", + 'class="govuk-link govuk-link--no-visited-state"', + ), + pytest.param( + SMSBodyPreviewTemplate, + "sms", + 'style="word-wrap: break-word;', + marks=pytest.mark.xfail, + ), + ], +) +@pytest.mark.parametrize( + "url, url_with_entities_replaced", + [ + ("http://example.com", "http://example.com"), + ("http://www.gov.uk/", "http://www.gov.uk/"), + ("https://www.gov.uk/", "https://www.gov.uk/"), + ("http://service.gov.uk", "http://service.gov.uk"), + ( + "http://service.gov.uk/blah.ext?q=a%20b%20c&order=desc#fragment", + "http://service.gov.uk/blah.ext?q=a%20b%20c&order=desc#fragment", + ), + pytest.param("example.com", "example.com", marks=pytest.mark.xfail), + pytest.param("www.example.com", "www.example.com", marks=pytest.mark.xfail), + pytest.param( + "http://service.gov.uk/blah.ext?q=one two three", + "http://service.gov.uk/blah.ext?q=one two three", + marks=pytest.mark.xfail, + ), + pytest.param("ftp://example.com", "ftp://example.com", marks=pytest.mark.xfail), + pytest.param( + "mailto:test@example.com", + "mailto:test@example.com", + marks=pytest.mark.xfail, + ), + ], +) +def test_makes_links_out_of_URLs( + extra_attributes, template_class, template_type, url, url_with_entities_replaced +): + assert '{}'.format( + extra_attributes, url_with_entities_replaced, url_with_entities_replaced + ) in str( + template_class({"content": url, "subject": "", "template_type": template_type}) + ) + + +@pytest.mark.parametrize( + "template_class, template_type", + ( + (SMSPreviewTemplate, "sms"), + (BroadcastPreviewTemplate, "broadcast"), + ), +) +@pytest.mark.parametrize( + "url, url_with_entities_replaced", + ( + ("example.com", "example.com"), + ("www.gov.uk/", "www.gov.uk/"), + ("service.gov.uk", "service.gov.uk"), + ("gov.uk/coronavirus", "gov.uk/coronavirus"), + ( + "service.gov.uk/blah.ext?q=a%20b%20c&order=desc#fragment", + "service.gov.uk/blah.ext?q=a%20b%20c&order=desc#fragment", + ), + ), +) +def test_makes_links_out_of_URLs_without_protocol_in_sms_and_broadcast( + template_class, + template_type, + url, + url_with_entities_replaced, +): + assert ( + f"' + f"{url_with_entities_replaced}" + f"" + ) in str( + template_class({"content": url, "subject": "", "template_type": template_type}) + ) + + +@pytest.mark.parametrize( + "content, html_snippet", + ( + ( + ( + "You've been invited to a service. Click this link:\n" + "https://service.example.com/accept_invite/a1b2c3d4\n" + "\n" + "Thanks\n" + ), + ( + '' + "https://service.example.com/accept_invite/a1b2c3d4" + "" + ), + ), + ( + ("https://service.example.com/accept_invite/?a=b&c=d&"), + ( + '' + "https://service.example.com/accept_invite/?a=b&c=d&" + "" + ), + ), + ), +) +def test_HTML_template_has_URLs_replaced_with_links(content, html_snippet): + assert html_snippet in str( + HTMLEmailTemplate({"content": content, "subject": "", "template_type": "email"}) + ) + + +@pytest.mark.parametrize( + "template_content,expected", + [ + ("gov.uk", "gov.\u200Buk"), + ("GOV.UK", "GOV.\u200BUK"), + ("Gov.uk", "Gov.\u200Buk"), + ("https://gov.uk", "https://gov.uk"), + ("https://www.gov.uk", "https://www.gov.uk"), + ("www.gov.uk", "www.gov.uk"), + ("gov.uk/register-to-vote", "gov.uk/register-to-vote"), + ("gov.uk?q=", "gov.uk?q="), + ], +) +def test_escaping_govuk_in_email_templates(template_content, expected): + assert unlink_govuk_escaped(template_content) == expected + assert expected in str( + PlainTextEmailTemplate( + { + "content": template_content, + "subject": "", + "template_type": "email", + } + ) + ) + assert expected in str( + HTMLEmailTemplate( + { + "content": template_content, + "subject": "", + "template_type": "email", + } + ) + ) + + +def test_stripping_of_unsupported_characters_in_email_templates(): + template_content = "line one\u2028line two" + expected = "line oneline two" + assert expected in str( + PlainTextEmailTemplate( + { + "content": template_content, + "subject": "", + "template_type": "email", + } + ) + ) + assert expected in str( + HTMLEmailTemplate( + { + "content": template_content, + "subject": "", + "template_type": "email", + } + ) + ) + + +@mock.patch("notifications_utils.template.add_prefix", return_value="") +@pytest.mark.parametrize( + "template_class, prefix, body, expected_call", + [ + (SMSMessageTemplate, "a", "b", (Markup("b"), "a")), + (SMSPreviewTemplate, "a", "b", (Markup("b"), "a")), + (BroadcastPreviewTemplate, "a", "b", (Markup("b"), "a")), + (SMSMessageTemplate, None, "b", (Markup("b"), None)), + (SMSPreviewTemplate, None, "b", (Markup("b"), None)), + (BroadcastPreviewTemplate, None, "b", (Markup("b"), None)), + (SMSMessageTemplate, "ht&ml", "b", (Markup("b"), "ht&ml")), + ( + SMSPreviewTemplate, + "ht&ml", + "b", + (Markup("b"), "<em>ht&ml</em>"), + ), + ( + BroadcastPreviewTemplate, + "ht&ml", + "b", + (Markup("b"), "<em>ht&ml</em>"), + ), + ], +) +def test_sms_message_adds_prefix( + add_prefix, template_class, prefix, body, expected_call +): + template = template_class( + {"content": body, "template_type": template_class.template_type} + ) + template.prefix = prefix + template.sender = None + str(template) + add_prefix.assert_called_once_with(*expected_call) + + +@mock.patch("notifications_utils.template.add_prefix", return_value="") +@pytest.mark.parametrize( + "template_class", + [ + SMSMessageTemplate, + SMSPreviewTemplate, + BroadcastPreviewTemplate, + ], +) +@pytest.mark.parametrize( + "show_prefix, prefix, body, sender, expected_call", + [ + (False, "a", "b", "c", (Markup("b"), None)), + (True, "a", "b", None, (Markup("b"), "a")), + (True, "a", "b", False, (Markup("b"), "a")), + ], +) +def test_sms_message_adds_prefix_only_if_asked_to( + add_prefix, + show_prefix, + prefix, + body, + sender, + expected_call, + template_class, +): + template = template_class( + {"content": body, "template_type": template_class.template_type}, + prefix=prefix, + show_prefix=show_prefix, + sender=sender, + ) + str(template) + add_prefix.assert_called_once_with(*expected_call) + + +@pytest.mark.parametrize("content_to_look_for", ["GOVUK", "sms-message-sender"]) +@pytest.mark.parametrize( + "show_sender", + [ + True, + pytest.param(False, marks=pytest.mark.xfail), + ], +) +def test_sms_message_preview_shows_sender( + show_sender, + content_to_look_for, +): + assert content_to_look_for in str( + SMSPreviewTemplate( + {"content": "foo", "template_type": "sms"}, + sender="GOVUK", + show_sender=show_sender, + ) + ) + + +def test_sms_message_preview_hides_sender_by_default(): + assert ( + SMSPreviewTemplate({"content": "foo", "template_type": "sms"}).show_sender + is False + ) + + +@mock.patch("notifications_utils.template.sms_encode", return_value="downgraded") +@pytest.mark.parametrize( + "template_class, extra_args, expected_call", + ( + (SMSMessageTemplate, {"prefix": "Service name"}, "Service name: Message"), + (SMSPreviewTemplate, {"prefix": "Service name"}, "Service name: Message"), + (BroadcastMessageTemplate, {}, "Message"), + (BroadcastPreviewTemplate, {"prefix": "Service name"}, "Service name: Message"), + (SMSBodyPreviewTemplate, {}, "Message"), + ), +) +def test_sms_messages_downgrade_non_sms( + mock_sms_encode, + template_class, + extra_args, + expected_call, +): + template = str( + template_class( + {"content": "Message", "template_type": template_class.template_type}, + **extra_args, + ) + ) + assert "downgraded" in str(template) + mock_sms_encode.assert_called_once_with(expected_call) + + +@pytest.mark.parametrize( + "template_class", + ( + SMSPreviewTemplate, + BroadcastPreviewTemplate, + ), +) +@mock.patch("notifications_utils.template.sms_encode", return_value="downgraded") +def test_sms_messages_dont_downgrade_non_sms_if_setting_is_false( + mock_sms_encode, template_class +): + template = str( + template_class( + {"content": "😎", "template_type": template_class.template_type}, + prefix="👉", + downgrade_non_sms_characters=False, + ) + ) + assert "👉: 😎" in str(template) + assert mock_sms_encode.called is False + + +@pytest.mark.parametrize( + "template_class", + ( + SMSPreviewTemplate, + BroadcastPreviewTemplate, + ), +) +@mock.patch("notifications_utils.template.nl2br") +def test_sms_preview_adds_newlines(nl2br, template_class): + content = "the\nquick\n\nbrown fox" + str( + template_class( + {"content": content, "template_type": template_class.template_type} + ) + ) + nl2br.assert_called_once_with(content) + + +@pytest.mark.parametrize( + "content", + [ + ("one newline\n" "two newlines\n" "\n" "end"), # Unix-style + ("one newline\r\n" "two newlines\r\n" "\r\n" "end"), # Windows-style + ("one newline\r" "two newlines\r" "\r" "end"), # Mac Classic style + ( # A mess + "\t\t\n\r one newline\n" "two newlines\r" "\r\n" "end\n\n \r \n \t " + ), + ], +) +def test_sms_message_normalises_newlines(content): + assert repr( + str(SMSMessageTemplate({"content": content, "template_type": "sms"})) + ) == repr("one newline\n" "two newlines\n" "\n" "end") + + +@pytest.mark.parametrize( + "content", + [ + ("one newline\n" "two newlines\n" "\n" "end"), # Unix-style + ("one newline\r\n" "two newlines\r\n" "\r\n" "end"), # Windows-style + ("one newline\r" "two newlines\r" "\r" "end"), # Mac Classic style + ( # A mess + "\t\t\n\r one newline\xa0\n" "two newlines\r" "\r\n" "end\n\n \r \n \t " + ), + ], +) +def test_broadcast_message_normalises_newlines(content): + assert str( + BroadcastMessageTemplate({"content": content, "template_type": "broadcast"}) + ) == ("one newline\n" "two newlines\n" "\n" "end") + + +@pytest.mark.parametrize( + "template_class", + ( + SMSMessageTemplate, + SMSBodyPreviewTemplate, + BroadcastMessageTemplate, + # Note: SMSPreviewTemplate and BroadcastPreviewTemplate not tested here + # as both will render full HTML template, not just the body + ), +) +def test_phone_templates_normalise_whitespace(template_class): + content = " Hi\u00A0there\u00A0 what's\u200D up\t" + assert ( + str( + template_class( + {"content": content, "template_type": template_class.template_type} + ) + ) + == "Hi there what's up" + ) + + +@freeze_time("2012-12-12 12:12:12") +@mock.patch("notifications_utils.template.LetterPreviewTemplate.jinja_template.render") +@mock.patch("notifications_utils.template.unlink_govuk_escaped") +@mock.patch( + "notifications_utils.template.notify_letter_preview_markdown", return_value="Bar" +) +@pytest.mark.parametrize( + "values, expected_address", + [ + ( + {}, + [ + "address line 1", + "address line 2", + "address line 3", + "address line 4", + "address line 5", + "address line 6", + "address line 7", + ], + ), + ( + { + "address line 1": "123 Fake Street", + "address line 6": "United Kingdom", + }, + [ + "123 Fake Street", + "address line 2", + "address line 3", + "address line 4", + "address line 5", + "United Kingdom", + "address line 7", + ], + ), + ( + { + "address line 1": "123 Fake Street", + "address line 2": "City of Town", + "postcode": "SW1A 1AA", + }, + [ + "123 Fake Street", + "City of Town", + "SW1A 1AA", + ], + ), + ], +) +@pytest.mark.parametrize( + "contact_block, expected_rendered_contact_block", + [ + (None, ""), + ("", ""), + ( + """ + The Pension Service + Mail Handling Site A + Wolverhampton WV9 1LU + + Telephone: 0845 300 0168 + Email: fpc.customercare@dwp.gsi.gov.uk + Monday - Friday 8am - 6pm + www.gov.uk + """, + ( + "The Pension Service
    " + "Mail Handling Site A
    " + "Wolverhampton WV9 1LU
    " + "
    " + "Telephone: 0845 300 0168
    " + "Email: fpc.customercare@dwp.gsi.gov.uk
    " + "Monday - Friday 8am - 6pm
    " + "www.gov.uk" + ), + ), + ], +) +@pytest.mark.parametrize( + "extra_args, expected_logo_file_name, expected_logo_class", + [ + ({}, None, None), + ({"logo_file_name": "example.foo"}, "example.foo", "foo"), + ], +) +@pytest.mark.parametrize( + "additional_extra_args, expected_date", + [ + ({}, "12 December 2012"), + ({"date": None}, "12 December 2012"), + # ({'date': datetime.date.fromtimestamp(0)}, '1 January 1970'), + ], +) +def test_letter_preview_renderer( + letter_markdown, + unlink_govuk, + jinja_template, + values, + expected_address, + contact_block, + expected_rendered_contact_block, + extra_args, + expected_logo_file_name, + expected_logo_class, + additional_extra_args, + expected_date, +): + extra_args.update(additional_extra_args) + str( + LetterPreviewTemplate( + {"content": "Foo", "subject": "Subject", "template_type": "letter"}, + values, + contact_block=contact_block, + **extra_args, + ) + ) + jinja_template.assert_called_once_with( + { + "address": expected_address, + "subject": "Subject", + "message": "Bar", + "date": expected_date, + "contact_block": expected_rendered_contact_block, + "admin_base_url": "http://localhost:6012", + "logo_file_name": expected_logo_file_name, + "logo_class": expected_logo_class, + } + ) + letter_markdown.assert_called_once_with(Markup("Foo\n")) + unlink_govuk.assert_not_called() + + +@freeze_time("2001-01-01 12:00:00.000000") +@mock.patch("notifications_utils.template.LetterPreviewTemplate.jinja_template.render") +def test_letter_preview_renderer_without_mocks(jinja_template): + str( + LetterPreviewTemplate( + {"content": "Foo", "subject": "Subject", "template_type": "letter"}, + {"addressline1": "name", "addressline2": "street", "postcode": "SW1 1AA"}, + contact_block="", + ) + ) + + jinja_template_locals = jinja_template.call_args_list[0][0][0] + + assert jinja_template_locals["address"] == [ + "name", + "street", + "SW1 1AA", + ] + assert jinja_template_locals["subject"] == "Subject" + assert jinja_template_locals["message"] == "

    Foo

    " + assert jinja_template_locals["date"] == "1 January 2001" + assert jinja_template_locals["contact_block"] == "" + assert jinja_template_locals["admin_base_url"] == "http://localhost:6012" + assert jinja_template_locals["logo_file_name"] is None + + +@freeze_time("2012-12-12 12:12:12") +@mock.patch("notifications_utils.template.LetterImageTemplate.jinja_template.render") +@pytest.mark.parametrize( + "page_count, expected_oversized, expected_page_numbers", + [ + ( + 1, + False, + [1], + ), + ( + 5, + False, + [1, 2, 3, 4, 5], + ), + ( + 10, + False, + [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], + ), + ( + 11, + True, + [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], + ), + ( + 99, + True, + [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], + ), + ], +) +@pytest.mark.parametrize( + "postage_args, expected_show_postage, expected_postage_class_value, expected_postage_description", + ( + pytest.param({}, False, None, None), + pytest.param({"postage": None}, False, None, None), + pytest.param({"postage": "first"}, True, "letter-postage-first", "first class"), + pytest.param( + {"postage": "second"}, True, "letter-postage-second", "second class" + ), + pytest.param( + {"postage": "europe"}, True, "letter-postage-international", "international" + ), + pytest.param( + {"postage": "rest-of-world"}, + True, + "letter-postage-international", + "international", + ), + pytest.param( + {"postage": "third"}, + True, + "letter-postage-third", + "third class", + marks=pytest.mark.xfail(raises=TypeError), + ), + ), +) +def test_letter_image_renderer( + jinja_template, + page_count, + expected_page_numbers, + expected_oversized, + postage_args, + expected_show_postage, + expected_postage_class_value, + expected_postage_description, +): + str( + LetterImageTemplate( + {"content": "Content", "subject": "Subject", "template_type": "letter"}, + image_url="http://example.com/endpoint.png", + page_count=page_count, + contact_block="10 Downing Street", + **postage_args, + ) + ) + jinja_template.assert_called_once_with( + { + "image_url": "http://example.com/endpoint.png", + "page_numbers": expected_page_numbers, + "address": [ + "address line 1", + "address line 2", + "address line 3", + "address line 4", + "address line 5", + "address line 6", + "address line 7", + ], + "contact_block": "10 Downing Street", + "date": "12 December 2012", + "subject": "Subject", + "message": "

    Content

    ", + "show_postage": expected_show_postage, + "postage_class_value": expected_postage_class_value, + "postage_description": expected_postage_description, + } + ) + + +@freeze_time("2012-12-12 12:12:12") +@mock.patch("notifications_utils.template.LetterImageTemplate.jinja_template.render") +@pytest.mark.parametrize( + "postage_argument", + ( + None, + "first", + "second", + "europe", + "rest-of-world", + ), +) +def test_letter_image_renderer_shows_international_post( + jinja_template, + postage_argument, +): + str( + LetterImageTemplate( + {"content": "Content", "subject": "Subject", "template_type": "letter"}, + { + "address line 1": "123 Example Street", + "address line 2": "Lima", + "address line 3": "Peru", + }, + image_url="http://example.com/endpoint.png", + page_count=1, + postage=postage_argument, + ) + ) + assert jinja_template.call_args_list[0][0][0]["postage_description"] == ( + "international" + ) + + +def test_letter_image_template_renders_visually_hidden_address(): + template = BeautifulSoup( + str( + LetterImageTemplate( + {"content": "", "subject": "", "template_type": "letter"}, + { + "address_line_1": "line 1", + "address_line_2": "line 2", + "postcode": "postcode", + }, + image_url="http://example.com/endpoint.png", + page_count=1, + ) + ), + features="html.parser", + ) + assert str(template.select_one(".govuk-visually-hidden ul")) == ( + "
      " "
    • line 1
    • " "
    • line 2
    • " "
    • postcode
    • " "
    " + ) + + +@pytest.mark.parametrize( + "page_image_url", + [ + pytest.param("http://example.com/endpoint.png?page=0", marks=pytest.mark.xfail), + "http://example.com/endpoint.png?page=1", + "http://example.com/endpoint.png?page=2", + "http://example.com/endpoint.png?page=3", + pytest.param("http://example.com/endpoint.png?page=4", marks=pytest.mark.xfail), + ], +) +def test_letter_image_renderer_pagination(page_image_url): + assert page_image_url in str( + LetterImageTemplate( + {"content": "", "subject": "", "template_type": "letter"}, + image_url="http://example.com/endpoint.png", + page_count=3, + ) + ) + + +@pytest.mark.parametrize( + "partial_call, expected_exception, expected_message", + [ + ( + partial(LetterImageTemplate), + TypeError, + "image_url is required", + ), + ( + partial(LetterImageTemplate, page_count=1), + TypeError, + "image_url is required", + ), + ( + partial(LetterImageTemplate, image_url="foo"), + TypeError, + "page_count is required", + ), + ( + partial(LetterImageTemplate, image_url="foo", page_count="foo"), + ValueError, + "invalid literal for int() with base 10: 'foo'", + ), + ( + partial( + LetterImageTemplate, image_url="foo", page_count=1, postage="third" + ), + TypeError, + "postage must be None, 'first', 'second', 'europe' or 'rest-of-world'", + ), + ], +) +def test_letter_image_renderer_requires_arguments( + partial_call, + expected_exception, + expected_message, +): + with pytest.raises(expected_exception) as exception: + partial_call({"content": "", "subject": "", "template_type": "letter"}) + assert str(exception.value) == expected_message + + +@pytest.mark.parametrize( + "postage, expected_attribute_value, expected_postage_text", + ( + (None, None, None), + ( + "first", + ["letter-postage", "letter-postage-first"], + "Postage: first class", + ), + ( + "second", + ["letter-postage", "letter-postage-second"], + "Postage: second class", + ), + ( + "europe", + ["letter-postage", "letter-postage-international"], + "Postage: international", + ), + ( + "rest-of-world", + ["letter-postage", "letter-postage-international"], + "Postage: international", + ), + ), +) +def test_letter_image_renderer_passes_postage_to_html_attribute( + postage, + expected_attribute_value, + expected_postage_text, +): + template = BeautifulSoup( + str( + LetterImageTemplate( + {"content": "", "subject": "", "template_type": "letter"}, + image_url="foo", + page_count=1, + postage=postage, + ) + ), + features="html.parser", + ) + if expected_attribute_value: + assert ( + template.select_one(".letter-postage")["class"] == expected_attribute_value + ) + assert ( + template.select_one(".letter-postage").text.strip() == expected_postage_text + ) + else: + assert not template.select(".letter-postage") + + +@pytest.mark.parametrize( + "template_class", + ( + SMSBodyPreviewTemplate, + SMSMessageTemplate, + SMSPreviewTemplate, + BroadcastMessageTemplate, + BroadcastPreviewTemplate, + ), +) +@pytest.mark.parametrize( + "template_json", + ( + {"content": ""}, + {"content": "", "subject": "subject"}, + ), +) +def test_sms_templates_have_no_subject(template_class, template_json): + template_json.update(template_type=template_class.template_type) + assert not hasattr( + template_class(template_json), + "subject", + ) + + +def test_subject_line_gets_applied_to_correct_template_types(): + for cls in [ + EmailPreviewTemplate, + HTMLEmailTemplate, + PlainTextEmailTemplate, + LetterPreviewTemplate, + LetterImageTemplate, + ]: + assert issubclass(cls, SubjectMixin) + for cls in [ + SMSBodyPreviewTemplate, + SMSMessageTemplate, + SMSPreviewTemplate, + BroadcastMessageTemplate, + BroadcastPreviewTemplate, + ]: + assert not issubclass(cls, SubjectMixin) + + +@pytest.mark.parametrize( + "template_class, template_type, extra_args", + ( + (EmailPreviewTemplate, "email", {}), + (HTMLEmailTemplate, "email", {}), + (PlainTextEmailTemplate, "email", {}), + (LetterPreviewTemplate, "letter", {}), + (LetterPrintTemplate, "letter", {}), + ( + LetterImageTemplate, + "letter", + { + "image_url": "http://example.com", + "page_count": 1, + }, + ), + ), +) +def test_subject_line_gets_replaced(template_class, template_type, extra_args): + template = template_class( + {"content": "", "template_type": template_type, "subject": "((name))"}, + **extra_args, + ) + assert template.subject == Markup("((name))") + template.values = {"name": "Jo"} + assert template.subject == "Jo" + + +@pytest.mark.parametrize( + "template_class, template_type, extra_args", + ( + (EmailPreviewTemplate, "email", {}), + (HTMLEmailTemplate, "email", {}), + (PlainTextEmailTemplate, "email", {}), + (LetterPreviewTemplate, "letter", {}), + (LetterPrintTemplate, "letter", {}), + ( + LetterImageTemplate, + "letter", + { + "image_url": "http://example.com", + "page_count": 1, + }, + ), + ), +) +@pytest.mark.parametrize( + "content, values, expected_count", + [ + ("Content with ((placeholder))", {"placeholder": "something extra"}, 28), + ("Content with ((placeholder))", {"placeholder": ""}, 12), + ("Just content", {}, 12), + ("((placeholder)) ", {"placeholder": " "}, 0), + (" ", {}, 0), + ], +) +def test_character_count_for_non_sms_templates( + template_class, + template_type, + extra_args, + content, + values, + expected_count, +): + template = template_class( + { + "content": content, + "subject": "Hi", + "template_type": template_type, + }, + **extra_args, + ) + template.values = values + assert template.content_count == expected_count + + +@pytest.mark.parametrize( + "template_class", + [ + SMSMessageTemplate, + SMSPreviewTemplate, + ], +) +@pytest.mark.parametrize( + "content, values, prefix, expected_count_in_template, expected_count_in_notification", + [ + # is an unsupported unicode character so should be replaced with a ? + ("深", {}, None, 1, 1), + # is a supported unicode character so should be kept as is + ("Ŵ", {}, None, 1, 1), + ("'First line.\n", {}, None, 12, 12), + ("\t\n\r", {}, None, 0, 0), + ( + "Content with ((placeholder))", + {"placeholder": "something extra here"}, + None, + 13, + 33, + ), + ("Content with ((placeholder))", {"placeholder": ""}, None, 13, 12), + ("Just content", {}, None, 12, 12), + ("((placeholder)) ", {"placeholder": " "}, None, 0, 0), + (" ", {}, None, 0, 0), + ( + "Content with ((placeholder))", + {"placeholder": "something extra here"}, + "GDS", + 18, + 38, + ), + ("Just content", {}, "GDS", 17, 17), + ("((placeholder)) ", {"placeholder": " "}, "GDS", 5, 4), + (" ", {}, "GDS", 4, 4), # Becomes `GDS:` + (" G D S ", {}, None, 5, 5), # Becomes `G D S` + ("P1 \n\n\n\n\n\n P2", {}, None, 6, 6), # Becomes `P1\n\nP2` + ( + "a ((placeholder)) b", + {"placeholder": ""}, + None, + 4, + 3, + ), # Counted as `a b` then `a b` + ], +) +def test_character_count_for_sms_templates( + content, + values, + prefix, + expected_count_in_template, + expected_count_in_notification, + template_class, +): + template = template_class( + {"content": content, "template_type": "sms"}, + prefix=prefix, + ) + template.sender = None + assert template.content_count == expected_count_in_template + template.values = values + assert template.content_count == expected_count_in_notification + + +@pytest.mark.parametrize( + "template_class", + [ + BroadcastMessageTemplate, + BroadcastPreviewTemplate, + ], +) +@pytest.mark.parametrize( + "content, values, expected_count_in_template, expected_count_in_notification", + [ + # is an unsupported unicode character so should be replaced with a ? + ("深", {}, 1, 1), + # is a supported unicode character so should be kept as is + ("Ŵ", {}, 1, 1), + ("'First line.\n", {}, 12, 12), + ("\t\n\r", {}, 0, 0), + ( + "Content with ((placeholder))", + {"placeholder": "something extra here"}, + 13, + 33, + ), + ("Content with ((placeholder))", {"placeholder": ""}, 13, 12), + ("Just content", {}, 12, 12), + ("((placeholder)) ", {"placeholder": " "}, 0, 0), + (" ", {}, 0, 0), + (" G D S ", {}, 5, 5), # Becomes `G D S` + ("P1 \n\n\n\n\n\n P2", {}, 6, 6), # Becomes `P1\n\nP2` + ], +) +def test_character_count_for_broadcast_templates( + content, + values, + expected_count_in_template, + expected_count_in_notification, + template_class, +): + template = template_class( + {"content": content, "template_type": "broadcast"}, + ) + assert template.content_count == expected_count_in_template + template.values = values + assert template.content_count == expected_count_in_notification + + +@pytest.mark.parametrize( + "template_class", + ( + SMSMessageTemplate, + BroadcastMessageTemplate, + ), +) +@pytest.mark.parametrize( + "msg, expected_sms_fragment_count", + [ + ( + """This is a very long long long long long long long long long long + long long long long long long long long long long long long long long text message.""", + 1, + ), + ("This is a short message.", 1), + ], +) +def test_sms_fragment_count_accounts_for_unicode_and_welsh_characters( + template_class, + msg, + expected_sms_fragment_count, +): + template = template_class( + {"content": msg, "template_type": template_class.template_type} + ) + assert template.fragment_count == expected_sms_fragment_count + + +@pytest.mark.parametrize( + "template_class", + ( + SMSMessageTemplate, + BroadcastMessageTemplate, + ), +) +@pytest.mark.parametrize( + "msg, expected_sms_fragment_count", + [ + # all extended GSM characters + ( + "Это длинное сообщение на русском языке, чтобы проверить, как система рассчитывает его стоимость.", + 2, + ), + ( + "이것은 매우 길고 오래 오래 오래 오래 오래 오래 오래 오래 오래 오래 오래 오래 오래 오래 오래 오래 오래 오래 오래 오래 긴 문자 메시지입니다.", + 2, + ), + ("Αυτό είναι ένα μεγάλο μήνυμα στα ρωσικά για να ελέγξετε πώς το για αυτό", 2), + ( + "これは、システムがコストをどのように計算するかをテストするためのロシア語の長いメッセージです", + 1, + ), + ("这是一条很长的俄语消息,用于测试系统如何计算其成本", 1), + ( + "这是一个非常长的长长长长的长长长长的长长长长的长长长长的长长长长长长长长长长长长的长长长长的长篇短信", + 1, + ), + ( + "これは、システムがコストをどのように計算するかをテストするためのロシア語の長いメッセージです foo foofoofoofoofoofoofoofoo", + 2, + ), + ( + "Это длинное сообщение на русском языке, чтобы проверить, как система рассчитывает его стоимость.\ + foo foo foo foo foo foo foo foo foo foo", + 3, + ), + ( + "Hello Carlos. Your Example Corp. bill of $100 is now available. Autopay is scheduled for next Thursday,\ + April 9. To view the details of your bill, go to https://example.com/bill1.", + 2, + ), + ( + "亚马逊公司是一家总部位于美国西雅图的跨国电子商务企业,业务起始于线上书店,不久之后商品走向多元化。杰夫·贝佐斯于1994年7月创建了这家公司。", + 2, + ), + # This test should break into two messages, but \u2019 gets converted to (') + ( + "John: Your appointment with Dr. Salazar’s office is scheduled for next Thursday at 4:30pm.\ + Reply YES to confirm, NO to reschedule.", + 1, + ), + ], +) +def test_sms_fragment_count_accounts_for_non_latin_characters( + template_class, + msg, + expected_sms_fragment_count, +): + template = template_class( + {"content": msg, "template_type": template_class.template_type} + ) + assert template.fragment_count == expected_sms_fragment_count + + +@pytest.mark.parametrize( + "template_class", + [ + SMSMessageTemplate, + SMSPreviewTemplate, + ], +) +@pytest.mark.parametrize( + "content, values, prefix, expected_result", + [ + ("", {}, None, True), + ("", {}, "GDS", True), + ("((placeholder))", {"placeholder": ""}, "GDS", True), + ("((placeholder))", {"placeholder": "Some content"}, None, False), + ("Some content", {}, "GDS", False), + ], +) +def test_is_message_empty_sms_templates( + content, values, prefix, expected_result, template_class +): + template = template_class( + {"content": content, "template_type": "sms"}, + prefix=prefix, + ) + template.sender = None + template.values = values + assert template.is_message_empty() == expected_result + + +@pytest.mark.parametrize( + "template_class", + [ + BroadcastMessageTemplate, + BroadcastPreviewTemplate, + ], +) +@pytest.mark.parametrize( + "content, values, expected_result", + [ + ("", {}, True), + ("((placeholder))", {"placeholder": ""}, True), + ("((placeholder))", {"placeholder": "Some content"}, False), + ("Some content", {}, False), + ], +) +def test_is_message_empty_broadcast_templates( + content, values, expected_result, template_class +): + template = template_class( + {"content": content, "template_type": "broadcast"}, + ) + template.sender = None + template.values = values + assert template.is_message_empty() == expected_result + + +@pytest.mark.parametrize( + "template_class, template_type", + ( + (HTMLEmailTemplate, "email"), + (LetterPrintTemplate, "letter"), + ), +) +@pytest.mark.parametrize( + "content, values, expected_result", + [ + ("", {}, True), + ("((placeholder))", {"placeholder": ""}, True), + ("((placeholder))", {"placeholder": " \t \r\n"}, True), + ("((placeholder))", {"placeholder": "Some content"}, False), + ("((placeholder??show_or_hide))", {"placeholder": False}, True), + ("Some content", {}, False), + ("((placeholder)) some content", {"placeholder": ""}, False), + ("Some content ((placeholder))", {"placeholder": ""}, False), + ], +) +def test_is_message_empty_email_and_letter_templates( + template_class, + template_type, + content, + values, + expected_result, +): + template = template_class( + { + "content": content, + "subject": "Hi", + "template_type": template_class.template_type, + } + ) + template.sender = None + template.values = values + assert template.is_message_empty() == expected_result + + +@pytest.mark.parametrize( + "template_class, template_type", + ( + (HTMLEmailTemplate, "email"), + (LetterPrintTemplate, "letter"), + ), +) +@pytest.mark.parametrize( + "content, values", + [ + ("Some content", {}), + ("((placeholder)) some content", {"placeholder": ""}), + ("Some content ((placeholder))", {"placeholder": ""}), + pytest.param( + "((placeholder))", + {"placeholder": "Some content"}, + marks=pytest.mark.xfail(raises=AssertionError), + ), + ], +) +def test_is_message_empty_email_and_letter_templates_tries_not_to_count_chars( + mocker, + template_class, + template_type, + content, + values, +): + template = template_class( + { + "content": content, + "subject": "Hi", + "template_type": template_type, + } + ) + mock_content = mocker.patch.object( + template_class, + "content_count", + create=True, + new_callable=mock.PropertyMock, + return_value=None, + ) + template.values = values + template.is_message_empty() + assert mock_content.called is False + + +@pytest.mark.parametrize( + "template_class, template_type, extra_args, expected_field_calls", + [ + ( + PlainTextEmailTemplate, + "email", + {}, + [mock.call("content", {}, html="passthrough", markdown_lists=True)], + ), + ( + HTMLEmailTemplate, + "email", + {}, + [ + mock.call( + "subject", {}, html="escape", redact_missing_personalisation=False + ), + mock.call( + "content", + {}, + html="escape", + markdown_lists=True, + redact_missing_personalisation=False, + ), + mock.call("content", {}, html="escape", markdown_lists=True), + ], + ), + ( + EmailPreviewTemplate, + "email", + {}, + [ + mock.call( + "content", + {}, + html="escape", + markdown_lists=True, + redact_missing_personalisation=False, + ), + mock.call( + "subject", {}, html="escape", redact_missing_personalisation=False + ), + mock.call("((email address))", {}, with_brackets=False), + ], + ), + ( + SMSMessageTemplate, + "sms", + {}, + [ + mock.call("content"), # This is to get the placeholders + mock.call("content", {}, html="passthrough"), + ], + ), + ( + SMSPreviewTemplate, + "sms", + {}, + [ + mock.call("((phone number))", {}, with_brackets=False, html="escape"), + mock.call( + "content", {}, html="escape", redact_missing_personalisation=False + ), + ], + ), + ( + BroadcastMessageTemplate, + "broadcast", + {}, + [ + mock.call("content", {}, html="escape"), + ], + ), + ( + BroadcastPreviewTemplate, + "broadcast", + {}, + [ + mock.call("((phone number))", {}, with_brackets=False, html="escape"), + mock.call( + "content", {}, html="escape", redact_missing_personalisation=False + ), + ], + ), + ( + LetterPreviewTemplate, + "letter", + {"contact_block": "www.gov.uk"}, + [ + mock.call( + "subject", {}, html="escape", redact_missing_personalisation=False + ), + mock.call( + "content", + {}, + html="escape", + markdown_lists=True, + redact_missing_personalisation=False, + ), + mock.call( + ( + "((address line 1))\n" + "((address line 2))\n" + "((address line 3))\n" + "((address line 4))\n" + "((address line 5))\n" + "((address line 6))\n" + "((address line 7))" + ), + {}, + with_brackets=False, + html="escape", + ), + mock.call( + "www.gov.uk", + {}, + html="escape", + redact_missing_personalisation=False, + ), + ], + ), + ( + LetterImageTemplate, + "letter", + { + "image_url": "http://example.com", + "page_count": 1, + "contact_block": "www.gov.uk", + }, + [ + mock.call( + ( + "((address line 1))\n" + "((address line 2))\n" + "((address line 3))\n" + "((address line 4))\n" + "((address line 5))\n" + "((address line 6))\n" + "((address line 7))" + ), + {}, + with_brackets=False, + html="escape", + ), + mock.call( + "www.gov.uk", + {}, + html="escape", + redact_missing_personalisation=False, + ), + mock.call( + "subject", {}, html="escape", redact_missing_personalisation=False + ), + mock.call( + "content", + {}, + html="escape", + markdown_lists=True, + redact_missing_personalisation=False, + ), + ], + ), + ( + EmailPreviewTemplate, + "email", + {"redact_missing_personalisation": True}, + [ + mock.call( + "content", + {}, + html="escape", + markdown_lists=True, + redact_missing_personalisation=True, + ), + mock.call( + "subject", {}, html="escape", redact_missing_personalisation=True + ), + mock.call("((email address))", {}, with_brackets=False), + ], + ), + ( + SMSPreviewTemplate, + "sms", + {"redact_missing_personalisation": True}, + [ + mock.call("((phone number))", {}, with_brackets=False, html="escape"), + mock.call( + "content", {}, html="escape", redact_missing_personalisation=True + ), + ], + ), + ( + BroadcastPreviewTemplate, + "broadcast", + {"redact_missing_personalisation": True}, + [ + mock.call("((phone number))", {}, with_brackets=False, html="escape"), + mock.call( + "content", {}, html="escape", redact_missing_personalisation=True + ), + ], + ), + ( + SMSBodyPreviewTemplate, + "sms", + {}, + [ + mock.call( + "content", {}, html="escape", redact_missing_personalisation=True + ), + ], + ), + ( + LetterPreviewTemplate, + "letter", + {"contact_block": "www.gov.uk", "redact_missing_personalisation": True}, + [ + mock.call( + "subject", {}, html="escape", redact_missing_personalisation=True + ), + mock.call( + "content", + {}, + html="escape", + markdown_lists=True, + redact_missing_personalisation=True, + ), + mock.call( + ( + "((address line 1))\n" + "((address line 2))\n" + "((address line 3))\n" + "((address line 4))\n" + "((address line 5))\n" + "((address line 6))\n" + "((address line 7))" + ), + {}, + with_brackets=False, + html="escape", + ), + mock.call( + "www.gov.uk", {}, html="escape", redact_missing_personalisation=True + ), + ], + ), + ], +) +@mock.patch("notifications_utils.template.Field.__init__", return_value=None) +@mock.patch( + "notifications_utils.template.Field.__str__", return_value="1\n2\n3\n4\n5\n6\n7\n8" +) +def test_templates_handle_html_and_redacting( + mock_field_str, + mock_field_init, + template_class, + template_type, + extra_args, + expected_field_calls, +): + assert str( + template_class( + { + "content": "content", + "subject": "subject", + "template_type": template_type, + }, + **extra_args, + ) + ) + assert mock_field_init.call_args_list == expected_field_calls + + +@pytest.mark.parametrize( + "template_class, template_type, extra_args, expected_remove_whitespace_calls", + [ + ( + PlainTextEmailTemplate, + "email", + {}, + [ + mock.call("\n\ncontent"), + mock.call(Markup("subject")), + mock.call(Markup("subject")), + ], + ), + ( + HTMLEmailTemplate, + "email", + {}, + [ + mock.call(Markup("subject")), + mock.call( + '

    ' + "content" + "

    " + ), + mock.call("\n\ncontent"), + mock.call(Markup("subject")), + mock.call(Markup("subject")), + ], + ), + ( + EmailPreviewTemplate, + "email", + {}, + [ + mock.call( + '

    ' + "content" + "

    " + ), + mock.call(Markup("subject")), + mock.call(Markup("subject")), + mock.call(Markup("subject")), + ], + ), + ( + SMSMessageTemplate, + "sms", + {}, + [ + mock.call("content"), + ], + ), + ( + SMSPreviewTemplate, + "sms", + {}, + [ + mock.call("content"), + ], + ), + ( + SMSBodyPreviewTemplate, + "sms", + {}, + [ + mock.call("content"), + ], + ), + ( + BroadcastMessageTemplate, + "broadcast", + {}, + [ + mock.call("content"), + ], + ), + ( + BroadcastPreviewTemplate, + "broadcast", + {}, + [ + mock.call("content"), + ], + ), + ( + LetterPreviewTemplate, + "letter", + {"contact_block": "www.gov.uk"}, + [ + mock.call(Markup("subject")), + mock.call(Markup("

    content

    ")), + mock.call(Markup("www.gov.uk")), + mock.call(Markup("subject")), + mock.call(Markup("subject")), + ], + ), + ], +) +@mock.patch( + "notifications_utils.template.remove_whitespace_before_punctuation", + side_effect=lambda x: x, +) +def test_templates_remove_whitespace_before_punctuation( + mock_remove_whitespace, + template_class, + template_type, + extra_args, + expected_remove_whitespace_calls, +): + template = template_class( + {"content": "content", "subject": "subject", "template_type": template_type}, + **extra_args, + ) + + assert str(template) + + if hasattr(template, "subject"): + assert template.subject + + assert mock_remove_whitespace.call_args_list == expected_remove_whitespace_calls + + +@pytest.mark.parametrize( + "template_class, template_type, extra_args, expected_calls", + [ + ( + PlainTextEmailTemplate, + "email", + {}, + [ + mock.call("\n\ncontent"), + mock.call(Markup("subject")), + ], + ), + ( + HTMLEmailTemplate, + "email", + {}, + [ + mock.call( + '

    ' + "content" + "

    " + ), + mock.call("\n\ncontent"), + mock.call(Markup("subject")), + ], + ), + ( + EmailPreviewTemplate, + "email", + {}, + [ + mock.call( + '

    ' + "content" + "

    " + ), + mock.call(Markup("subject")), + ], + ), + (SMSMessageTemplate, "sms", {}, []), + (SMSPreviewTemplate, "sms", {}, []), + (SMSBodyPreviewTemplate, "sms", {}, []), + (BroadcastMessageTemplate, "broadcast", {}, []), + (BroadcastPreviewTemplate, "broadcast", {}, []), + ( + LetterPreviewTemplate, + "letter", + {"contact_block": "www.gov.uk"}, + [ + mock.call(Markup("subject")), + mock.call(Markup("

    content

    ")), + ], + ), + ], +) +@mock.patch("notifications_utils.template.make_quotes_smart", side_effect=lambda x: x) +@mock.patch( + "notifications_utils.template.replace_hyphens_with_en_dashes", + side_effect=lambda x: x, +) +def test_templates_make_quotes_smart_and_dashes_en( + mock_en_dash_replacement, + mock_smart_quotes, + template_class, + template_type, + extra_args, + expected_calls, +): + template = template_class( + {"content": "content", "subject": "subject", "template_type": template_type}, + **extra_args, + ) + + assert str(template) + + if hasattr(template, "subject"): + assert template.subject + + mock_smart_quotes.assert_has_calls(expected_calls) + mock_en_dash_replacement.assert_has_calls(expected_calls) + + +@pytest.mark.parametrize( + "content", + ( + "first.o'last@example.com", + "first.o’last@example.com", + ), +) +@pytest.mark.parametrize( + "template_class", + ( + HTMLEmailTemplate, + PlainTextEmailTemplate, + EmailPreviewTemplate, + ), +) +def test_no_smart_quotes_in_email_addresses(template_class, content): + template = template_class( + { + "content": content, + "subject": content, + "template_type": "email", + } + ) + assert "first.o'last@example.com" in str(template) + assert template.subject == "first.o'last@example.com" + + +def test_smart_quotes_removed_from_long_template_in_under_a_second(): + long_string = "a" * 100000 + template = PlainTextEmailTemplate( + { + "content": long_string, + "subject": "", + "template_type": "email", + } + ) + + start_time = process_time() + + str(template) + + assert process_time() - start_time < 1 + + +@pytest.mark.parametrize( + "template_instance, expected_placeholders", + [ + ( + SMSMessageTemplate( + { + "content": "((content))", + "subject": "((subject))", + "template_type": "sms", + }, + ), + ["content"], + ), + ( + SMSPreviewTemplate( + { + "content": "((content))", + "subject": "((subject))", + "template_type": "sms", + }, + ), + ["content"], + ), + ( + SMSBodyPreviewTemplate( + { + "content": "((content))", + "subject": "((subject))", + "template_type": "sms", + }, + ), + ["content"], + ), + ( + BroadcastMessageTemplate( + { + "content": "((content))", + "subject": "((subject))", + "template_type": "broadcast", + }, + ), + ["content"], + ), + ( + BroadcastPreviewTemplate( + { + "content": "((content))", + "subject": "((subject))", + "template_type": "broadcast", + }, + ), + ["content"], + ), + ( + PlainTextEmailTemplate( + { + "content": "((content))", + "subject": "((subject))", + "template_type": "email", + }, + ), + ["subject", "content"], + ), + ( + HTMLEmailTemplate( + { + "content": "((content))", + "subject": "((subject))", + "template_type": "email", + }, + ), + ["subject", "content"], + ), + ( + EmailPreviewTemplate( + { + "content": "((content))", + "subject": "((subject))", + "template_type": "email", + }, + ), + ["subject", "content"], + ), + ( + LetterPreviewTemplate( + { + "content": "((content))", + "subject": "((subject))", + "template_type": "letter", + }, + contact_block="((contact_block))", + ), + ["contact_block", "subject", "content"], + ), + ( + LetterImageTemplate( + { + "content": "((content))", + "subject": "((subject))", + "template_type": "letter", + }, + contact_block="((contact_block))", + image_url="http://example.com", + page_count=99, + ), + ["contact_block", "subject", "content"], + ), + ], +) +def test_templates_extract_placeholders( + template_instance, + expected_placeholders, +): + assert template_instance.placeholders == OrderedSet(expected_placeholders) + + +@pytest.mark.parametrize( + "extra_args", + [ + {"from_name": "Example service"}, + { + "from_name": "Example service", + "from_address": "test@example.com", + }, + pytest.param({}, marks=pytest.mark.xfail), + ], +) +def test_email_preview_shows_from_name(extra_args): + template = EmailPreviewTemplate( + {"content": "content", "subject": "subject", "template_type": "email"}, + **extra_args, + ) + assert 'From' in str(template) + assert "Example service" in str(template) + assert "test@example.com" not in str(template) + + +def test_email_preview_escapes_html_in_from_name(): + template = EmailPreviewTemplate( + {"content": "content", "subject": "subject", "template_type": "email"}, + from_name='', + from_address="test@example.com", + ) + assert "