Skip to content

Search_for_wildcards function updated to add @DATETO@ functionality #510

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

Open
wants to merge 10 commits into
base: main
Choose a base branch
from

Conversation

Diya910
Copy link

@Diya910 Diya910 commented Apr 13, 2025

Before submitting a pull request (PR), please read the contributing guide.

Please fill out as much of this template as you can, but if you have any problems or questions, just leave a comment and we will help out :)

Description

What is this PR

  • Bug fix
  • [ yes] Addition of a new feature
  • Other

Why is this PR needed?
This PR introduces a new @Dateto@ wildcard that enables users to search for folders based on a date range embedded in their names. This feature is especially useful when users want to transfer data recorded within a specific date range, without needing to create folders for every date in that range.

What does this PR do?
Implements @Dateto@ pattern recognition inside search_for_wildcards.

Uses get_values_from_bids_formatted_name to extract date-YYYYMMDD from folder names.

Filters the folders based on whether the date falls within the provided range.

References

#508

How has this PR been tested?

Created automated tests (test_date_search_range) using a simulated folder structure with date-YYYYMMDD format.

Verified that only folders within the specified date range are returned.

Confirmed that existing wildcard functionality remains unaffected.

Is this a breaking change?

No, this feature is additive and does not alter existing behavior.

Does this PR require an update to the documentation?

Yes. The documentation should be updated to mention the new @Dateto@ wildcard and its usage.

If any features have changed, or have been added. Please explain how the
documentation has been updated.

Checklist:

  • [ yes] The code has been tested locally
  • [ yes] Tests have been added to cover all new functionality
  • The documentation has been updated to reflect any changes
  • The code has been formatted with pre-commit

There are two minor mypy errors I couldn't fully resolve:
A type conflict involving the dummy Configs class used in tests — guidance from maintainers would help finalize this.
A type mismatch originating from an existing code path — this appears unrelated to the new functionality added.

@Diya910
Copy link
Author

Diya910 commented Apr 17, 2025

@adamltyson @JoeZiminski
Is there any update on the pull request. Your feedback will be really helpful.

@sumana-2705
Copy link
Contributor

Hello @Diya910,
The changes are looking great. The review process might be a little delayed since the team is currently a bit busy. In the meantime, it might be a good idea to take a look at the documentation as well, in the transfer_data.md file :)

@JoeZiminski
Copy link
Member

Hi @Diya910 so sorry for the delay in response! thanks a lot for this PR and the extensive tests. I'm still not back full time but will definitely have time to review this within the next two weeks. Thanks for your patience

Copy link
Member

@JoeZiminski JoeZiminski left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey @Diya910 thanks a lot for this, its a really nice implementation and is exactly what we need to do in this case. I have left a few comments on refactoring, this is because the introduced functionality can be aligned with some existing code to reduce duplication across the codebase. This requires some massaging of existing datashuttle code to make it a little more general so it can be called here. The suggestions also extend the implementation to handle the TIMETO and DATETIMETO case. For now I have not reviewed the tests as they might need changing after the refactor, but in general they look good and the attention to detail on testing is much appreciated.

Let me know if anything is not clear and if you have any questions or alternative ways to tackle this. Refactorings like those suggested can be a little fiddly. The linting / type checking will be useful when performing such refactorings. Of course, I'm happy to help wherever it would be useful. Thanks again for this contribution!

Just a reminder to myself, we will also need to add documentation for this new functionality.

name = name.replace(canonical_tags.tags("*"), "*")

matching_names: List[str]
if canonical_tags.tags("*") in name or "@DATETO@" in name:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can split this case, I think at present if there is both a wildcard and date in the name it will overwrite search_str generated with search_str = name.replace(canonical_tags.tags("*"), "*") with search_str = re.sub(r"\d{8}@DATETO@\d{8}", "date-*", name)

search_str = name
if canonical_tags.tags("*") in name or "@DATETO@" in name:
    search_str = search_str = name.replace(canonical_tags.tags("*"), "*")
    
if @DATETO@ in search_str:
... date replacement code

This is not very nice as it is constantly mutating search-string which can be difficult to debug. However, I think the problem at hand calls for this and it is the neatest way to indication the intention. We can leave a comment to explain.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reorganized the code to handle wildcards and datetime tags separately

if canonical_tags.tags("*") in name or "@DATETO@" in name:
search_str = name.replace(canonical_tags.tags("*"), "*")
# If a date-range tag is present, extract dates and update the search string.
if "@DATETO@" in name:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We have a canonical tags.tags() function that contains all the tags (just in case we change them or some other problem that requires their editing arises). So @DATETO@, @TIMETO@ and @DATETIMETO@ could be added to that function and here @DATETO@ replaced with tags.tags("DATETO")

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added DATETO, TIMETO, and DATETIMETO to canonical_tags.py and using them through tags()

search_str = name.replace(canonical_tags.tags("*"), "*")
# If a date-range tag is present, extract dates and update the search string.
if "@DATETO@" in name:
m = re.search(r"(\d{8})@DATETO@(\d{8})", name)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is nice, we have some validation code for ISO formats here however this function works on a list of names, and returns in a slightly strange format (which makes sense in the context of the validation functions).

I think to centralise this we can do a few refactorings:

Move this code block:

    formats = {
        "datetime": "%Y%m%dT%H%M%S",
        "time": "%H%M%S",
        "date": "%Y%m%d",
    }

to configs/canonical_tags and wrap it in a function like get_datetime_format based on the format.

Then this code in datetimes_in_iso_format can be factored into a separate function valdiate_datetime

        strfmt = formats[key]

        try:
            datetime.strptime(format_to_check, strfmt)
            error_message = []
        except ValueError:
            error_message = [get_datetime_error(key, name, strfmt, path_)]

except it can just return True / False and we can leave the error message stuff to datetimes_in_iso_format.

Now, we can call this new function from here. I think it is also worth having a quick function to get the expected number of values (8 for date) for use above, instead of hard-coding. This could be like:

def get_expected_num_datetime_values(format):
    format_str = get_datetime_format(format)
    today = datetime.now()
    return len(today.strftime(format_str)

We can then pass this "date", "time" or "datetime" to get the values.

This section would then look something like:

# somewhere need to check that @DATETO@, @TIMETO@ and @DATETIMETO@ are used exclusively
format = tag  = None
if tags.tag("DATETO") in search_str:
    format = "date"
    tag = tags.tag("DATETO") 
elif  tags.tag("TIMETO" in search_str:
    format = "time"
    tag = tags.tag("TIMETO") 
elif tags.tag("DATETIMETO") in search_str:
    format = "datetime"
    tag = tags.tag("DATETIMETO") 
    
expected_values =  get_expected_num_datetime_values(format)
full_tag_regex = fr"(\d{{{num_values}}}){re.escape(tag)}(\d{{{num_values}}})"
match = re.search(full_tag_regex , search_str)

if not match:
...raise (use utils. raise_error and raise a NeuroBlueprint error)

start_str, end_str = match.groups()

start_timepoint =datetime.strptime(start_str, get_datetime_format(format)")
end_timepoint =datetime.strptime(end, get_datetime_format(format)")
    
if not validate_datetime(start_timepoint, format):
... raise error

<same for end_timepoint>

search_str = re.sub(full_tag_regex , f{format}-*", search_str)

I think all of this could be isolated to a new function such that in this function we just have something like:

format = tag  = None
if tags.tag("DATETO") in search_str:
    format = "date"
    tag = tags.tag("DATETO") 
elif  tags.tag("TIMETO" in search_str:
    format = "time"
    tag = tags.tag("TIMETO") 
elif tags.tag("DATETIMETO") in search_str:
    format = "datetime"
    tag = tags.tag("DATETIMETO") 
    
search_str = format_and_validate_datetime_search_str(search_str, format, tag)

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moved datetime formats to canonical_tags.py and created get_datetime_format function for centralized access

)[0]

# If a date-range tag was provided, further filter the results.
if "@DATETO@" in name:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is nice, and at this point we know we have validated dates. Ideally the validation should happen immediately before the point of use but in this case, there is no point wasting time searching if the dates are not valid, so it makes sense to do it before. But it is worth leaving a comment to indicate we know the dates are valid at this stage.

# If a date-range tag was provided, further filter the results.
if "@DATETO@" in name:
filtered_names: List[str] = []
for candidate in matching_names:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is nice, to generalise it a bit more we can have:

if format is not None:
    assert tag is not None, "format and tag should be set together"
    
get_values_from_bids_formatted_name can use `format` and in the strptime call use the new `get_datetime_format ` function

values_list = get_values_from_bids_formatted_name(
[candidate_basename], "date"
)
if not values_list:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can assume this list is not empty because date-* was used to search the names already (?)

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed unnecessary empty list check since the search pattern ensures valid datetime values

except ValueError:
continue
if start_date <= candidate_date <= end_date:
filtered_names.append(candidate)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this entire block could be isolated in a new function filter list of names by datetime just for readability (and it might also be useful in future)

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Created filter_names_by_datetime_range() function

@JoeZiminski
Copy link
Member

JoeZiminski commented Jun 6, 2025

Hey @Diya910 do you think you would be interested in continuing to work on this PR? This is a great addition and it would be nice to release it in a version soon. I'm happy to finalise the PR as most of the work now is just refactoring into the existing codebase.

@Diya910
Copy link
Author

Diya910 commented Jun 6, 2025

Hey @Diya910 do you think you would be interested in continuing to work on this PR? This is a great addition and it would be nice to release it in a version soon. I'm happy to finalise the PR as most of the work now is just refactoring into the existing codebase.

Yes yes, I am interested. I was busy with my exams and other stuffs. Just allow me a day or two. I'll do the required changes suggested by you.

@JoeZiminski
Copy link
Member

Hey @Diya910 great! No rush BTW I was just checking in, please prioritise exams / other stuff / taking some time to recuperate after exams. I was thinking it might be nice to merge over the next few weeks (rather than next few days), thanks!

@Diya910
Copy link
Author

Diya910 commented Jun 6, 2025

Thanks, I'll try to work on it as soon as possible.

…ion of code my making functions in validation.py and using in search_with_tags feature in folders file
@Diya910
Copy link
Author

Diya910 commented Jun 15, 2025

Hey, @JoeZiminski I have probably done all the changes suggested by you and also centralized the code. I have also changed the test file with additional test functions, everything is working fine from side. If any other changes are required, please let me know. I will do them at the earliest.

@JoeZiminski JoeZiminski added this to the v2.8.0 milestone Jun 17, 2025
@JoeZiminski
Copy link
Member

Hi @Diya910 thanks a lot for this! Will review tomorrow

Copy link
Member

@JoeZiminski JoeZiminski left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey @Diya910 thanks for this, this is really great stuff. The code is very clean, this is going to make a great feature. I have left a few comments on the code, they just suggest some minor refactoring's to reduce code duplication where possible. For critical code, it makes sense to define the key parts only in once place, just in case they are changed later but the editor forgets to check for all places they are defined.

The tests are great for ensuring the features works well, I have suggested a refactoring here to use our existing testing machinery which I think should reduce some boilerplate, let me know if you have any questions about this. The tests will should probably test all three cases, dateto, timeto and datetimteto, happy to help with this.

I just pushed some fixes to the pre-commit on the CI which was failing, just some minor typing issues (see here for some detail on the pre-commit hooks). This should move on to the full test suite now.

Thanks again Diya this is nearly done! I just remembered we will also need to document this change, the contributing guide for this is here. It would make sense to add the new tags to this section. Happy to do this because the documentation can be a bit fiddly, but if you are interested in this please feel free to go ahead, let me know if you have any questions!

}
return tags[tag_name]


_DATETIME_FORMATS = {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great thanks for this. Can this be refacotred to be returned from a function rather than a dict with global scope e.g.:

def get_datetime_formats():
return {...}

The reason is that _DATETIME_FORMATS becomes a dictionary with global scope across the application, meaning if it is accidentally changed in one part of the code this will propagate everywhere. Wrapping it in a function means the scope is not longer globa.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Apologies I see what you did here, that's great. In this case, you can move _DATETIME_FORMATS directly into get_datetime_format

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great suggestion, I will move it into the function


key = next((key for key in formats if key in name), None)
key = next(
(key for key in ["datetime", "time", "date"] if key in name), None
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

here you can do datetime_keys = list(get_datetime_format().keys()) then

key for key in datetime_keys (just to avoid the re-definition of these keys))

Copy link
Author

@Diya910 Diya910 Jun 26, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

datetime_keys = list(canonical_tags.get_datetime_format())
key = next((key for key in datetime_keys if key in name), None)

Like this??

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh I see apologies for the confusion, the return value of get_datetime_format is slightly different to that I thought.

What do you think of changing the function to take no arguments and return the entire dictionary, and then it can be indexed (instead of called). For example format = get_datetime_format(format_type) becomes format = get_datatime_formats()[format_type].

Then above, the signature can be:

datetime_keys = list(canonical_tags.get_datetime_formats().keys())
key = next((key for key in datetime_keys if key in name), None)

This signature is now different to the tags() function, but it is probably a better design because it is more flexible. Now the datetime names are canonically defined in a central place, and we can grab them all or index them as we like. Later on tags() can be changed to follow the same design. Sorry this would be a bit of a pain to change all the calls in your code though. What do you think?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, that makes a lot of sense! I agree that returning the full dictionary from get_datetime_formats() would make the design cleaner and more flexible — especially for cases like this where we need all the keys. Happy to refactor the calls where needed. Also agree that aligning the design of tags() later on would help keep things consistent across the codebase. Will go ahead with this change!

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

great!

try:
datetime.strptime(format_to_check, strfmt)
error_message = []
if not validate_datetime(format_to_check, key):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I may be incorrect, I don't think this needs to be in a try catch block anymore? Would the below work? (The order of the conditional is also reversed to make the positive case first which is usually slightly more readable):

if validate_datetime(format_to_check, key):
    error_message = []
else:
    error_message = [
        get_datetime_error(
            key,
            name,
            canonical_tags.get_datetime_format(key),
            path_,
        )
    ]    

return error_message


return error_message


def validate_datetime(datetime_str: str, format_type: str) -> bool:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a good name but because there are a few similar functions it could be slightly more explicit e.g. datetime_value_str_is_iso_format()

return False


def get_expected_num_datetime_values(format_type: str) -> int:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because this function get_expected_num_datetime_values and format_and_validate_datetime_search_str are only used in folders.py I think it makes sense to move them there (they could be under a Datetime Tag section or something)

match
): # We know this is true because format_and_validate_datetime_search_str succeeded
start_str, end_str = match.groups()
start_timepoint = datetime.strptime(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These lines can also use the new datetime_object_from_string function

@@ -0,0 +1,217 @@
import glob
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a well thought out test script that puts a lot of emphasis on realistic tests which is excellent. I think on balance, here it would be easier to use some of the test functionality to actually make a project and some folders, then check these are found as expected. For example here.

The test code might look something like the below. The project fixture is inherited from the BaseTest class and automatically comes with set up and tear down. Thinking about it, you might as well test directly with project.transfer_custom and see that the correct folders are transferred. This will then check every cog in the machine:

class TestDateSearchRange(BaseTest):

def test_date_search_range(self, project)
        
       sub_names = ["a list of example subs to test"]
       ses_names = ["a list of example ses to test]  # it might actually be easier to test the ses and sub case separately

        test_utils.make_and_check_local_project_folders(
            project, "rawdata", subs, sessions,  ["behav", "ephys"]
        )

      project.upload_custom( some search strings)
      
      transerred_subjects = (project.get_central_path() / "rawdata).glob("*")

     # now check that the correct files have been transferred

     
    )

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

also see here

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When I am opening the first example, it is causing some disruption in the ui and i am not getting where you are pointing. Can you please see it? Can you share that again

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @Diya910, I think I accidentally copied from a commit, does this work?

it is the function test_wildcard_transfer in /tests/tests_integration/test_filesystem_transfer.py

assert found_dates == expected_dates


def test_simple_wildcard(temp_project_dir: Path):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can remove this function given this but if this checks an extra case we can keep of course

search_with_tags(cfg, base_folder, local_or_central, [pattern])
assert "Invalid" in str(exc_info.value)


Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Brilliant idea, this can be adjusted as suggested above but the core test is great

@Diya910
Copy link
Author

Diya910 commented Jul 2, 2025

Hey @JoeZiminski, I have done changes required by you. These were a lot of changes I am not able to reply to all of them individually. But I made sure to make changes suggested by you. I have tested the changes on draft test file and they are working fine. I haven't properly done work on test file. It was a lot for me to do in a go. Once you confirm these changes I'll move ahead in refactoring test file. I hope you are fine with it. If I missed any suggestion above just in case, please point out to that I'll make those changes.

Copy link
Member

@JoeZiminski JoeZiminski left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey @Diya910 this is great, definitely good to go bar some very minor suggestions. Most of these are minor github code suggestions so you can directly commit them.

Apologies, one of my suggestions was actually worse than what was already there 😅 around the walrus operator. Sorry for the inconvenience of having to revert this.

After these changes are integrated I will message @Akseli-Ilmanen to test this manually while the other tests are been written. Let me know if you have any questions as you refactor the tests. Thanks again!

Diya910 and others added 7 commits July 4, 2025 12:50
Co-authored-by: Joe Ziminski <55797454+JoeZiminski@users.noreply.github.com>
Co-authored-by: Joe Ziminski <55797454+JoeZiminski@users.noreply.github.com>
Co-authored-by: Joe Ziminski <55797454+JoeZiminski@users.noreply.github.com>
Co-authored-by: Joe Ziminski <55797454+JoeZiminski@users.noreply.github.com>
Co-authored-by: Joe Ziminski <55797454+JoeZiminski@users.noreply.github.com>
@Diya910
Copy link
Author

Diya910 commented Jul 4, 2025

@JoeZiminski I have done all the changes. Please have a look. I am not sure about if I have removed declarations the right way. Please let me know if you want me to change docstrings in any specific way. Thankyou

@JoeZiminski
Copy link
Member

Hey @Diya910 thanks for these changes, all looks great! Docstrings are formatted correctly, I think good to move on to tests now! Let me know if you have any questions about anything.

Currently the pre-commit test is failing, you can read some details on our tools here. Basically, you can do pre-commit install in the environment, then every time you commit it will run all the checks. Let me know if you have any questions about solving the issues, sometimes it is not clear. You can also do pre-commit run --all-files to run on the entire codebase (which you can do now to fix the issues). You can see the issues in the Checks section at the bottom of this page.

At some point I will merge the new main into your branch and fix the conflicts (I think this will be easier if I do it, as I know the changes that are coming in from main branch so will be easier to solve). Let me know a day that you will not be using the branch and I can do it then.

The final thing to do will be to add this feature to the documentation, see a guide here. If you would like to learn the sphinx documentation stack, please give it a go and ask me any questions you have. Otherwise, I'm happy to do it.

@Diya910
Copy link
Author

Diya910 commented Jul 4, 2025

@JoeZiminski I'll now move to making changes to test file. Once you approve them you can take the lead to fix pre-commit.ci error. I'll definitely like to add the documentation for this feature once all other things are done.

@JoeZiminski
Copy link
Member

Great! Thanks @Diya910 that sounds like a good plan

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Search within date range
3 participants