Repository: microsoft/botbuilder-python

Issue:
NumberPrompt Locale Not Fully Implemented
## Version
v4.5

## Describe the bug
Found this bug while investigating for parity with regards to this [`NumberPrompt` bug filed in the dotnet repo](https://github.com/microsoft/botbuilder-dotnet/issues/2288)
* in constructor, `default_locale` attribute is set, but never used in `NumberPrompt`'s implementation (not in `on_prompt()` nor `on_recognize()`
* `on_recognize()` does allow you to pass in `locale` via `Activity`, however locale will not be used if only `default_locale` is specified
* "`English`" is used as string to specify locale, when we should be using the constants provided by the python recognizers-text repo

* Separately, there's definitely a lack of unit test coverage for this feature (only 2 tests written)

## Expected behavior
* implement use of `default_locale`
* implement use of recognizers-text constants to specify locale



[bug]



Discussion:
Update: looks like python actually never parses the value from `str` to `float`/`int` in `NumberPrompt`, which deviates from the [behavior in dotnet repo](https://github.com/microsoft/botbuilder-dotnet/blob/master/libraries/Microsoft.Bot.Builder.Dialogs/Prompts/NumberPrompt.cs#L112).

This bug should be fixed as well.

Python:
```python
            results = recognize_number(message.text, culture)
            if results:
                result.succeeded = True
                result.value = results[0].resolution["value"]
```

![image](https://user-images.githubusercontent.com/35248895/62323196-9832cb00-b45b-11e9-9a6f-cb007b54732c.png)



Resolving patch:
diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/number_prompt.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/number_prompt.py
--- a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/number_prompt.py
+++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/number_prompt.py
@@ -1,18 +1,29 @@
 # Copyright (c) Microsoft Corporation. All rights reserved.
 # Licensed under the MIT License.
 
-from typing import Dict
+from typing import Callable, Dict
+
+from babel.numbers import parse_decimal
 from recognizers_number import recognize_number
+from recognizers_text import Culture, ModelResult
+
 from botbuilder.core.turn_context import TurnContext
 from botbuilder.schema import ActivityTypes
-from .prompt import Prompt
+
+from .prompt import Prompt, PromptValidatorContext
 from .prompt_options import PromptOptions
 from .prompt_recognizer_result import PromptRecognizerResult
 
 
 class NumberPrompt(Prompt):
-    # TODO: PromptValidator
-    def __init__(self, dialog_id: str, validator: object, default_locale: str):
+    # TODO: PromptValidator needs to be fixed
+    # Does not accept answer as intended (times out)
+    def __init__(
+        self,
+        dialog_id: str,
+        validator: Callable[[PromptValidatorContext], bool] = None,
+        default_locale: str = None,
+    ):
         super(NumberPrompt, self).__init__(dialog_id, validator)
         self.default_locale = default_locale
 
@@ -30,9 +41,8 @@ async def on_prompt(
 
         if is_retry and options.retry_prompt is not None:
             turn_context.send_activity(options.retry_prompt)
-        else:
-            if options.prompt is not None:
-                await turn_context.send_activity(options.prompt)
+        elif options.prompt is not None:
+            await turn_context.send_activity(options.prompt)
 
     async def on_recognize(
         self,
@@ -46,17 +56,25 @@ async def on_recognize(
         result = PromptRecognizerResult()
         if turn_context.activity.type == ActivityTypes.message:
             message = turn_context.activity
+            culture = self._get_culture(turn_context)
+            results: [ModelResult] = recognize_number(message.text, culture)
 
-            # TODO: Fix constant English with correct constant from text recognizer
-            culture = (
-                turn_context.activity.locale
-                if turn_context.activity.locale is not None
-                else "English"
-            )
-
-            results = recognize_number(message.text, culture)
             if results:
                 result.succeeded = True
-                result.value = results[0].resolution["value"]
+                result.value = parse_decimal(
+                    results[0].resolution["value"], locale=culture.replace("-", "_")
+                )
 
         return result
+
+    def _get_culture(self, turn_context: TurnContext):
+        culture = (
+            turn_context.activity.locale
+            if turn_context.activity.locale
+            else self.default_locale
+        )
+
+        if not culture:
+            culture = Culture.English
+
+        return culture
diff --git a/libraries/botbuilder-dialogs/setup.py b/libraries/botbuilder-dialogs/setup.py
--- a/libraries/botbuilder-dialogs/setup.py
+++ b/libraries/botbuilder-dialogs/setup.py
@@ -12,6 +12,7 @@
     "recognizers-text-choice>=1.0.1a0",
     "grapheme>=0.5.0",
     "emoji>=0.5.2",
+    "babel>=2.7.0",
     "botbuilder-schema>=4.4.0b1",
     "botframework-connector>=4.4.0b1",
     "botbuilder-core>=4.4.0b1",


Test patch:
diff --git a/libraries/botbuilder-dialogs/tests/test_number_prompt.py b/libraries/botbuilder-dialogs/tests/test_number_prompt.py
--- a/libraries/botbuilder-dialogs/tests/test_number_prompt.py
+++ b/libraries/botbuilder-dialogs/tests/test_number_prompt.py
@@ -1,7 +1,16 @@
 # Copyright (c) Microsoft Corporation. All rights reserved.
 # Licensed under the MIT License.
+from typing import Callable
+
 import aiounittest
-from botbuilder.dialogs.prompts import NumberPrompt, PromptOptions
+from recognizers_text import Culture
+
+from botbuilder.dialogs import DialogContext, DialogTurnResult
+from botbuilder.dialogs.prompts import (
+    NumberPrompt,
+    PromptOptions,
+    PromptValidatorContext,
+)
 from botbuilder.core import (
     MemoryStorage,
     ConversationState,
@@ -10,14 +19,68 @@
 )
 from botbuilder.core.adapters import TestAdapter, TestFlow
 from botbuilder.dialogs import DialogSet, DialogTurnStatus
+from botbuilder.schema import Activity, ActivityTypes
+
+
+class NumberPromptMock(NumberPrompt):
+    def __init__(
+        self,
+        dialog_id: str,
+        validator: Callable[[PromptValidatorContext], bool] = None,
+        default_locale=None,
+    ):
+        super().__init__(dialog_id, validator, default_locale)
+
+    async def on_prompt_null_context(self, options: PromptOptions):
+        # Should throw TypeError
+        await self.on_prompt(
+            turn_context=None, state=None, options=options, is_retry=False
+        )
+
+    async def on_prompt_null_options(self, dialog_context: DialogContext):
+        # Should throw TypeError
+        await self.on_prompt(
+            dialog_context.context, state=None, options=None, is_retry=False
+        )
+
+    async def on_recognize_null_context(self):
+        # Should throw TypeError
+        await self.on_recognize(turn_context=None, state=None, options=None)
 
 
 class NumberPromptTests(aiounittest.AsyncTestCase):
-    def test_empty_should_fail(self):
+    def test_empty_id_should_fail(self):
         # pylint: disable=no-value-for-parameter
         empty_id = ""
         self.assertRaises(TypeError, lambda: NumberPrompt(empty_id))
 
+    def test_none_id_should_fail(self):
+        # pylint: disable=no-value-for-parameter
+        self.assertRaises(TypeError, lambda: NumberPrompt(dialog_id=None))
+
+    async def test_with_null_turn_context_should_fail(self):
+        number_prompt_mock = NumberPromptMock("NumberPromptMock")
+
+        options = PromptOptions(
+            prompt=Activity(type=ActivityTypes.message, text="Please send a number.")
+        )
+
+        with self.assertRaises(TypeError):
+            await number_prompt_mock.on_prompt_null_context(options)
+
+    async def test_on_prompt_with_null_options_fails(self):
+        conver_state = ConversationState(MemoryStorage())
+        dialog_state = conver_state.create_property("dialogState")
+        dialogs = DialogSet(dialog_state)
+
+        number_prompt_mock = NumberPromptMock(
+            dialog_id="NumberPromptMock", validator=None, default_locale=Culture.English
+        )
+        dialogs.add(number_prompt_mock)
+
+        with self.assertRaises(TypeError):
+            await number_prompt_mock.on_recognize_null_context()
+
     async def test_number_prompt(self):
         # Create new ConversationState with MemoryStorage and register the state as middleware.
         conver_state = ConversationState(MemoryStorage())
@@ -28,11 +91,10 @@ async def test_number_prompt(self):
         dialogs = DialogSet(dialog_state)
 
         # Create and add number prompt to DialogSet.
-        number_prompt = NumberPrompt("NumberPrompt", None, "English")
+        number_prompt = NumberPrompt("NumberPrompt", None, Culture.English)
         dialogs.add(number_prompt)
 
         async def exec_test(turn_context: TurnContext) -> None:
-
             dialog_context = await dialogs.create_context(turn_context)
             results = await dialog_context.continue_dialog()
 
@@ -62,3 +124,263 @@ async def exec_test(turn_context: TurnContext) -> None:
         test_flow3 = await test_flow2.assert_reply("Enter quantity of cable")
         test_flow4 = await test_flow3.send("Give me twenty meters of cable")
         await test_flow4.assert_reply("You asked me for '20' meters of cable.")
+
+    # TODO: retry_prompt in NumberPrompt appears to be broken
+    # It when NumberPrompt fails to receive a number, it retries, prompting
+    # with the prompt and not retry prompt in options
+    async def test_number_prompt_retry(self):
+        async def exec_test(turn_context: TurnContext) -> None:
+            dialog_context: DialogContext = await dialogs.create_context(turn_context)
+
+            results: DialogTurnResult = await dialog_context.continue_dialog()
+
+            if results.status == DialogTurnStatus.Empty:
+                options = PromptOptions(
+                    prompt=Activity(type=ActivityTypes.message, text="Enter a number."),
+                    retry_prompt=Activity(
+                        type=ActivityTypes.message, text="You must enter a number."
+                    ),
+                )
+                await dialog_context.prompt("NumberPrompt", options)
+            elif results.status == DialogTurnStatus.Complete:
+                number_result = results.result
+                await turn_context.send_activity(
+                    MessageFactory.text(f"Bot received the number '{number_result}'.")
+                )
+
+            await convo_state.save_changes(turn_context)
+
+        adapter = TestAdapter(exec_test)
+
+        convo_state = ConversationState(MemoryStorage())
+        dialog_state = convo_state.create_property("dialogState")
+        dialogs = DialogSet(dialog_state)
+        number_prompt = NumberPrompt(
+            dialog_id="NumberPrompt", validator=None, default_locale=Culture.English
+        )
+        dialogs.add(number_prompt)
+
+        step1 = await adapter.send("hello")
+        await step1.assert_reply("Enter a number.")
+        # TODO: something is breaking in the validators or retry prompt
+        # where it does not accept the 2nd answer after reprompting the user
+        # for another value
+        # step3 = await step2.send("hello")
+        # step4 = await step3.assert_reply("You must enter a number.")
+        # step5 = await step4.send("64")
+        # await step5.assert_reply("Bot received the number '64'.")
+
+    async def test_number_uses_locale_specified_in_constructor(self):
+        # Create new ConversationState with MemoryStorage and register the state as middleware.
+        conver_state = ConversationState(MemoryStorage())
+
+        # Create a DialogState property, DialogSet and register the WaterfallDialog.
+        dialog_state = conver_state.create_property("dialogState")
+
+        dialogs = DialogSet(dialog_state)
+
+        # Create and add number prompt to DialogSet.
+        number_prompt = NumberPrompt(
+            "NumberPrompt", None, default_locale=Culture.Spanish
+        )
+        dialogs.add(number_prompt)
+
+        async def exec_test(turn_context: TurnContext) -> None:
+
+            dialog_context = await dialogs.create_context(turn_context)
+            results = await dialog_context.continue_dialog()
+
+            if results.status == DialogTurnStatus.Empty:
+                await dialog_context.begin_dialog(
+                    "NumberPrompt",
+                    PromptOptions(
+                        prompt=MessageFactory.text(
+                            "How much money is in your gaming account?"
+                        )
+                    ),
+                )
+            else:
+                if results.status == DialogTurnStatus.Complete:
+                    number_result = results.result
+                    await turn_context.send_activity(
+                        MessageFactory.text(
+                            f"You say you have ${number_result} in your gaming account."
+                        )
+                    )
+
+            await conver_state.save_changes(turn_context)
+
+        adapter = TestAdapter(exec_test)
+
+        test_flow = TestFlow(None, adapter)
+
+        test_flow2 = await test_flow.send("Hello")
+        test_flow3 = await test_flow2.assert_reply(
+            "How much money is in your gaming account?"
+        )
+        test_flow4 = await test_flow3.send("I've got $1.200.555,42 in my account.")
+        await test_flow4.assert_reply(
+            "You say you have $1200555.42 in your gaming account."
+        )
+
+    async def test_number_prompt_validator(self):
+        async def exec_test(turn_context: TurnContext) -> None:
+            dialog_context = await dialogs.create_context(turn_context)
+            results = await dialog_context.continue_dialog()
+
+            if results.status == DialogTurnStatus.Empty:
+                options = PromptOptions(
+                    prompt=Activity(type=ActivityTypes.message, text="Enter a number."),
+                    retry_prompt=Activity(
+                        type=ActivityTypes.message,
+                        text="You must enter a positive number less than 100.",
+                    ),
+                )
+                await dialog_context.prompt("NumberPrompt", options)
+
+            elif results.status == DialogTurnStatus.Complete:
+                number_result = int(results.result)
+                await turn_context.send_activity(
+                    MessageFactory.text(f"Bot received the number '{number_result}'.")
+                )
+
+            await conver_state.save_changes(turn_context)
+
+        # Create new ConversationState with MemoryStorage and register the state as middleware.
+        conver_state = ConversationState(MemoryStorage())
+
+        # Create a DialogState property, DialogSet and register the WaterfallDialog.
+        dialog_state = conver_state.create_property("dialogState")
+
+        dialogs = DialogSet(dialog_state)
+
+        # Create and add number prompt to DialogSet.
+        async def validator(prompt_context: PromptValidatorContext):
+            result = prompt_context.recognized.value
+
+            if 0 < result < 100:
+                return True
+
+            return False
+
+        number_prompt = NumberPrompt(
+            "NumberPrompt", validator, default_locale=Culture.English
+        )
+        dialogs.add(number_prompt)
+
+        adapter = TestAdapter(exec_test)
+
+        step1 = await adapter.send("hello")
+        step2 = await step1.assert_reply("Enter a number.")
+        await step2.send("150")
+        # TODO: something is breaking in the validators or retry prompt
+        # where it does not accept the 2nd answer after reprompting the user
+        # for another value
+        # step4 = await step3.assert_reply("You must enter a positive number less than 100.")
+        # step5 = await step4.send("64")
+        # await step5.assert_reply("Bot received the number '64'.")
+
+    async def test_float_number_prompt(self):
+        async def exec_test(turn_context: TurnContext) -> None:
+            dialog_context = await dialogs.create_context(turn_context)
+            results = await dialog_context.continue_dialog()
+
+            if results.status == DialogTurnStatus.Empty:
+                options = PromptOptions(
+                    prompt=Activity(type=ActivityTypes.message, text="Enter a number.")
+                )
+                await dialog_context.prompt("NumberPrompt", options)
+
+            elif results.status == DialogTurnStatus.Complete:
+                number_result = float(results.result)
+                await turn_context.send_activity(
+                    MessageFactory.text(f"Bot received the number '{number_result}'.")
+                )
+
+            await conver_state.save_changes(turn_context)
+
+        # Create new ConversationState with MemoryStorage and register the state as middleware.
+        conver_state = ConversationState(MemoryStorage())
+
+        # Create a DialogState property, DialogSet and register the WaterfallDialog.
+        dialog_state = conver_state.create_property("dialogState")
+
+        dialogs = DialogSet(dialog_state)
+
+        # Create and add number prompt to DialogSet.
+        number_prompt = NumberPrompt(
+            "NumberPrompt", validator=None, default_locale=Culture.English
+        )
+        dialogs.add(number_prompt)
+
+        adapter = TestAdapter(exec_test)
+
+        step1 = await adapter.send("hello")
+        step2 = await step1.assert_reply("Enter a number.")
+        step3 = await step2.send("3.14")
+        await step3.assert_reply("Bot received the number '3.14'.")
+
+    async def test_number_prompt_uses_locale_specified_in_activity(self):
+        async def exec_test(turn_context: TurnContext):
+            dialog_context = await dialogs.create_context(turn_context)
+            results = await dialog_context.continue_dialog()
+
+            if results.status == DialogTurnStatus.Empty:
+                options = PromptOptions(
+                    prompt=Activity(type=ActivityTypes.message, text="Enter a number.")
+                )
+                await dialog_context.prompt("NumberPrompt", options)
+
+            elif results.status == DialogTurnStatus.Complete:
+                number_result = float(results.result)
+                self.assertEqual(3.14, number_result)
+
+            await conver_state.save_changes(turn_context)
+
+        conver_state = ConversationState(MemoryStorage())
+        dialog_state = conver_state.create_property("dialogState")
+        dialogs = DialogSet(dialog_state)
+
+        number_prompt = NumberPrompt("NumberPrompt", None, None)
+        dialogs.add(number_prompt)
+
+        adapter = TestAdapter(exec_test)
+
+        step1 = await adapter.send("hello")
+        step2 = await step1.assert_reply("Enter a number.")
+        await step2.send(
+            Activity(type=ActivityTypes.message, text="3,14", locale=Culture.Spanish)
+        )
+
+    async def test_number_prompt_defaults_to_en_us_culture(self):
+        async def exec_test(turn_context: TurnContext):
+            dialog_context = await dialogs.create_context(turn_context)
+            results = await dialog_context.continue_dialog()
+
+            if results.status == DialogTurnStatus.Empty:
+                options = PromptOptions(
+                    prompt=Activity(type=ActivityTypes.message, text="Enter a number.")
+                )
+                await dialog_context.prompt("NumberPrompt", options)
+
+            elif results.status == DialogTurnStatus.Complete:
+                number_result = float(results.result)
+                await turn_context.send_activity(
+                    MessageFactory.text(f"Bot received the number '{number_result}'.")
+                )
+
+            await conver_state.save_changes(turn_context)
+
+        conver_state = ConversationState(MemoryStorage())
+        dialog_state = conver_state.create_property("dialogState")
+        dialogs = DialogSet(dialog_state)
+
+        number_prompt = NumberPrompt("NumberPrompt")
+        dialogs.add(number_prompt)
+
+        adapter = TestAdapter(exec_test)
+
+        step1 = await adapter.send("hello")
+        step2 = await step1.assert_reply("Enter a number.")
+        step3 = await step2.send("3.14")
+        await step3.assert_reply("Bot received the number '3.14'.")