-
Notifications
You must be signed in to change notification settings - Fork 0
feat: Add .po file pseudolocalization to example web app #24
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -1,9 +1,11 @@ | ||||||
| #!/usr/bin/env python3 | ||||||
|
|
||||||
| from flask import Flask, jsonify, make_response, redirect, render_template, request | ||||||
| from flask import Flask, jsonify, make_response, redirect, render_template, request, send_file | ||||||
| import os | ||||||
| import tempfile | ||||||
|
|
||||||
| import pseudol10nutil.transforms as xforms | ||||||
| from pseudol10nutil import PseudoL10nUtil | ||||||
| from pseudol10nutil import PseudoL10nUtil, POFileUtil | ||||||
|
|
||||||
| app = Flask(__name__) | ||||||
| appname = "pseudol10nutil" | ||||||
|
|
@@ -106,5 +108,69 @@ def do_pseudo_ui(): | |||||
| return render_template("pseudolocalize_template.html", **default_options) | ||||||
|
|
||||||
|
|
||||||
| @app.route(ui_base_url + "po_upload", methods=["POST"]) | ||||||
| def do_pseudo_po_upload(): | ||||||
| input_temp_file_path = None | ||||||
| output_temp_file_path = None | ||||||
| try: | ||||||
| if "po_file" not in request.files: | ||||||
| return make_response( | ||||||
| jsonify({"error": "400 Error: No file part in the request."}), 400 | ||||||
| ) | ||||||
|
|
||||||
| file = request.files["po_file"] | ||||||
|
|
||||||
| if file.filename == "": | ||||||
| return make_response( | ||||||
| jsonify({"error": "400 Error: No file selected for uploading."}), 400 | ||||||
| ) | ||||||
|
|
||||||
| if not file.filename.endswith(".po"): | ||||||
| return make_response( | ||||||
| jsonify( | ||||||
| {"error": "400 Error: Invalid file type. Please upload a .po file."} | ||||||
| ), | ||||||
| 400, | ||||||
| ) | ||||||
|
|
||||||
| # Create temporary files | ||||||
| fd_input, input_temp_file_path = tempfile.mkstemp(suffix=".po") | ||||||
| os.close(fd_input) # close file descriptor as pofileutil will open and close the file | ||||||
| fd_output, output_temp_file_path = tempfile.mkstemp(suffix=".po") | ||||||
| os.close(fd_output) # close file descriptor as pofileutil will open and close the file | ||||||
|
|
||||||
|
|
||||||
|
||||||
Copilot
AI
Feb 27, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
POFileUtil doesn't have a transforms property. The transforms should be set on the internal l10nutil object instead. Change this line to: pofileutil.l10nutil.transforms = current_transforms
| pofileutil.transforms = current_transforms | |
| pofileutil.l10nutil.transforms = current_transforms |
Copilot
AI
Feb 27, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Using send_file with a temporary file that is deleted in the finally block can cause a race condition. Flask's send_file may not have finished sending the file when the finally block executes and deletes it. Instead, either: (1) Don't delete output_temp_file_path and let the OS clean up temp files, or (2) Use send_file's download_name parameter with open() and let Flask handle the cleanup, or (3) Use Flask's after_this_request to schedule cleanup after the response is sent.
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -1,29 +1,112 @@ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import unittest | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import io | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import json | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import requests | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| from examples.webapp.app import app, ui_base_url, api_base_url | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| from src.pseudol10nutil.pseudol10nutil import PseudoL10nUtil | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| from src.pseudol10nutil import transforms as xforms | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| from pseudol10nutil import PseudoL10nUtil | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| base_url = "http://localhost:5000/pseudol10nutil/api/v1.0/" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| headers = {"Accept": "application/json", "Content-Type": "application/json"} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| class TestPseudoL10nUtil(unittest.TestCase): | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| class TestWebApp(unittest.TestCase): | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| def setUp(self): | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| self.client = app.test_client() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # Keep util if other tests directly use its methods, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # or for comparing results in API tests. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| self.util = PseudoL10nUtil() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # Default transforms for PO file for assertion comparison | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # This should match the defaults set in app.py's po_upload route | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| self.util.transforms = [ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| xforms.transliterate_diacritic, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| xforms.pad_length, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| xforms.square_brackets | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ] | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| def test_pseudo(self): | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| def test_pseudo_api(self): | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| data = { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "key1": "The quick brown fox jumps over the lazy dog.", | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "key2": "The quick brown {animal1} jumps over the lazy {animal2}.", | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "key3": "The quick brown %s jumps over the lazy %s.", | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "key4": "The quick brown %(animal1)s jumps over the lazy %(animal2)s.", | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| request_data = {"strings": data} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| resp = requests.post(base_url + "pseudo", headers=headers, json=request_data) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| results = resp.json()["strings"] | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # Reset transforms to default for this specific API test if needed, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # as self.util might be configured with PO file defaults in setUp. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # For this test, we use a fresh PseudoL10nUtil instance with its own default transforms. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| fresh_util_for_api_test = PseudoL10nUtil() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # If the main API also uses specific non-default transforms, set them here for fresh_util_for_api_test | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # For now, assuming it uses the class defaults. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| resp = self.client.post( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| api_base_url + "pseudo", | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| headers={"Content-Type": "application/json", "Accept": "application/json"}, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| data=json.dumps(request_data), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| self.assertEqual(resp.status_code, 200) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| results = resp.get_json()["strings"] | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| for k, v in results.items(): | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| self.assertEqual(self.util.pseudolocalize(data[k]), v) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| self.assertEqual(fresh_util_for_api_test.pseudolocalize(data[k]), v) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| def test_po_file_upload_success(self): | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| po_content = 'msgid "Hello"\nmsgstr ""' | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # Apply the specific transforms to "Hello" to get the expected output part | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # The self.util is already configured with these transforms in setUp | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| expected_output_part = self.util.pseudolocalize("Hello") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| data = { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 'po_file': (io.BytesIO(po_content.encode('utf-8')), 'test.po') | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| resp = self.client.post( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ui_base_url + "po_upload", | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| content_type='multipart/form-data', | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| data=data | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| self.assertEqual(resp.status_code, 200) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| self.assertIn('attachment', resp.headers['Content-Disposition']) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| self.assertIn('filename=pseudolocalized.po', resp.headers['Content-Disposition']) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # Check if the pseudolocalized string is in the output | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # The actual PO file will have more structure (headers, etc.) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # but checking for the transformed msgid is a good indicator. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| self.assertIn(expected_output_part, resp.data.decode('utf-8')) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+70
to
+71
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| def test_po_file_upload_with_non_default_transforms(self): | |
| """ | |
| Ensure that the po_upload route correctly applies a non-default set of | |
| transforms to the PO file contents. This test would fail if the route | |
| forgets to configure its PseudoL10nUtil instance and instead relies on | |
| class defaults. | |
| """ | |
| po_content = 'msgid "Hello"\nmsgstr ""' | |
| # Configure a util instance with a non-default ordering/subset of transforms. | |
| # Using a different order and subset ensures that the expected output | |
| # diverges from the class defaults if the route does not configure | |
| # its own transforms. | |
| custom_util = PseudoL10nUtil() | |
| custom_util.transforms = [ | |
| xforms.square_brackets, | |
| xforms.transliterate_diacritic, | |
| ] | |
| expected_output_part = custom_util.pseudolocalize("Hello") | |
| data = { | |
| 'po_file': (io.BytesIO(po_content.encode('utf-8')), 'test_custom.po') | |
| } | |
| resp = self.client.post( | |
| ui_base_url + "po_upload", | |
| content_type='multipart/form-data', | |
| data=data | |
| ) | |
| self.assertEqual(resp.status_code, 200) | |
| self.assertIn('attachment', resp.headers['Content-Disposition']) | |
| self.assertIn('filename=pseudolocalized.po', resp.headers['Content-Disposition']) | |
| # With non-default transforms applied, the customized pseudolocalized | |
| # string for "Hello" should appear in the returned PO file. | |
| self.assertIn(expected_output_part, resp.data.decode('utf-8')) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The file type validation only checks the filename extension, which can be easily spoofed. Consider validating the actual file content by checking if polib.pofile() can successfully parse it, and wrap that in a try-except to catch invalid PO files. This would provide more robust validation than just checking the extension.