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
upload: Enable File Upload for users. #1414
base: main
Are you sure you want to change the base?
upload: Enable File Upload for users. #1414
Conversation
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.
Thank you for working on this @Subhasish-Behera
I ran the code from the PR on my local machine. I was able to view the link generated when a file is uploaded by entering the path in the popup.
Further steps can be manipulating the write_box
such that the generated link gets attached in the message editor.
8d355d6
to
d9df231
Compare
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.
This version of the PR is much closer to the bare bones approach which I had mentioned on the stream earlier. The commit structure looks fine to me. I ran this on my local machine, and it worked fine when the extra /
was not present in the appended URL.
Also, I think it was a good idea to change the position of the cursor after the URL is appended to the compose box content.
zulipterminal/ui_tools/boxes.py
Outdated
edit_widget = self.contents[self.FOCUS_CONTAINER_MESSAGE][ | ||
self.FOCUS_MESSAGE_BOX_BODY | ||
] | ||
edit_widget.edit_text += f"[{file_name}](/{str(uri)})" |
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.
Upon sending the message and opening the file, the URL wasn't able to fetch the file. This may be because of the extra /
before the uri
, because the URI returned from the server already has a /
at the start.
Also, the function already has the type of uri
as str
, so there might be no need to typecast it to string.
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 upload works now, but the str
conversion remains - is there a reason for this?
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.
@Subhasish-Behera As per Mounil's review, this works with a very minor change to the file prefix 👍
Other than Mounil's and my inline comments, and ensuring the link is generated correctly, the next steps would be to add some tests for commits, in particular those handling the logic. That should help with thinking about the path entered and what the server expects and provides back to the application.
zulipterminal/model.py
Outdated
def get_file_upld_uri(self, file_loctn: str) -> str: | ||
with open(file_loctn, "rb") as fp: | ||
result = self.client.upload_file(fp) | ||
return result["uri"] |
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.
Please use full words for function/variable names - we're not short of space here :)
I know we need to be more consistent here, but also note that while this method does return the URI, it doesn't only get the URI but also uploads the file first.
zulipterminal/ui_tools/boxes.py
Outdated
@@ -710,6 +710,14 @@ def autocomplete_emojis( | |||
|
|||
return emoji_typeahead, emojis | |||
|
|||
def insert_uri(self, file_name: str, uri: str) -> None: |
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 following may feel minor, but is useful in the same way as naming things well :)
It would be useful to ensure that at calling sites users know what the parameters to a function are - if only inserting a URI, why are there two parameters? :)
eg. I could call this as insert_uri(x,y)
, and it wouldn't be clear. I could try and name x and y clearly at the call site, but that doesn't guarantee that they are meaningful.
For a few parameters you could do something like action_xthing_with_ything(x,y)
and the order can help, but otherwise it's useful to use *
to require a named parameter. For example:
def action_xthing(xthing, *, ything):
...
action_xthing(x, ything=y)
I expect you've iterated on what this method does, but it's good to reflect the behavior in the name, eg. perhaps now this should be append_*
?
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.
Makes sense.
zulipterminal/ui_tools/views.py
Outdated
self.write_box.insert_uri(file_name, self.uri) | ||
|
||
def keypress(self, size: urwid_Size, key: str) -> str: | ||
if is_command_key("FILE_UPLOAD", key): |
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.
This is the first commit where you use this string, and adding it makes it available in the UI, so it would be good to have that commit just before this one.
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 commits haven't been reordered for this yet. Do you understand what I was requesting?
32abc79
to
6b8e464
Compare
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.
Ran this code on my local machine and manually did a quick test. It works as per expectation 👍
I think it is good to start with writing tests for this PR.
I left feedback in the stream, so removing needs-review flag, unless there are specific concerns? |
ff683ca
to
2d76e2b
Compare
c84a30a
to
6f11bed
Compare
get_file_upld_uri takes a file location as an argument and returns the URI.
insert_uri method takes the file_name and the uri generated for that file as arguments. The added text is in the format [<file_name>](/<uri>). At last the cursor is shited after the end of the added text.
FileUploadView's base class is PopUpView. On keypress of `FILE_UPLOAD` it passes the value of the edit_text to _handle_file_upload function. The __init__ have write_box as a parameter which is used to insert the URI in the write_box.The PopUpView is closed on 'ESC' and the upload is done via 'ENTER'.
show_file_upload_view in core.py creates an instance of FileUploadView when called. It is called by `ctrl + o`keypress on WriteBox.
If a passed file location is invalid then the get_file_upload_uri returns None and using that the error and reported.
Test cases of different file formats.Firstly a initial edit text is set which is appended by calling append_uri_and_filename.
6f11bed
to
d6d8b90
Compare
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.
Thank you for implementing all the feedback and writing the tests. I tested the code on my local machine and it worked fine. I even tried uploading multiple files in the same message and that too worked without a glitch.
The tests seem fine to me. I think in the next iteration, apart from the minor suggestion, the commit structure can have the code block commits squashed together with their respective tests.
write_box.msg_write_box.edit_text = initial_edit_text | ||
|
||
write_box.append_uri_and_filename(file_name, uri) | ||
result_edit_text = write_box.msg_write_box.edit_text |
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.
Defining a variable can be avoided here as the value is being used only once.
self, mocker, model, file_name, upload_result, expected_result | ||
): | ||
self.client.upload_file = mocker.Mock(return_value=upload_result) | ||
with tempfile.TemporaryDirectory() as temp_dir: |
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.
Good thinking to use this.
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.
In principle I agree, and using something like this for testing the file existence is certainly a valid approach. One of the downsides is that it involves filesystem actions in each test.
We actually use a similar approach for testing some of run.py
right now, using some built-in fixtures that pytest provides. If we take this approach, it would likely be wise to use one of those.
An alternative approach would be to patch open - though that can be tricky since it is built-in - and set it to raise certain exceptions.
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.
@Subhasish-Behera Thanks for the updates and the tests 👍
This does work well, so I've focused more on the details here.
I agree with Mounil that squashing the appropriate test commits with the matching code would make this clearer, and likely also the two commits which build slightly on each other to wrap the upload.
Once your commits are squashed, I'd recommend checking their titles and any summary text, since many have not been updated while you've been working on the PR.
I'm looking forward to having this merged! :)
if os.path.exists(file_location): | ||
with open(file_location, "rb") as fp: |
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.
Note that os.path.exists
only checks that this is a valid path, not that it is a file or that it can be accessed by the active user.
There is also the slim possibility that we check it exists and then the file disappears before it is opened. The better pattern is therefore to try to open, and then handle any exceptions relevant to not being able to open the file, or the parent exception class of those we expect, and return None from there.
However, this function now returns None if there is a problem uploading, as well as if the file doesn't exist - the calling function (and user!) cannot distinguish between these cases. In the short term using display_error_if_present
for reporting any server error could enable a distinction there - though note that after reporting the error it would still return None, so trigger the same error handling in the caller.
For now we should certainly use a try/except around the open
to look for file issues via exception(s). You could experiment with display_error_if_present
, but duplicate error messages may look strange.
At a later point it may be clearer to handle only the uploading of an already-opened source file here, and handle other errors where this function is called, making the showing of different errors much simpler - including those arising from different exceptions when opening a proposed upload.
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.
Rereading this, it would definitely be better to use the exception approach here, and focus error handling for upload as occurring from the model, compared to error handling with accessing the file from the calling function.
It's possible there could be errors related to processing the upload related to the file handling, but that would be part of the upload process.
@@ -710,6 +710,14 @@ def autocomplete_emojis( | |||
|
|||
return emoji_typeahead, emojis | |||
|
|||
def append_uri_and_filename(self, file_name: str, uri: str) -> None: |
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.
Nit: The name of the function suggests that the first argument is the uri and the second the file_name.
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.
will change the order of the arguments.
file_name = file_path.name | ||
self.write_box.append_uri_and_filename(file_name, self.uri) | ||
else: | ||
self.controller.report_error(["ERROR: Unable to get the URI"]) |
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.
This error is triggered if the file to upload is the empty string, ie. just pressing enter
with no contents in the box. As a user I'd expect that to be equivalent to exiting the box - so it doesn't even try and upload.
That would fit with doing more error handling in this location, as per another comment, though this would be easier to do without changing the way the function is called.
zulipterminal/ui_tools/boxes.py
Outdated
edit_widget = self.contents[self.FOCUS_CONTAINER_MESSAGE][ | ||
self.FOCUS_MESSAGE_BOX_BODY | ||
] | ||
edit_widget.edit_text += f"[{file_name}](/{str(uri)})" |
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 upload works now, but the str
conversion remains - is there a reason for this?
zulipterminal/ui_tools/views.py
Outdated
self.write_box.insert_uri(file_name, self.uri) | ||
|
||
def keypress(self, size: urwid_Size, key: str) -> str: | ||
if is_command_key("FILE_UPLOAD", key): |
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 commits haven't been reordered for this yet. Do you understand what I was requesting?
file_name, self.file_upload_view.uri | ||
) | ||
else: | ||
self.controller.append_uri_and_filename.assert_not_called() |
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.
This doesn't exist? It likely still passes due to the perils of Mock without spec.
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.
Got it. will remove it.
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.
If you do wish to assert on the mocked details, then my point here was more that the object you're asserting on does not exist - and is different to that a few lines above.
|
||
self.controller.model.get_file_upload_uri.assert_called_once_with(file_location) | ||
if not expected_error_message: | ||
file_name = Path(file_location).name |
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.
This file_name would be good as a test (case) parameter. It may look like a duplicate, but you're processing the result explicitly here instead, and it's clearer as a parameter to the test.
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.
Cool. I think you are suggesting this
def test__handle_file_upload(
self,
file_location: str,
file_name: str,
expected_uri: str,
expected_error_message: Optional[str],
) -> None:
and directly use the file_name
parameter.
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.
Right, the principle is that ideally a test is as simple as possible - less logic and calculation - and the test cases handle the input and corresponding expected output (expected_*
parameters).
), | ||
case( | ||
"existing_file.txt", | ||
{"result": "failure", "error_message": "Upload failed"}, |
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.
This matches to the code we have (result is not success), but I'm unsure it's the actual form that we would get from the API in the case of an error, which we should be using - at a bare minimum for the value of result
. Does it currently match?
self, mocker, model, file_name, upload_result, expected_result | ||
): | ||
self.client.upload_file = mocker.Mock(return_value=upload_result) | ||
with tempfile.TemporaryDirectory() as temp_dir: |
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.
In principle I agree, and using something like this for testing the file existence is certainly a valid approach. One of the downsides is that it involves filesystem actions in each test.
We actually use a similar approach for testing some of run.py
right now, using some built-in fixtures that pytest provides. If we take this approach, it would likely be wise to use one of those.
An alternative approach would be to patch open - though that can be tricky since it is built-in - and set it to raise certain exceptions.
"file_name, upload_result, expected_result", | ||
[ | ||
case( | ||
"existing_file.txt", |
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.
I don't see that we gain from using different file_name
strings, when this variable is passed in as it is each time. Instead I'd suggest using a fixed string - if we continue to set a filename to create - and then using an alternative parameter here which may have a different path. That would allow using the same branch of the conditional each time, and using this new parameter to select the file that is generated or not.
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.
I probably thought along the lines of testing the code for different file types like pdf
and txt
.
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.
I think you are suggesting that file_name
doesn't help in testing that.I will change as you said.
But should ZT-Client's code test if different file formats work?
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.
I agree that this illustrates that various file names should be acceptable to the function. However, how many would we include to show that? Also, file extensions do not necessarily correspond to file types - and I suspect the client/server accepts arbitrary files anyhow? So, if we want to demonstrate that, we could include a fixed string in the test - or as a default value to a test parameter - which looks fairly random.
In case it needs clarifying, using a mock for client.upload
means that only ZT code sees this file in any case - it doesn't touch the actual Zulip.Client
, and of course not any server.
I mentioned using a path parameter combined with a fixed file_name, since you're not testing eg. ./file_name
, path_to/file_name
or similar vs just file_name
. The file_name
could be a constant, with the path varying in the test cases.
Firstly the TestWriteBox is imported as unlike other popups FileUploadView takes a argument write_box. A fixture write_box is created which creates a instance of TestWriteBox and returns its write_box fixture. There are two cases of keypress test- one for keys which dosent make the popup exit and the other for key that exits the popup. There are 4 testcases for _handle_file_upload. The tests are based on if the return of get_file_upload_uri is None or a valid uri. On that basis the errors/append_func are called or not is asserted.
There are three tests based on upload result and existence of file. tempfile is used to create and write file in case of existing files and in csase of non-existing files a invalid temp_file_path is created.
d6d8b90
to
5d1b7e4
Compare
Heads up @Subhasish-Behera, we just merged some commits that conflict with the changes you made in this pull request! You can review this repository's recent commits to see where the conflicts occur. Please rebase your feature branch against the |
What does this PR do, and why?
External discussion & connections
File Upload #T529 #T1414
How did you test this?
Self-review checklist for each commit
Visual changes