From 336385a026c54bd127f6f1610c9a8e3bada7db73 Mon Sep 17 00:00:00 2001 From: Dan Kapner Date: Thu, 25 Mar 2021 09:03:53 -0700 Subject: [PATCH 01/86] sets version and doc placeholders for new rc branch --- CHANGELOG.md | 3 +++ allensdk/__init__.py | 2 +- doc_template/index.rst | 5 +++++ 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ac9c2467..2958dca14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,9 @@ # Change Log All notable changes to this project will be documented in this file. +## [2.10.2] = TBD +- list updates here + ## [2.10.2] = 2021-03-25 - This version marks the release of Visual Behavior Optical Physiology data! For more details please visit the: [Visual Behavior - Optical Physiology Project Page](https://allensdk.readthedocs.io/en/latest/visual_behavior_optical_physiology.html) - update documentation to support visual behavior data release diff --git a/allensdk/__init__.py b/allensdk/__init__.py index c8c148ab9..47a4627b1 100644 --- a/allensdk/__init__.py +++ b/allensdk/__init__.py @@ -35,7 +35,7 @@ # import logging -__version__ = '2.10.2' +__version__ = '2.11.0' try: diff --git a/doc_template/index.rst b/doc_template/index.rst index b3043ed88..062b62409 100644 --- a/doc_template/index.rst +++ b/doc_template/index.rst @@ -119,6 +119,11 @@ The Allen SDK provides Python code for accessing experimental metadata along wit See the `mouse connectivity section `_ for more details. +What's New - 2.11.0 +----------------------------------------------------------------------- +- list updates here + + What's New - 2.10.2 ----------------------------------------------------------------------- - This version marks the release of Visual Behavior Optical Physiology data! For more details please visit the: `Visual Behavior - Optical Physiology Project Page `_ From f3a1fc48788cfb0a116b1dabbfefca4418a4d52c Mon Sep 17 00:00:00 2001 From: Nicholas Mei Date: Thu, 25 Mar 2021 14:45:15 -0700 Subject: [PATCH 02/86] Rename get_performance_metrics() dict fields Renames: - `auto_rewarded_trial_count` -> `auto_reward_count` - `rewarded_trial_count` -> `earned_reward_count` Docstring changes: - `earned_reward_count` field now has a new docstring Relates to: #2017 --- .../behavior/behavior_session.py | 17 ++++++++-------- ..._behavior_compare_across_trial_types.ipynb | 4 ++-- .../nb/visual_behavior_mouse_history.ipynb | 20 +++++++++---------- 3 files changed, 21 insertions(+), 20 deletions(-) diff --git a/allensdk/brain_observatory/behavior/behavior_session.py b/allensdk/brain_observatory/behavior/behavior_session.py index 4969a9391..6b2d60200 100644 --- a/allensdk/brain_observatory/behavior/behavior_session.py +++ b/allensdk/brain_observatory/behavior/behavior_session.py @@ -204,12 +204,13 @@ def get_performance_metrics( correct_reject_trial_count: Number of trials with a correct reject behavior response during a behavior session - auto_rewarded_trial_count: + auto_reward_count: Number of trials where the mouse received an auto reward of water. - rewarded_trial_count: - Number of trials on which the animal was rewarded for - licking in the response window. + earned_reward_count: + Number of trials where the mouse was eligible to receive a + water reward ('go' trials) and did receive an earned + water reward total_reward_count: Number of trials where the mouse received a water reward (earned or auto rewarded) @@ -260,14 +261,14 @@ def get_performance_metrics( self.trials.false_alarm.sum() performance_metrics['correct_reject_trial_count'] = \ self.trials.correct_reject.sum() - performance_metrics['auto_rewarded_trial_count'] = \ + performance_metrics['auto_reward_count'] = \ self.trials.auto_rewarded.sum() - # Although 'rewarded_trial_count' will currently have the same value as + # Although 'earned_reward_count' will currently have the same value as # 'hit_trial_count', in the future there may be variants of the # task where rewards are withheld. In that case the - # 'rewarded_trial_count' will be smaller than (and different from) + # 'earned_reward_count' will be smaller than (and different from) # the 'hit_trial_count'. - performance_metrics['rewarded_trial_count'] = self.trials.hit.sum() + performance_metrics['earned_reward_count'] = self.trials.hit.sum() performance_metrics['total_reward_count'] = len(self.rewards) performance_metrics['total_reward_volume'] = self.rewards.volume.sum() diff --git a/doc_template/examples_root/examples/nb/visual_behavior_compare_across_trial_types.ipynb b/doc_template/examples_root/examples/nb/visual_behavior_compare_across_trial_types.ipynb index 76801644b..0a46b1e40 100644 --- a/doc_template/examples_root/examples/nb/visual_behavior_compare_across_trial_types.ipynb +++ b/doc_template/examples_root/examples/nb/visual_behavior_compare_across_trial_types.ipynb @@ -490,8 +490,8 @@ " 'miss_trial_count': 174,\n", " 'false_alarm_trial_count': 2,\n", " 'correct_reject_trial_count': 41,\n", - " 'auto_rewarded_trial_count': 5,\n", - " 'rewarded_trial_count': 130,\n", + " 'auto_reward_count': 5,\n", + " 'earned_reward_count': 130,\n", " 'total_reward_count': 135,\n", " 'total_reward_volume': 0.935,\n", " 'maximum_reward_rate': 4.505086994064128,\n", diff --git a/doc_template/examples_root/examples/nb/visual_behavior_mouse_history.ipynb b/doc_template/examples_root/examples/nb/visual_behavior_mouse_history.ipynb index bfca41183..c2885f1b5 100644 --- a/doc_template/examples_root/examples/nb/visual_behavior_mouse_history.ipynb +++ b/doc_template/examples_root/examples/nb/visual_behavior_mouse_history.ipynb @@ -3560,8 +3560,8 @@ " 'miss_trial_count': 173,\n", " 'false_alarm_trial_count': 4,\n", " 'correct_reject_trial_count': 37,\n", - " 'auto_rewarded_trial_count': 5,\n", - " 'rewarded_trial_count': 112,\n", + " 'auto_reward_count': 5,\n", + " 'earned_reward_count': 112,\n", " 'total_reward_count': 117,\n", " 'total_reward_volume': 0.8090000000000003,\n", " 'maximum_reward_rate': 4.538096320942588,\n", @@ -3639,8 +3639,8 @@ " miss_trial_count\n", " false_alarm_trial_count\n", " correct_reject_trial_count\n", - " auto_rewarded_trial_count\n", - " rewarded_trial_count\n", + " auto_reward_count\n", + " earned_reward_count\n", " total_reward_count\n", " total_reward_volume\n", " maximum_reward_rate\n", @@ -3807,14 +3807,14 @@ "839565422 162 4 \n", "839912316 71 6 \n", "\n", - " correct_reject_trial_count auto_rewarded_trial_count \\\n", + " correct_reject_trial_count auto_reward_count \\\n", "837658854 0 118 \n", "838515247 7 5 \n", "839219841 60 29 \n", "839565422 37 15 \n", "839912316 14 10 \n", "\n", - " rewarded_trial_count total_reward_count total_reward_volume \\\n", + " earned_reward_count total_reward_count total_reward_volume \\\n", "837658854 0 118 0.590 \n", "838515247 106 111 1.085 \n", "839219841 48 77 0.625 \n", @@ -3926,8 +3926,8 @@ " miss_trial_count\n", " false_alarm_trial_count\n", " correct_reject_trial_count\n", - " auto_rewarded_trial_count\n", - " rewarded_trial_count\n", + " auto_reward_count\n", + " earned_reward_count\n", " total_reward_count\n", " total_reward_volume\n", " maximum_reward_rate\n", @@ -4330,7 +4330,7 @@ "839565422 162 4 \n", "839912316 71 6 \n", "\n", - " correct_reject_trial_count auto_rewarded_trial_count \\\n", + " correct_reject_trial_count auto_reward_count \\\n", "behavior_session_id \n", "837658854 0 118 \n", "838515247 7 5 \n", @@ -4338,7 +4338,7 @@ "839565422 37 15 \n", "839912316 14 10 \n", "\n", - " rewarded_trial_count total_reward_count \\\n", + " earned_reward_count total_reward_count \\\n", "behavior_session_id \n", "837658854 0 118 \n", "838515247 106 111 \n", From 8edd121213dfe9798e124455d4bfc8172ad1e334 Mon Sep 17 00:00:00 2001 From: Kate Roll Date: Thu, 25 Mar 2021 16:39:17 -0700 Subject: [PATCH 03/86] updated doc strings --- .../behavior/behavior_session.py | 244 ++++++++++++++---- 1 file changed, 199 insertions(+), 45 deletions(-) diff --git a/allensdk/brain_observatory/behavior/behavior_session.py b/allensdk/brain_observatory/behavior/behavior_session.py index 4969a9391..efec79498 100644 --- a/allensdk/brain_observatory/behavior/behavior_session.py +++ b/allensdk/brain_observatory/behavior/behavior_session.py @@ -135,30 +135,30 @@ def get_rolling_performance_df(self) -> pd.DataFrame: ------- pd.DataFrame A pandas DataFrame containing: - trials_id [index]: + trials_id [index]: (int) Index of the trial. All trials, including aborted trials, are assigned an index starting at 0 for the first trial. - reward_rate: + reward_rate: (float) Rewards earned in the previous 25 trials, normalized by the elapsed time of the same 25 trials. Units are rewards/minute. - hit_rate_raw: + hit_rate_raw: (float) Fraction of go trials where the mouse licked in the response window, calculated over the previous 100 non-aborted trials. Without trial count correction applied. - hit_rate: + hit_rate: (float) Fraction of go trials where the mouse licked in the response window, calculated over the previous 100 non-aborted trials. With trial count correction applied. - false_alarm_rate_raw: + false_alarm_rate_raw: (float) Fraction of catch trials where the mouse licked in the response window, calculated over the previous 100 non-aborted trials. Without trial count correction applied. - false_alarm_rate: + false_alarm_rate: (float) Fraction of catch trials where the mouse licked in the response window, calculated over the previous 100 non-aborted trials. Without trial count correction applied. - rolling_dprime: + rolling_dprime: (float) d prime calculated using the rolling hit_rate and rolling false_alarm _rate. @@ -185,68 +185,68 @@ def get_performance_metrics( ------- dict Returns a dict of performance metrics with the following fields: - trial_count: + trial_count: (int) The length of the trial dataframe (including all 'go', 'catch', and 'aborted' trials) - go_trial_count: + go_trial_count: (int) Number of 'go' trials in a behavior session - catch_trial_count: + catch_trial_count: (int) Number of 'catch' trial types during a behavior session - hit_trial_count: + hit_trial_count: (int) Number of trials with a hit behavior response type in a behavior session - miss_trial_count: + miss_trial_count: (int) Number of trials with a miss behavior response type in a behavior session - false_alarm_trial_count: + false_alarm_trial_count: (int) Number of trials where the mouse had a false alarm behavior response - correct_reject_trial_count: + correct_reject_trial_count: (int) Number of trials with a correct reject behavior response during a behavior session - auto_rewarded_trial_count: + auto_rewarded_trial_count: (int) Number of trials where the mouse received an auto reward of water. - rewarded_trial_count: + rewarded_trial_count: (int) Number of trials on which the animal was rewarded for licking in the response window. - total_reward_count: + total_reward_count: (int) Number of trials where the mouse received a water reward (earned or auto rewarded) - total_reward_volume: + total_reward_volume: (float) Volume of all water rewards received during a behavior session (earned and auto rewarded) - maximum_reward_rate: + maximum_reward_rate: (float) The peak of the rolling reward rate (rewards/minute) - engaged_trial_count: + engaged_trial_count: (int) Number of trials where the mouse is engaged (reward rate > 2 rewards/minute) - mean_hit_rate: + mean_hit_rate: (float) The mean of the rolling hit_rate mean_hit_rate_uncorrected: The mean of the rolling hit_rate_raw - mean_hit_rate_engaged: + mean_hit_rate_engaged: (float) The mean of the rolling hit_rate, excluding epochs when the rolling reward rate was below 2 rewards/minute - mean_false_alarm_rate: + mean_false_alarm_rate: (float) The mean of the rolling false_alarm_rate, excluding epochs when the rolling reward rate was below 2 rewards/minute - mean_false_alarm_rate_uncorrected: + mean_false_alarm_rate_uncorrected: (float) The mean of the rolling false_alarm_rate_raw - mean_false_alarm_rate_engaged: + mean_false_alarm_rate_engaged: (float) The mean of the rolling false_alarm_rate, excluding epochs when the rolling reward rate was below 2 rewards/minute - mean_dprime: + mean_dprime: (float) The mean of the rolling d_prime - mean_dprime_engaged: + mean_dprime_engaged: (float) The mean of the rolling d_prime, excluding epochs when the rolling reward rate was below 2 rewards/minute - max_dprime: + max_dprime: (float) The peak of the rolling d_prime - max_dprime_engaged: + max_dprime_engaged: (float) The peak of the rolling d_prime, excluding epochs when the rolling reward rate was below 2 rewards/minute """ @@ -305,7 +305,7 @@ def get_performance_metrics( @property def behavior_session_id(self) -> int: - """Unique identifier for this experimental session. + """Unique identifier for a behavioral session. :rtype: int """ return self._behavior_session_id @@ -323,6 +323,12 @@ def licks(self) -> pd.DataFrame: ------- np.ndarray A dataframe containing lick timestamps. + dataframe columns: + timestamps: (float) + time of lick, in seconds + frame: (int) + frame of lick + """ return self._licks @@ -343,6 +349,19 @@ def rewards(self) -> pd.DataFrame: ------- pd.DataFrame A dataframe containing timestamps of delivered rewards. + Timestamps are sampled at 60Hz. + + dataframe columns: + volume: (float) + volume of individual water reward in ml. + 0.007 if earned reward, 0.005 if auto reward. + timestamps: (float) + time in seconds + autorewarded: (bool) + True if free reward was delivered for that trial. + Occurs during the first 5 trials of a session and + throughout as needed + """ return self._rewards @@ -352,9 +371,9 @@ def rewards(self, value): @property def running_speed(self) -> pd.DataFrame: - """Get running speed data. By default applies a 10Hz low pass - filter to the data. To get the running speed without the filter, - use `raw_running_speed`. + """Running speed and timestamps, sampled at 60Hz. By default + applies a 10Hz low pass filter to the data. To get the + running speed without the filter, use `raw_running_speed`. NOTE: For BehaviorSessions, returned timestamps are not aligned to external 'synchronization' reference timestamps. @@ -364,8 +383,12 @@ def running_speed(self) -> pd.DataFrame: Returns ------- pd.DataFrame - Dataframe containing various signals used to compute running - speed, and the filtered speed. + Dataframe containing running speed and timestamps + dataframe columns: + timestamps: (float) + time in seconds + speed: (float) + speed in cm/sec """ return self._running_speed @@ -375,7 +398,7 @@ def running_speed(self, value): @property def raw_running_speed(self) -> pd.DataFrame: - """Get unfiltered running speed data. + """Get unfiltered running speed data. Sampled at 60Hz. NOTE: For BehaviorSessions, returned timestamps are not aligned to external 'synchronization' reference timestamps. @@ -385,8 +408,12 @@ def raw_running_speed(self) -> pd.DataFrame: Returns ------- pd.DataFrame - Dataframe containing various signals used to compute running - speed, and the unfiltered speed. + Dataframe containing unfiltered running speed and timestamps + dataframe columns: + timestamps: (float) + time in seconds + speed: (float) + speed in cm/sec """ return self._raw_running_speed @@ -406,6 +433,32 @@ def stimulus_presentations(self) -> pd.DataFrame: Table whose rows are stimulus presentations (i.e. a given image, for a given duration, typically 250 ms) and whose columns are presentation characteristics. + + dataframe columns: + stimulus_presentations_id [index]: (int) + identifier for a stimulus presentation + (presentation of an image) + duration: (float) + duration of an image presentation (flash) + in seconds (stop_time - start_time). NaN if omitted + end_frame: (float) + image presentation end frame + image_index: (int) + image index (0-7) for a given session, + corresponding to each image name + image_set: (string) + image set for this behavior session + index: (int) + an index assigned to each stimulus presentation + omitted: (bool) + True if no image was shown for this stimulus + presentation + start_frame: (int) + image presentation start frame + start_time: (float) + image presentation start time in seconds + stop_time: (float) + image presentation end time in seconds """ return self._stimulus_presentations @@ -421,8 +474,17 @@ def stimulus_templates(self) -> pd.DataFrame: ------- pd.DataFrame A pandas DataFrame object containing the stimulus images for the - experiment. Indices are image names, 'warped' and 'unwarped' - columns contains image arrays, and the df.name is the image set. + experiment. + + dataframe columns: + image_name [index]: (string) + name of image presented, if 'omitted' + then no image was presented + unwarped: (array of int) + image array of unwarped stimulus image + warped: (array of int) + image array of warped stimulus image + """ return self._stimulus_templates.to_dataframe() @@ -432,7 +494,8 @@ def stimulus_templates(self, value): @property def stimulus_timestamps(self) -> np.ndarray: - """Get stimulus timestamps from pkl file. + """Timestamps associated with the stimulus presetntation on + the monitor retrieved from pkl file. Sampled at 60Hz. NOTE: For BehaviorSessions, returned timestamps are not aligned to external 'synchronization' reference timestamps. @@ -508,8 +571,65 @@ def trials(self) -> pd.DataFrame: Returns ------- pd.DataFrame - A dataframe containing behavioral trial start/stop times, - and trial data + A dataframe containing trial and behavioral response data, + by cell specimen id + + dataframe columns: + trials_id: (int) + trial identifier + lick_times: (array of float) + array of lick times in seconds during that trial. + Empty array if no licks occured during the trial. + reward_time: (NaN or float) + Time the reward is delivered following a correct + response or on auto rewarded trials. + reward_volume: (float) + volume of reward in ml. 0.005 for auto reward + 0.007 for earned reward + hit: (bool) + Behavior response type. On catch trial mouse licks + within reward window. + false_alarm: (bool) + Behavior response type. On catch trial mouse licks + within reward window. + miss: (bool) + Behavior response type. On a go trial, mouse either + does not lick at all, or licks after reward window + stimulus_change: (bool) + True if an image change occurs during the trial + (if the trial was both a 'go' trial and the trial + was not aborted) + aborted: (bool) + Behavior response type. True if the mouse licks + before the scheduled change time. + go: (bool) + Trial type. True if there was a change in stimulus + image identity on this trial + catch: (bool) + Trial type. True if there was not a change in stimulus + identity on this trial + auto_rewarded: (bool) + True if free reward was delivered for that trial. + Occurs during the first 5 trials of a session and + throughout as needed. + correct_reject: (bool) + Behavior response type. On a catch trial, mouse + either does not lick at all or licks after reward + window + start_time: (float) + start time of the trial in seconds + stop_time: (float) + end time of the trial in seconds + trial_length: (float) + duration of trial in seconds (stop_time -start_time) + response_time: (float) + time of first lick in trial in seconds and NaN if + trial aborted + initial_image_name: (string) + name of image presented at start of trial + change_image_name: (string) + name of image that is changed to at the change time, + on go trials """ return self._trials @@ -519,8 +639,42 @@ def trials(self, value): @property def metadata(self) -> Dict[str, Any]: - """Return metadata about the session. - :rtype: dict + """metadata for a give session + + Returns + ------- + Dict + A dictionary containing behavior session specific metadata + dictionary keys: + age_in_days: (int) + age of mouse in days + behavior_session_uuid: (int) + unique identifier for a behavior session + behavior_session_id: (int) + unique identifier for a behavior session + cre_line: (string) + cre driver line for a transgenic mouse + date_of_acquisition: (date time object) + date and time of experiment acquisition, + yyyy-mm-dd hh:mm:ss + driver_line: (list of string) + all driver lines for a transgenic mouse + equipment_name: (string) + identifier for equipment data was collected on + full_genotype: (string) + full genotype of transgenic mouse + mouse_id: (int) + unique identifier for a mouse + reporter_line: (string) + reporter line for a transgenic mouse + session_type: (string) + visual stimulus type displayed during behavior + session + sex: (string) + sex of the mouse + stimulus_frame_rate: (float) + frame rate (Hz) at which the visual stimulus is + displayed """ if isinstance(self._metadata, BehaviorMetadata): metadata = self._metadata.to_dict() From 95f4ca5763d98f071055187e88839d395821d2e6 Mon Sep 17 00:00:00 2001 From: Kate Roll Date: Thu, 25 Mar 2021 17:44:49 -0700 Subject: [PATCH 04/86] updated doc strings --- .../behavior/behavior_ophys_experiment.py | 161 +++++++++++++++--- .../behavior/behavior_session.py | 2 +- 2 files changed, 135 insertions(+), 28 deletions(-) diff --git a/allensdk/brain_observatory/behavior/behavior_ophys_experiment.py b/allensdk/brain_observatory/behavior/behavior_ophys_experiment.py index c5286c8f0..413dc5af2 100644 --- a/allensdk/brain_observatory/behavior/behavior_ophys_experiment.py +++ b/allensdk/brain_observatory/behavior/behavior_ophys_experiment.py @@ -108,13 +108,13 @@ def from_nwb_path( # ========================= 'get' methods ========================== def get_segmentation_mask_image(self): - """ Returns an image with value 1 if the pixel was included - in an ROI, and 0 otherwise + """a 2D binary image of all valid cell masks Returns ---------- allensdk.brain_observatory.behavior.image_api.Image: - array-like interface to segmentation_mask image data and metadata + array-like interface to segmentation_mask image data and + metadata """ mask_data = np.sum(self.roi_masks['roi_mask'].values).astype(int) @@ -203,8 +203,25 @@ def ophys_timestamps(self, value): @property def dff_traces(self) -> pd.DataFrame: - """Traces of dff organized into a dataframe; index is the cell roi ids. - :rtype: pandas.DataFrame + """traces of change in fluoescence / fluorescence + + Returns + ------- + pd.DataFrame + dataframe of traces of dff + (change in fluorescence / fluorescence) + + dataframe columns: + cell_specimen_id [index]: (int) + unified id of segmented cell across experiments + assigned after cell matching + cell_roi_id: (int) + experiment specific id of segmented roi, + assigned before cell matching + dff: (list of float) + fluorescence fractional values relative to baseline + (arbitrary units) + """ return self._dff_traces @@ -214,19 +231,35 @@ def dff_traces(self, value): @property def events(self) -> pd.DataFrame: - """Get event detection data + """A dataframe containing spiking events in traces derived + from the two photon movies, organized by cell specimen id. + For more information on event detection processing + please see the event detection portion of the white paper. Returns ------- pd.DataFrame - index: - cell_specimen_id: int - cell_roi_id: int - events: np.array - filtered_events: np.array - Events, convolved with filter to smooth it for visualization - lambdas: float64 - noise_stds: float64 + cell_specimen_id [index]: (int) + unified id of segmented cell across experiments + (assigned after cell matching) + cell_roi_id: (int) + experiment specific id of segmented roi (assigned + before cell matching) + events: (np.array of float) + event trace where events correspond to the rise time + of a calcium transient in the dF/F trace, with a + magnitude roughly proportional the magnitude of the + increase in dF/F. + filtered_events: (np.array of float) + Events array with a 1d causal half-gaussian filter to + smooth it for visualization. Uses a halfnorm + distribution as weights to the filter + lambdas: (float64) + regularization value selected to make the minimum + event size be close to N * noise_std + noise_stds: (float64) + estimated noise standard deviation for the events trace + """ params = {'events_filter_scale', 'events_filter_n_time_steps'} @@ -245,9 +278,47 @@ def events(self, value): @property def cell_specimen_table(self) -> pd.DataFrame: - """Cell roi information organized into a dataframe; index is the cell - roi ids. - :rtype: pandas.DataFrame + """Cell information organized into a dataframe. Table only + contains roi_valid = True entries, as invalid ROIs/ non cell + segmented objects have been filtered out + + Returns + ------- + pd.DataFrame + dataframe columns: + cell_specimen_id [index]: (int) + unified id of segmented cell across experiments + (assigned after cell matching) + cell_roi_id: (int) + experiment specific id of segmented roi + (assigned before cell matching) + height: (int) + height of ROI/cell in pixels + mask_image_plane: (int) + which image plane an ROI resides on. Overlapping + ROIs are stored on different mask image planes + max_corretion_down: (float) + max motion correction in down direction in pixels + max_correction_left: (float) + max motion correction in left direction in pixels + max_correction_right: (float) + max motion correction in right direction in pixels + max_correction_up: (float) + max motion correction in up direction in pixels + roi_mask: (array of bool) + an image array that displays the location of the + roi mask in the field of view + valid_roi: (bool) + indicates if cell classification found the segmented + ROI to be a cell or not (True = cell, False = not cell). + width: (int) + width of ROI in pixels + x: (float) + x position of ROI in field of view in pixels (top + left corner) + y: (float) + y position of ROI in field of view in pixels (top + left corner) """ return self._cell_specimen_table @@ -257,9 +328,26 @@ def cell_specimen_table(self, value): @property def corrected_fluorescence_traces(self) -> pd.DataFrame: - """The motion-corrected fluorescence traces organized into a dataframe; - index is the cell roi ids. - :rtype: pandas.DataFrame + """Corrected fluorescence traces which are neuropil corrected + and demixed. Sampling rate can be found in metadata + ‘ophys_frame_rate’ + + Returns + ------- + pd.DataFrame + Dataframe that contains the corrected fluorescence traces + for all valid cells. + + dataframe columns: + cell_specimen_id [index]: (int) + unified id of segmented cell across experiments + (assigned after cell matching) + cell_roi_id: (int) + experiment specific id of segmented roi + (assigned before cell matching) + corrected_fluorescence: (list of float) + fluorescence values (arbitrary units) + """ return self._corrected_fluorescence_traces @@ -269,9 +357,17 @@ def corrected_fluorescence_traces(self, value): @property def motion_correction(self) -> pd.DataFrame: - """A dataframe containing trace data used during motion correction - computation - :rtype: pandas.DataFrame + """a dataframe containing the x and y offsets applied during + motion correction + + Returns + ------- + pd.DataFrame + dataframe columns: + x: (int) + frame shift along x axis + y: (int) + frame shift along y axis """ return self._motion_correction @@ -281,8 +377,7 @@ def motion_correction(self, value): @property def segmentation_mask_image(self) -> Image: - """An image with pixel value 1 if that pixel was included in an ROI, - and 0 otherwise + """A 2d binary image of all valid cell masks :rtype: allensdk.brain_observatory.behavior.image_api.Image """ if self._segmentation_mask_image is None: @@ -346,8 +441,20 @@ def eye_tracking(self, value): @property def eye_tracking_rig_geometry(self) -> dict: - """Get the eye tracking rig geometry - associated with an ophys experiment""" + """the eye tracking equipment geometry associate with a + given ophys experiment session. + + Returns + ------- + dict + dictionary with the following keys: + camera_eye_position_mm (array of float) + camera_rotation_deg (array of float) + equipment (string) + led_position (array of float) + monitor_position_mm (array of float) + monitor_rotation_deg (array of float) + """ return self.api.get_eye_tracking_rig_geometry() @property diff --git a/allensdk/brain_observatory/behavior/behavior_session.py b/allensdk/brain_observatory/behavior/behavior_session.py index efec79498..691557a33 100644 --- a/allensdk/brain_observatory/behavior/behavior_session.py +++ b/allensdk/brain_observatory/behavior/behavior_session.py @@ -655,7 +655,7 @@ def metadata(self) -> Dict[str, Any]: cre_line: (string) cre driver line for a transgenic mouse date_of_acquisition: (date time object) - date and time of experiment acquisition, + date and time of experiment acquisition, yyyy-mm-dd hh:mm:ss driver_line: (list of string) all driver lines for a transgenic mouse From 85f7920926457405739caa9567d7fde3aa9e2bdc Mon Sep 17 00:00:00 2001 From: Kate Roll Date: Fri, 26 Mar 2021 12:28:08 -0700 Subject: [PATCH 05/86] line too long lint --- .../brain_observatory/behavior/behavior_ophys_experiment.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/allensdk/brain_observatory/behavior/behavior_ophys_experiment.py b/allensdk/brain_observatory/behavior/behavior_ophys_experiment.py index 413dc5af2..e6428a6cd 100644 --- a/allensdk/brain_observatory/behavior/behavior_ophys_experiment.py +++ b/allensdk/brain_observatory/behavior/behavior_ophys_experiment.py @@ -99,7 +99,7 @@ def from_lims(cls, ophys_experiment_id: int, @classmethod def from_nwb_path( - cls, nwb_path: str, **api_kwargs: Any) -> "BehaviorOphysExperiment": + cls, nwb_path: str, **api_kwargs: Any) -> "BehaviorOphysExperiment": # noqa: E501 api_kwargs["filter_invalid_rois"] = api_kwargs.get( "filter_invalid_rois", True) return cls(api=BehaviorOphysNwbApi.from_path( From 766c2edcd6a08434cb811bc1e930ad6b117feeba Mon Sep 17 00:00:00 2001 From: Kate Roll Date: Fri, 26 Mar 2021 12:47:45 -0700 Subject: [PATCH 06/86] removes references to pkl file in docstrings removed reference to pkl file as it is not important to external users. Replace with "data file saved at the end of the behavior session" --- .../behavior/behavior_session.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/allensdk/brain_observatory/behavior/behavior_session.py b/allensdk/brain_observatory/behavior/behavior_session.py index 691557a33..ede516462 100644 --- a/allensdk/brain_observatory/behavior/behavior_session.py +++ b/allensdk/brain_observatory/behavior/behavior_session.py @@ -312,7 +312,8 @@ def behavior_session_id(self) -> int: @property def licks(self) -> pd.DataFrame: - """Get lick data from pkl file. + """A dataframe containing lick timestmaps and frames, sampled + at 60 Hz. NOTE: For BehaviorSessions, returned timestamps are not aligned to external 'synchronization' reference timestamps. @@ -338,7 +339,8 @@ def licks(self, value): @property def rewards(self) -> pd.DataFrame: - """Get reward data from pkl file. + """Retrieves rewards from data file saved at the end of the + behavior session. NOTE: For BehaviorSessions, returned timestamps are not aligned to external 'synchronization' reference timestamps. @@ -495,7 +497,8 @@ def stimulus_templates(self, value): @property def stimulus_timestamps(self) -> np.ndarray: """Timestamps associated with the stimulus presetntation on - the monitor retrieved from pkl file. Sampled at 60Hz. + the monitor retrieveddata file saved at the end of the + behavior session. Sampled at 60Hz. NOTE: For BehaviorSessions, returned timestamps are not aligned to external 'synchronization' reference timestamps. @@ -515,7 +518,8 @@ def stimulus_timestamps(self, value): @property def task_parameters(self) -> dict: - """Get task parameters from pkl file. + """Get task parameters from data file saved at the end of + the behavior session file. Returns ------- @@ -566,7 +570,8 @@ def task_parameters(self, value): @property def trials(self) -> pd.DataFrame: - """Get trials from pkl file + """Get trials from data file saved at the end of the + behavior session. Returns ------- From 8853fc05d5798174ff833e148d247d538bac9fb0 Mon Sep 17 00:00:00 2001 From: Dan Kapner Date: Mon, 29 Mar 2021 09:25:10 -0700 Subject: [PATCH 07/86] adds deprecation warning to trace_extraction module --- .../brain_observatory/ophys/trace_extraction/__init__.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/allensdk/brain_observatory/ophys/trace_extraction/__init__.py b/allensdk/brain_observatory/ophys/trace_extraction/__init__.py index e69de29bb..1a3694070 100644 --- a/allensdk/brain_observatory/ophys/trace_extraction/__init__.py +++ b/allensdk/brain_observatory/ophys/trace_extraction/__init__.py @@ -0,0 +1,8 @@ +import warnings + +warnings.warn("trace_extraction functionality has been moved from AllenSDK " + "to https://github.com/AllenInstitute/ophys_etl_pipelines ." + "The functionality in this AllenSDK package will be removed " + "in v3.0.0.", + category=DeprecationWarning, + stacklevel=2) From 91a05aa8056a1dca4afe999d0b3d23eb8d90080d Mon Sep 17 00:00:00 2001 From: Adam Amster Date: Mon, 29 Mar 2021 13:04:43 -0400 Subject: [PATCH 08/86] Updates argschema version --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 35e05dcca..9f5da6185 100644 --- a/requirements.txt +++ b/requirements.txt @@ -15,7 +15,7 @@ scikit-image>=0.14.0,<0.17.0 scikit-build<1.0.0 statsmodels==0.9.0 simpleitk<2.0.0 -argschema<2.0.0 +argschema==2.0.2 marshmallow==3.0.0rc6 glymur==0.8.19 xarray<0.16.0 From 63aab1038b94b6ce27d0b6b69ec48dedfe7466cc Mon Sep 17 00:00:00 2001 From: aamster Date: Tue, 30 Mar 2021 15:01:06 -0700 Subject: [PATCH 09/86] fixes issue with parser.output --- allensdk/brain_observatory/argschema_utilities.py | 4 +++- .../brain_observatory/ecephys/stimulus_table/__main__.py | 9 +++------ 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/allensdk/brain_observatory/argschema_utilities.py b/allensdk/brain_observatory/argschema_utilities.py index 2f1aaff0a..f50522c61 100644 --- a/allensdk/brain_observatory/argschema_utilities.py +++ b/allensdk/brain_observatory/argschema_utilities.py @@ -1,3 +1,4 @@ +import json import os import pathlib @@ -41,7 +42,8 @@ def _validate(self, value: pathlib.Path): def write_or_print_outputs(data, parser): data.update({'input_parameters': parser.args}) if 'output_json' in parser.args: - parser.output(data, indent=2) + with open(parser.args['output_json'], 'w') as f: + f.write(json.dumps(data, indent=2)) else: print(parser.get_output_json(data)) diff --git a/allensdk/brain_observatory/ecephys/stimulus_table/__main__.py b/allensdk/brain_observatory/ecephys/stimulus_table/__main__.py index bbd2f9430..b7db75cae 100644 --- a/allensdk/brain_observatory/ecephys/stimulus_table/__main__.py +++ b/allensdk/brain_observatory/ecephys/stimulus_table/__main__.py @@ -8,7 +8,8 @@ from allensdk.brain_observatory.ecephys.file_io.ecephys_sync_dataset import ( EcephysSyncDataset, ) -from allensdk.brain_observatory.argschema_utilities import ArgSchemaParserPlus +from allensdk.brain_observatory.argschema_utilities import ArgSchemaParserPlus, \ + write_or_print_outputs from allensdk.brain_observatory.ecephys.file_io.stim_file import ( CamStimOnePickleStimFile, ) @@ -95,11 +96,7 @@ def main(): ) output = build_stimulus_table(**mod.args) - output.update({"input_parameters": mod.args}) - if "output_json" in mod.args: - mod.output(output, indent=2) - else: - print(mod.get_output_json(output)) + write_or_print_outputs(data=output, parser=mod) if __name__ == "__main__": From 5d0972a7cbc87c40e9c87f538ce8597a21af79ba Mon Sep 17 00:00:00 2001 From: Doug Ollerenshaw Date: Wed, 31 Mar 2021 10:22:40 -0700 Subject: [PATCH 10/86] add arguments to make sync warnings optional --- allensdk/brain_observatory/sync_dataset.py | 11 +++++++---- allensdk/internal/brain_observatory/time_sync.py | 5 +++-- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/allensdk/brain_observatory/sync_dataset.py b/allensdk/brain_observatory/sync_dataset.py index 8aeec75e2..17284555b 100644 --- a/allensdk/brain_observatory/sync_dataset.py +++ b/allensdk/brain_observatory/sync_dataset.py @@ -104,14 +104,17 @@ class Dataset(object): "cam1_exposure", "behavior_monitoring"} - def __init__(self, path): + def __init__(self, path, sync_line_label_deprecation_warning=False): + ''' + sync_line_label_deprecation_warning: (bool) if True, warns about deprecated lines in sync file + ''' self.dfile = self.load(path) - self._check_line_labels() + self._check_line_labels(sync_line_label_deprecation_warning=sync_line_label_deprecation_warning) - def _check_line_labels(self): + def _check_line_labels(self, sync_line_label_deprecation_warning): if hasattr(self, "line_labels"): deprecated_keys = set(self.line_labels) & self.DEPRECATED_KEYS - if deprecated_keys: + if deprecated_keys and sync_line_label_deprecation_warning: warnings.warn((f"The loaded sync file contains the " f"following deprecated line label keys: " f"{deprecated_keys}. Consider updating the sync " diff --git a/allensdk/internal/brain_observatory/time_sync.py b/allensdk/internal/brain_observatory/time_sync.py index 3657e2a98..1556b0cc5 100644 --- a/allensdk/internal/brain_observatory/time_sync.py +++ b/allensdk/internal/brain_observatory/time_sync.py @@ -19,13 +19,14 @@ MAX_MONITOR_DELAY = 0.07 # seconds -def get_keys(sync_dset: Dataset) -> dict: +def get_keys(sync_dset: Dataset, invalid_sync_line_warning=False) -> dict: """ Gets the correct keys for the sync file by searching the sync file line labels. Removes key from the dictionary if it is not in the sync dataset line labels. Args: sync_dset: The sync dataset to search for keys within + invalid_sync_line_warning: (bool) if True, prints warnings about keys in sync file Returns: key_dict: dictionary of key value pairs for finding data in the @@ -55,7 +56,7 @@ def get_keys(sync_dset: Dataset) -> dict: key_dict[key] = diff.pop() else: remove_keys.append(key) - if len(remove_keys) > 0: + if len(remove_keys) > 0 and invalid_sync_line_warning: logging.warning("Could not find valid lines for the following data " "sources") for key in remove_keys: From 79ce0765ed80768feed39bfcd7baf38d92595201 Mon Sep 17 00:00:00 2001 From: Doug Ollerenshaw Date: Wed, 31 Mar 2021 11:02:54 -0700 Subject: [PATCH 11/86] ticket 1502 deal with lint errors --- allensdk/brain_observatory/sync_dataset.py | 64 +++++++++++++--------- 1 file changed, 38 insertions(+), 26 deletions(-) diff --git a/allensdk/brain_observatory/sync_dataset.py b/allensdk/brain_observatory/sync_dataset.py index 17284555b..40dd57282 100644 --- a/allensdk/brain_observatory/sync_dataset.py +++ b/allensdk/brain_observatory/sync_dataset.py @@ -90,12 +90,18 @@ class Dataset(object): FRAME_KEYS = ('frames', 'stim_vsync') PHOTODIODE_KEYS = ('photodiode', 'stim_photodiode') OPTOGENETIC_STIMULATION_KEYS = ("LED_sync", "opto_trial") - EYE_TRACKING_KEYS = ("eye_frame_received", # Expected eye tracking line label after 3/27/2020 - "cam2_exposure", # clocks eye tracking frame pulses (port 0, line 9) - "eyetracking", # previous line label for eye tracking (prior to ~ Oct. 2018) - "eye_tracking") # An undocumented, but possible eye tracking line label - BEHAVIOR_TRACKING_KEYS = ("beh_frame_received", # Expected behavior line label after 3/27/2020 - "cam1_exposure", # clocks behavior tracking frame pulses (port 0, line 8) + EYE_TRACKING_KEYS = ("eye_frame_received", # Expected eye tracking + # line label after 3/27/2020 + # clocks eye tracking frame pulses (port 0, line 9) + "cam2_exposure", + # previous line label for eye tracking + # (prior to ~ Oct. 2018) + "eyetracking", + "eye_tracking") # An undocumented, but possible eye tracking line label # NOQA E114 + BEHAVIOR_TRACKING_KEYS = ("beh_frame_received", # Expected behavior line label after 3/27/2020 # NOQA E127 + # clocks behavior tracking frame # NOQA E127 + # pulses (port 0, line 8) + "cam1_exposure", "behavior_monitoring") DEPRECATED_KEYS = {"cam2_exposure", @@ -106,10 +112,12 @@ class Dataset(object): def __init__(self, path, sync_line_label_deprecation_warning=False): ''' - sync_line_label_deprecation_warning: (bool) if True, warns about deprecated lines in sync file + sync_line_label_deprecation_warning: (bool) if True, + warns about deprecated lines in sync file ''' self.dfile = self.load(path) - self._check_line_labels(sync_line_label_deprecation_warning=sync_line_label_deprecation_warning) + self._check_line_labels( + sync_line_label_deprecation_warning=sync_line_label_deprecation_warning) # NOQA E501 def _check_line_labels(self, sync_line_label_deprecation_warning): if hasattr(self, "line_labels"): @@ -117,10 +125,10 @@ def _check_line_labels(self, sync_line_label_deprecation_warning): if deprecated_keys and sync_line_label_deprecation_warning: warnings.warn((f"The loaded sync file contains the " f"following deprecated line label keys: " - f"{deprecated_keys}. Consider updating the sync " + f"{deprecated_keys}. Consider updating the sync " # NOQA E501 f"file line labels."), stacklevel=2) else: - warnings.warn((f"The loaded sync file has no line labels and may " + warnings.warn((f"The loaded sync file has no line labels and may " # NOQA F541 f"not be valid."), stacklevel=2) def _process_times(self): @@ -149,7 +157,8 @@ def load(self, path): Path to hdf5 file. """ - self.dfile = h5.File(path, 'r') # MG edit 3/15 removed 'r' because some sync files were unable to load + self.dfile = h5.File( + path, 'r') # MG edit 3/15 removed 'r' because some sync files were unable to load # NOQA E501 self.meta_data = eval(self.dfile['meta'][()]) self.line_labels = self.meta_data['line_labels'] self.times = self._process_times() @@ -323,34 +332,34 @@ def get_rising_edges(self, line, units='samples'): return self.get_all_times(units)[np.where(changes == 1)] def get_edges( - self, - kind: str, - keys: Union[str, Sequence[str]], - units: str = "seconds", + self, + kind: str, + keys: Union[str, Sequence[str]], + units: str = "seconds", permissive: bool = False ) -> Optional[np.ndarray]: """ Utility function for extracting edge times from a line Parameters ---------- - kind : One of "rising", "falling", or "all". Should this method return - timestamps for rising, falling or both edges on the appropriate + kind : One of "rising", "falling", or "all". Should this method return + timestamps for rising, falling or both edges on the appropriate line - keys : These will be checked in sequence. Timestamps will be returned + keys : These will be checked in sequence. Timestamps will be returned for the first which is present in the line labels - units : one of "seconds", "samples", or "indices". The returned + units : one of "seconds", "samples", or "indices". The returned "time"stamps will be given in these units. raise_missing : If True and no matching line is found, a KeyError will be raised Returns ------- - An array of edge times. If raise_missing is False and none of the keys + An array of edge times. If raise_missing is False and none of the keys were found, returns None. Raises ------ - KeyError : none of the provided keys were found among this dataset's + KeyError : none of the provided keys were found among this dataset's line labels """ @@ -417,9 +426,9 @@ def get_nearest(self, """ source_edges = getattr(self, - "get_{}_edges".format(source_edge.lower()))(source.lower(), units="samples") + "get_{}_edges".format(source_edge.lower()))(source.lower(), units="samples") # NOQA E501 target_edges = getattr(self, - "get_{}_edges".format(target_edge.lower()))(target.lower(), units="samples") + "get_{}_edges".format(target_edge.lower()))(target.lower(), units="samples") # NOQA E501 indices = np.searchsorted(target_edges, source_edges, side="right") if direction.lower() == "previous": indices[np.where(indices != 0)] -= 1 @@ -432,7 +441,8 @@ def get_nearest(self, elif units in ['sec', 'seconds', 'second']: return target_edges[indices] / self.sample_freq else: - raise KeyError("Invalid units. Try 'seconds', 'samples' or 'indices'") + raise KeyError( + "Invalid units. Try 'seconds', 'samples' or 'indices'") def get_analog_channel(self, channel, @@ -457,8 +467,10 @@ def get_analog_channel(self, """ if isinstance(channel, str): - channel_index = self.analog_meta_data['analog_labels'].index(channel) - channel = self.analog_meta_data['analog_channels'].index(channel_index) + channel_index = self.analog_meta_data['analog_labels'].index( + channel) + channel = self.analog_meta_data['analog_channels'].index( + channel_index) if "analog_data" in self.dfile.keys(): dset = self.dfile['analog_data'] From bf9a69a3e7fdc2b1028af9acb465acf02b93ae10 Mon Sep 17 00:00:00 2001 From: Doug Ollerenshaw Date: Wed, 31 Mar 2021 11:07:12 -0700 Subject: [PATCH 12/86] ticket 1502 add line break in docstring --- allensdk/internal/brain_observatory/time_sync.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/allensdk/internal/brain_observatory/time_sync.py b/allensdk/internal/brain_observatory/time_sync.py index 1556b0cc5..1c0ce32b9 100644 --- a/allensdk/internal/brain_observatory/time_sync.py +++ b/allensdk/internal/brain_observatory/time_sync.py @@ -26,7 +26,8 @@ def get_keys(sync_dset: Dataset, invalid_sync_line_warning=False) -> dict: sync dataset line labels. Args: sync_dset: The sync dataset to search for keys within - invalid_sync_line_warning: (bool) if True, prints warnings about keys in sync file + invalid_sync_line_warning: (bool) if True, + prints warnings about keys in sync file Returns: key_dict: dictionary of key value pairs for finding data in the From 1ae3376c3e5628c34ab949b2aa8cd2ecbb97b6dd Mon Sep 17 00:00:00 2001 From: Taylor Matyasz Date: Wed, 31 Mar 2021 13:43:57 -0700 Subject: [PATCH 13/86] Write NWB methods fail properly when file doesn't exist (#2073) Fixed the write nwb methods in the case where no .inprogress file is created before an exception is raised, and added tests for these methods --- .../behavior/write_behavior_nwb/__main__.py | 3 +- .../behavior/write_nwb/__main__.py | 32 +------ .../behavior/test_write_behavior_nwb.py | 75 +++++++++++++++++ .../behavior/test_write_nwb_behavior_ophys.py | 83 ++++++++++++++++++- 4 files changed, 160 insertions(+), 33 deletions(-) diff --git a/allensdk/brain_observatory/behavior/write_behavior_nwb/__main__.py b/allensdk/brain_observatory/behavior/write_behavior_nwb/__main__.py index 572a15685..ff0263282 100644 --- a/allensdk/brain_observatory/behavior/write_behavior_nwb/__main__.py +++ b/allensdk/brain_observatory/behavior/write_behavior_nwb/__main__.py @@ -48,7 +48,8 @@ def write_behavior_nwb(session_data, nwb_filepath): os.rename(nwb_filepath_inprogress, nwb_filepath) return {'output_path': nwb_filepath} except Exception as e: - os.rename(nwb_filepath_inprogress, nwb_filepath_error) + if os.path.isfile(nwb_filepath_inprogress): + os.rename(nwb_filepath_inprogress, nwb_filepath_error) raise e diff --git a/allensdk/brain_observatory/behavior/write_nwb/__main__.py b/allensdk/brain_observatory/behavior/write_nwb/__main__.py index 24eee7e76..fdb66905e 100644 --- a/allensdk/brain_observatory/behavior/write_nwb/__main__.py +++ b/allensdk/brain_observatory/behavior/write_nwb/__main__.py @@ -53,7 +53,8 @@ def write_behavior_ophys_nwb(session_data: dict, os.rename(nwb_filepath_inprogress, nwb_filepath) return {'output_path': nwb_filepath} except Exception as e: - os.rename(nwb_filepath_inprogress, nwb_filepath_error) + if os.path.isfile(nwb_filepath_inprogress): + os.rename(nwb_filepath_inprogress, nwb_filepath_error) raise e @@ -90,33 +91,4 @@ def main(): if __name__ == "__main__": - - # input_dict = {'log_level':'DEBUG', - # 'session_data': {'ophys_experiment_id': 789359614, - # 'surface_2p_pixel_size_um': 0.78125, - # "max_projection_file": "/allen/programs/braintv/production/visualbehavior/prod0/specimen_756577249/ophys_session_789220000/ophys_experiment_789359614/processed/ophys_cell_segmentation_run_789410052/maxInt_a13a.png", - # "sync_file": "/allen/programs/braintv/production/visualbehavior/prod0/specimen_756577249/ophys_session_789220000/789220000_sync.h5", - # "rig_name": "CAM2P.5", - # "movie_width": 447, - # "movie_height": 512, - # "container_id": 814796558, - # "targeted_structure": "VISp", - # "targeted_depth": 375, - # "stimulus_name": "Unknown", - # "date_of_acquisition": '2018-11-30 23:28:37', - # "reporter_line": ["Ai93(TITL-GCaMP6f)"], - # "driver_line": ['Camk2a-tTA', 'Slc17a7-IRES2-Cre'], - # "external_specimen_name": 416369, - # "full_genotype": "Slc17a7-IRES2-Cre/wt;Camk2a-tTA/wt;Ai93(TITL-GCaMP6f)/wt", - # "behavior_stimulus_file": "/allen/programs/braintv/production/visualbehavior/prod0/specimen_756577249/behavior_session_789295700/789220000.pkl", - # "dff_file": "/allen/programs/braintv/production/visualbehavior/prod0/specimen_756577249/ophys_session_789220000/ophys_experiment_789359614/789359614_dff.h5", - # "ophys_cell_segmentation_run_id": 789410052, - # "cell_specimen_table_dict": json.load(open('/home/nicholasc/projects/allensdk/allensdk/test/brain_observatory/behavior/cell_specimen_table_789359614.json', 'r')), - # "demix_file": "/allen/programs/braintv/production/visualbehavior/prod0/specimen_756577249/ophys_session_789220000/ophys_experiment_789359614/demix/789359614_demixed_traces.h5", - # "average_intensity_projection_image_file": "/allen/programs/braintv/production/visualbehavior/prod0/specimen_756577249/ophys_session_789220000/ophys_experiment_789359614/processed/ophys_cell_segmentation_run_789410052/avgInt_a1X.png", - # "rigid_motion_transform_file": "/allen/programs/braintv/production/visualbehavior/prod0/specimen_756577249/ophys_session_789220000/ophys_experiment_789359614/processed/789359614_rigid_motion_transform.csv", - # }, - # 'output_path': 'tmp.nwb'} - # json.dump(input_dict, open('dev.json', 'w')) - main() diff --git a/allensdk/test/brain_observatory/behavior/test_write_behavior_nwb.py b/allensdk/test/brain_observatory/behavior/test_write_behavior_nwb.py index 96df5d16a..d7a2bc2a7 100644 --- a/allensdk/test/brain_observatory/behavior/test_write_behavior_nwb.py +++ b/allensdk/test/brain_observatory/behavior/test_write_behavior_nwb.py @@ -1,4 +1,6 @@ import math +import mock +from pathlib import Path import numpy as np import pandas as pd @@ -11,6 +13,9 @@ from allensdk.brain_observatory.behavior.stimulus_processing import \ StimulusTemplate, get_stimulus_templates +from allensdk.brain_observatory.behavior.write_behavior_nwb.__main__ import \ + write_behavior_nwb # noqa: E501 + # pytest fixtures: # nwbfile: test.brain_observatory.conftest @@ -238,3 +243,73 @@ def test_add_task_parameters_stim_nan(nwbfile, roundtrip, assert math.isnan(val) else: assert val == task_parameters_obt[key] + + +def test_write_behavior_nwb_no_file(): + """ + This function is testing the fail condition of the write_behavior_nwb + method. The main functionality of the write_behavior_nwb method occurs + in a try block, and in the case that an exception is raised there is + functionality in the except block to check if any partial output + exists, and if so rename that file to have a .error suffix before + raising the previously mentioned exception. + + This test is checking the case where that partial output does not + exist. In this case we still want to have the original exception + returned and avoid a FileNotFound error. + + To ensure that we enter the except block, a value of None is passed + for the session_data argument. This will cause a TypeError when + write_behavior_nwb tries to subscript this variable. We are checking + that, even though no partial output exists, we still get this + TypeError raised. + """ + with pytest.raises(TypeError): + write_behavior_nwb( + session_data=None, + nwb_filepath='' + ) + + +def test_write_behavior_nwb_with_file(tmpdir): + """ + This function is testing the fail condition of the write_behavior_nwb + method. The main functionality of the write_behavior_nwb method occurs + in a try block, and in the case that an exception is raised there is + functionality in the except block to check if any partial output + exists, and if so rename that file to have a .error suffix before + raising the previously mentioned exception. + + This test is checking the case where a partial output file does + exist. In this case we still want to have the original exception + returned and avoid a FileNotFound error, but also check that a new + file with the .error suffix exists. + + To ensure that we enter the except block, a value of None is passed + for the session_data argument. This will cause a TypeError when + write_behavior_nwb tries to subscript this variable. To get the + partial output file to exist, we simply create a Path object and + call the .touch method. + + This test also patched the os.remove method to do nothing. This is + necessary because the write_behavior_nwb method checks for any + existing output and removes it before running. + """ + # Create the dummy .nwb file + fake_nwb_fp = Path(tmpdir) / 'fake_nwb.nwb' + Path(str(fake_nwb_fp) + '.inprogress').touch() + + def mock_os_remove(fp): + pass + + # Patch the os.remove method to do nothing + with mock.patch('os.remove', side_effects=mock_os_remove): + with pytest.raises(TypeError): + write_behavior_nwb( + session_data=None, + nwb_filepath=str(fake_nwb_fp) + ) + + # Check that the new .error file exists, and that we + # still get the expected exception + assert Path(str(fake_nwb_fp) + '.error').exists() diff --git a/allensdk/test/brain_observatory/behavior/test_write_nwb_behavior_ophys.py b/allensdk/test/brain_observatory/behavior/test_write_nwb_behavior_ophys.py index f556e9c93..7d20621b7 100644 --- a/allensdk/test/brain_observatory/behavior/test_write_nwb_behavior_ophys.py +++ b/allensdk/test/brain_observatory/behavior/test_write_nwb_behavior_ophys.py @@ -1,4 +1,6 @@ import math +import mock +from pathlib import Path import warnings import numpy as np @@ -9,8 +11,11 @@ import allensdk.brain_observatory.nwb as nwb from allensdk.brain_observatory.behavior.session_apis.data_io import ( BehaviorOphysNwbApi) -from allensdk.test.brain_observatory.behavior.test_eye_tracking_processing import \ - create_refined_eye_tracking_df # noqa: E501 +from allensdk.test.brain_observatory.behavior.test_eye_tracking_processing import ( # noqa: E501 + create_refined_eye_tracking_df) + +from allensdk.brain_observatory.behavior.write_nwb.__main__ import \ + write_behavior_ophys_nwb # noqa: E501 @pytest.fixture @@ -388,3 +393,77 @@ def test_add_events(tmp_path, nwbfile, roundtripper, roundtrip, obtained = obt.get_events() pd.testing.assert_frame_equal(obtained, events, check_like=True) + + +def test_write_behavior_ophys_nwb_no_file(): + """ + This function is testing the fail condition of the + write_behavior_ophys_nwb method. The main functionality of the + write_behavior_ophys_nwb method occurs in a try block, and in the + case that an exception is raised there is functionality in the except + block to check if any partial output exists, and if so rename that + file to have a .error suffix before raising the previously + mentioned exception. + + This test is checking the case where that partial output does not + exist. In this case we still want to have the original exception + returned and avoid a FileNotFound error. + + To ensure that we enter the except block, a value of None is passed + for the session_data argument. This will cause a TypeError when + write_behavior_ophys_nwb tries to subscript this variable. We are + checking that, even though no partial output exists, we still get + this TypeError raised. + """ + with pytest.raises(TypeError): + write_behavior_ophys_nwb( + session_data=None, + nwb_filepath='', + skip_eye_tracking=True + ) + + +def test_write_behavior_ophys_nwb_with_file(tmpdir): + """ + This function is testing the fail condition of the + write_behavior_ophys_nwb method. The main functionality of the + write_behavior_ophys_nwb method occurs in a try block, and in the + case that an exception is raised there is functionality in the except + block to check if any partial output exists, and if so rename that + file to have a .error suffix before raising the previously + mentioned exception. + + This test is checking the case where a partial output file does + exist. In this case we still want to have the original exception + returned and avoid a FileNotFound error, but also check that a new + file with the .error suffix exists. + + To ensure that we enter the except block, a value of None is passed + for the session_data argument. This will cause a TypeError when + write_behavior_ophys_nwb tries to subscript this variable. To get the + partial output file to exist, we simply create a Path object and + call the .touch method. + + This test also patched the os.remove method to do nothing. This is + necessary because the write_behavior_nwb method checks for any + existing output and removes it before running. + """ + # Create the dummy .nwb file + fake_nwb_fp = Path(tmpdir) / 'fake_nwb.nwb' + Path(str(fake_nwb_fp) + '.inprogress').touch() + + def mock_os_remove(fp): + pass + + # Patch the os.remove method to do nothing + with mock.patch('os.remove', side_effects=mock_os_remove): + with pytest.raises(TypeError): + write_behavior_ophys_nwb( + session_data=None, + nwb_filepath=str(fake_nwb_fp), + skip_eye_tracking=True + ) + + # Check that the new .error file exists, and that we + # still get the expected exception + assert Path(str(fake_nwb_fp) + '.error').exists() From 1593b84c279ce0b33e46313b6a0168e0051c131d Mon Sep 17 00:00:00 2001 From: Doug Ollerenshaw Date: Wed, 31 Mar 2021 15:24:07 -0700 Subject: [PATCH 14/86] ticket 1502 - add to key_dict in time_sync.py to avoid warning, add comments --- allensdk/internal/brain_observatory/time_sync.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/allensdk/internal/brain_observatory/time_sync.py b/allensdk/internal/brain_observatory/time_sync.py index 1c0ce32b9..e191525f2 100644 --- a/allensdk/internal/brain_observatory/time_sync.py +++ b/allensdk/internal/brain_observatory/time_sync.py @@ -19,15 +19,13 @@ MAX_MONITOR_DELAY = 0.07 # seconds -def get_keys(sync_dset: Dataset, invalid_sync_line_warning=False) -> dict: +def get_keys(sync_dset: Dataset) -> dict: """ Gets the correct keys for the sync file by searching the sync file line labels. Removes key from the dictionary if it is not in the sync dataset line labels. Args: sync_dset: The sync dataset to search for keys within - invalid_sync_line_warning: (bool) if True, - prints warnings about keys in sync file Returns: key_dict: dictionary of key value pairs for finding data in the @@ -45,19 +43,26 @@ def get_keys(sync_dset: Dataset, invalid_sync_line_warning=False) -> dict: "eye_frame_received"], "behavior_camera": ["cam1_exposure", "behavior_monitoring", "beh_frame_received"], - "acquiring": ["2p_acquiring"], + "acquiring": ["2p_acquiring", "acq_trigger"], "lick_sensor": ["lick_1", "lick_sensor"] } label_set = set(sync_dset.line_labels) remove_keys = [] for key, value in key_dict.items(): + # for each key in the above `key_dict`, this loop + # checks to see if there is a corresponing value in + # the set of line labels present in the sync file (`label_set`) + # If not, the key is added to the `remove_keys` list value_set = set(value) diff = value_set.intersection(label_set) if len(diff) == 1: key_dict[key] = diff.pop() else: remove_keys.append(key) - if len(remove_keys) > 0 and invalid_sync_line_warning: + + # the contents of the `remove_keys` list is printed to the console + # as a user warning + if len(remove_keys) > 0: logging.warning("Could not find valid lines for the following data " "sources") for key in remove_keys: From 3ad7f50fb98ad702e0406980a685f21e368e4339 Mon Sep 17 00:00:00 2001 From: Doug Ollerenshaw Date: Wed, 31 Mar 2021 15:24:40 -0700 Subject: [PATCH 15/86] remove warning about deprecated keys in sync_dataset.py --- allensdk/brain_observatory/sync_dataset.py | 20 +------------------- 1 file changed, 1 insertion(+), 19 deletions(-) diff --git a/allensdk/brain_observatory/sync_dataset.py b/allensdk/brain_observatory/sync_dataset.py index 40dd57282..4c53dd96f 100644 --- a/allensdk/brain_observatory/sync_dataset.py +++ b/allensdk/brain_observatory/sync_dataset.py @@ -110,26 +110,8 @@ class Dataset(object): "cam1_exposure", "behavior_monitoring"} - def __init__(self, path, sync_line_label_deprecation_warning=False): - ''' - sync_line_label_deprecation_warning: (bool) if True, - warns about deprecated lines in sync file - ''' + def __init__(self, path): self.dfile = self.load(path) - self._check_line_labels( - sync_line_label_deprecation_warning=sync_line_label_deprecation_warning) # NOQA E501 - - def _check_line_labels(self, sync_line_label_deprecation_warning): - if hasattr(self, "line_labels"): - deprecated_keys = set(self.line_labels) & self.DEPRECATED_KEYS - if deprecated_keys and sync_line_label_deprecation_warning: - warnings.warn((f"The loaded sync file contains the " - f"following deprecated line label keys: " - f"{deprecated_keys}. Consider updating the sync " # NOQA E501 - f"file line labels."), stacklevel=2) - else: - warnings.warn((f"The loaded sync file has no line labels and may " # NOQA F541 - f"not be valid."), stacklevel=2) def _process_times(self): """ From b81882ad002eabe9b8b7b8df2d8b360426c3779d Mon Sep 17 00:00:00 2001 From: Doug Ollerenshaw Date: Wed, 31 Mar 2021 15:25:34 -0700 Subject: [PATCH 16/86] ticket 1502 - removed unused warnings import from sync_dataset.py --- allensdk/brain_observatory/sync_dataset.py | 1 - 1 file changed, 1 deletion(-) diff --git a/allensdk/brain_observatory/sync_dataset.py b/allensdk/brain_observatory/sync_dataset.py index 4c53dd96f..539e87ac5 100644 --- a/allensdk/brain_observatory/sync_dataset.py +++ b/allensdk/brain_observatory/sync_dataset.py @@ -20,7 +20,6 @@ import h5py as h5 import numpy as np -import warnings import logging logger = logging.getLogger(__name__) From a53799ff016eecf5bd0492e77eb21f87df87786a Mon Sep 17 00:00:00 2001 From: aamster Date: Thu, 1 Apr 2021 06:30:00 -0700 Subject: [PATCH 17/86] Updates schemas to pass validation Use function to write output instead of duplicated code --- allensdk/brain_observatory/argschema_utilities.py | 3 +-- .../behavior/write_behavior_nwb/_schemas.py | 1 + .../brain_observatory/behavior/write_nwb/_schemas.py | 1 + .../ecephys/align_timestamps/__main__.py | 9 +++------ .../ecephys/copy_utility/_schemas.py | 2 +- .../ecephys/current_source_density/_schemas.py | 3 --- .../ecephys/lfp_subsampling/__main__.py | 9 +++------ .../ecephys/optotagging_table/__main__.py | 9 +++------ .../ecephys/stimulus_analysis/__main__.py | 8 +++----- .../extract_running_speed/__main__.py | 10 +++------- allensdk/brain_observatory/gaze_mapping/_schemas.py | 3 ++- .../ophys/trace_extraction/_schemas.py | 1 + allensdk/mouse_connectivity/grid/__main__.py | 12 +++--------- allensdk/mouse_connectivity/grid/_schemas.py | 2 +- 14 files changed, 26 insertions(+), 47 deletions(-) diff --git a/allensdk/brain_observatory/argschema_utilities.py b/allensdk/brain_observatory/argschema_utilities.py index f50522c61..cfb448efd 100644 --- a/allensdk/brain_observatory/argschema_utilities.py +++ b/allensdk/brain_observatory/argschema_utilities.py @@ -42,8 +42,7 @@ def _validate(self, value: pathlib.Path): def write_or_print_outputs(data, parser): data.update({'input_parameters': parser.args}) if 'output_json' in parser.args: - with open(parser.args['output_json'], 'w') as f: - f.write(json.dumps(data, indent=2)) + parser.output(data, indent=2) else: print(parser.get_output_json(data)) diff --git a/allensdk/brain_observatory/behavior/write_behavior_nwb/_schemas.py b/allensdk/brain_observatory/behavior/write_behavior_nwb/_schemas.py index f8d05d4f3..df45dabda 100644 --- a/allensdk/brain_observatory/behavior/write_behavior_nwb/_schemas.py +++ b/allensdk/brain_observatory/behavior/write_behavior_nwb/_schemas.py @@ -75,6 +75,7 @@ class Meta: class OutputSchema(RaisingSchema): + input_parameters = Nested(InputSchema) output_path = String(required=True, validate=check_write_access_overwrite, description='Path of output.json to be written') diff --git a/allensdk/brain_observatory/behavior/write_nwb/_schemas.py b/allensdk/brain_observatory/behavior/write_nwb/_schemas.py index aca452345..c4b454cdb 100644 --- a/allensdk/brain_observatory/behavior/write_nwb/_schemas.py +++ b/allensdk/brain_observatory/behavior/write_nwb/_schemas.py @@ -140,4 +140,5 @@ class Meta: class OutputSchema(RaisingSchema): + input_parameters = Nested(InputSchema) output_path = String(required=True, description='write outputs to here') diff --git a/allensdk/brain_observatory/ecephys/align_timestamps/__main__.py b/allensdk/brain_observatory/ecephys/align_timestamps/__main__.py index ece163b8f..2df261d9e 100644 --- a/allensdk/brain_observatory/ecephys/align_timestamps/__main__.py +++ b/allensdk/brain_observatory/ecephys/align_timestamps/__main__.py @@ -3,7 +3,8 @@ import copy import numpy as np -from allensdk.brain_observatory.argschema_utilities import ArgSchemaParserPlus +from allensdk.brain_observatory.argschema_utilities import ArgSchemaParserPlus, \ + write_or_print_outputs import argparse from ._schemas import InputParameters, OutputParameters @@ -104,11 +105,7 @@ def main(): ) output = align_timestamps(mod.args) - output.update({"input_parameters": mod.args}) - if "output_json" in mod.args: - mod.output(output, indent=2) - else: - print(mod.get_output_json(output)) + write_or_print_outputs(data=output, parser=mod) if __name__ == "__main__": diff --git a/allensdk/brain_observatory/ecephys/copy_utility/_schemas.py b/allensdk/brain_observatory/ecephys/copy_utility/_schemas.py index bfb3f46fe..581a4d596 100644 --- a/allensdk/brain_observatory/ecephys/copy_utility/_schemas.py +++ b/allensdk/brain_observatory/ecephys/copy_utility/_schemas.py @@ -45,5 +45,5 @@ class Meta: class OutputSchema(RaisingSchema): + input_parameters = Nested(InputSchema) files = Nested(CopiedFile, many=True, required=True, description='copied files') - \ No newline at end of file diff --git a/allensdk/brain_observatory/ecephys/current_source_density/_schemas.py b/allensdk/brain_observatory/ecephys/current_source_density/_schemas.py index 4a0bb5a18..d1b5d4207 100644 --- a/allensdk/brain_observatory/ecephys/current_source_density/_schemas.py +++ b/allensdk/brain_observatory/ecephys/current_source_density/_schemas.py @@ -42,7 +42,6 @@ class InputParameters(ArgSchema): class ProbeOutputParameters(DefaultSchema): name = String(required=True, help='Identifier for this probe.') csd_path = String(required=True, help='Path to current source density file.') - csd_channels = List(Int, required=True, help='LFP channels from which CSD was calculated.') class OutputSchema(DefaultSchema): @@ -53,6 +52,4 @@ class OutputSchema(DefaultSchema): class OutputParameters(OutputSchema): - stimulus_name = String(required=True, help="name of stimulus from which CSD was calculated") - stimulus_index = Int(required=True, help="index of stimulus from which CSD was calculated") probe_outputs = Nested(ProbeOutputParameters, many=True, required=True, help='probewise outputs') diff --git a/allensdk/brain_observatory/ecephys/lfp_subsampling/__main__.py b/allensdk/brain_observatory/ecephys/lfp_subsampling/__main__.py index a080ebe5a..f9688db32 100644 --- a/allensdk/brain_observatory/ecephys/lfp_subsampling/__main__.py +++ b/allensdk/brain_observatory/ecephys/lfp_subsampling/__main__.py @@ -38,7 +38,8 @@ from ._schemas import InputParameters, OutputParameters from allensdk.brain_observatory.ecephys.file_io.continuous_file import ContinuousFile -from allensdk.brain_observatory.argschema_utilities import ArgSchemaParserPlus +from allensdk.brain_observatory.argschema_utilities import ArgSchemaParserPlus, \ + write_or_print_outputs from .subsampling import select_channels, subsample_timestamps, subsample_lfp, remove_lfp_offset, remove_lfp_noise @@ -120,11 +121,7 @@ def subsample(args): def main(): mod = ArgSchemaParserPlus(schema_type=InputParameters, output_schema_type=OutputParameters) output = subsample(mod.args) - output.update({"input_parameters": mod.args}) - if "output_json" in mod.args: - mod.output(output, indent=2) - else: - logger.info(mod.get_output_json(output)) + write_or_print_outputs(data=output, parser=mod) if __name__ == "__main__": diff --git a/allensdk/brain_observatory/ecephys/optotagging_table/__main__.py b/allensdk/brain_observatory/ecephys/optotagging_table/__main__.py index 423c70122..ff8ee2f4c 100644 --- a/allensdk/brain_observatory/ecephys/optotagging_table/__main__.py +++ b/allensdk/brain_observatory/ecephys/optotagging_table/__main__.py @@ -1,6 +1,7 @@ import pandas as pd -from allensdk.brain_observatory.argschema_utilities import ArgSchemaParserPlus +from allensdk.brain_observatory.argschema_utilities import ArgSchemaParserPlus, \ + write_or_print_outputs from allensdk.brain_observatory.ecephys.file_io.ecephys_sync_dataset import ( EcephysSyncDataset, ) @@ -50,11 +51,7 @@ def main(): mod = ArgSchemaParserPlus(schema_type=InputParameters, output_schema_type=OutputParameters) output = build_opto_table(mod.args) - output.update({"input_parameters": mod.args}) - if "output_json" in mod.args: - mod.output(output, indent=2) - else: - print(mod.get_output_json(output)) + write_or_print_outputs(data=output, parser=mod) if __name__ == "__main__": diff --git a/allensdk/brain_observatory/ecephys/stimulus_analysis/__main__.py b/allensdk/brain_observatory/ecephys/stimulus_analysis/__main__.py index c774a7d7f..d90bbd65a 100644 --- a/allensdk/brain_observatory/ecephys/stimulus_analysis/__main__.py +++ b/allensdk/brain_observatory/ecephys/stimulus_analysis/__main__.py @@ -6,6 +6,8 @@ import numpy as np import logging +from allensdk.brain_observatory.argschema_utilities import \ + write_or_print_outputs from ..ecephys_session import EcephysSession from .drifting_gratings import DriftingGratings from .static_gratings import StaticGratings @@ -181,11 +183,7 @@ def main(): # output = calculate_stimulus_metrics_ondisk(mod.args) output = calculate_stimulus_metrics_gather(mod.args) if MPI_rank == 0: - output.update({"input_parameters": mod.args}) - if "output_json" in mod.args: - mod.output(output, indent=2) - else: - log_info(mod.get_output_json(output)) + write_or_print_outputs(data=output, parser=mod) barrier() diff --git a/allensdk/brain_observatory/extract_running_speed/__main__.py b/allensdk/brain_observatory/extract_running_speed/__main__.py index 32dd0c1bc..6e6680aee 100644 --- a/allensdk/brain_observatory/extract_running_speed/__main__.py +++ b/allensdk/brain_observatory/extract_running_speed/__main__.py @@ -5,7 +5,8 @@ from allensdk.brain_observatory.sync_dataset import Dataset from allensdk.brain_observatory import sync_utilities -from allensdk.brain_observatory.argschema_utilities import ArgSchemaParserPlus +from allensdk.brain_observatory.argschema_utilities import ArgSchemaParserPlus, \ + write_or_print_outputs from ._schemas import InputParameters, OutputParameters @@ -146,9 +147,4 @@ def main( ) output = main(**mod.args) - output.update({"input_parameters": mod.args}) - - if "output_json" in mod.args: - mod.output(output, indent=2) - else: - print(mod.get_output_json(output)) + write_or_print_outputs(data=output, parser=mod) diff --git a/allensdk/brain_observatory/gaze_mapping/_schemas.py b/allensdk/brain_observatory/gaze_mapping/_schemas.py index a3659fe67..d8c98a00b 100644 --- a/allensdk/brain_observatory/gaze_mapping/_schemas.py +++ b/allensdk/brain_observatory/gaze_mapping/_schemas.py @@ -1,5 +1,5 @@ from argschema import ArgSchema -from argschema.fields import Float, LogLevel, String, Boolean +from argschema.fields import Float, LogLevel, String, Boolean, Nested from allensdk.brain_observatory.argschema_utilities import ( InputFile, @@ -102,6 +102,7 @@ class InputSchema(ArgSchema): class OutputSchema(RaisingSchema): + input_parameters = Nested(InputSchema) screen_mapping_file = OutputFile(required=True, description=('Full save path of output h5 ' 'file that will be created ' diff --git a/allensdk/brain_observatory/ophys/trace_extraction/_schemas.py b/allensdk/brain_observatory/ophys/trace_extraction/_schemas.py index c5c3da7c6..d6a1b49da 100644 --- a/allensdk/brain_observatory/ophys/trace_extraction/_schemas.py +++ b/allensdk/brain_observatory/ophys/trace_extraction/_schemas.py @@ -42,6 +42,7 @@ class Meta: class OutputSchema(RaisingSchema): + input_parameters = Nested(InputSchema) neuropil_trace_file = String(required=True, description='path to output h5 file containing neuropil traces') # TODO rename these to _path roi_trace_file = String(required=True, description='path to output h5 file containing roi traces') exclusion_labels = Nested(ExclusionLabel, many=True, description='a report of roi-wise problems detected during extraction') diff --git a/allensdk/mouse_connectivity/grid/__main__.py b/allensdk/mouse_connectivity/grid/__main__.py index a9e8e3fbe..aa2fedad5 100755 --- a/allensdk/mouse_connectivity/grid/__main__.py +++ b/allensdk/mouse_connectivity/grid/__main__.py @@ -7,6 +7,8 @@ import argschema import requests +from allensdk.brain_observatory.argschema_utilities import \ + write_or_print_outputs from ._schemas import InputParameters, OutputParameters from . import cases from .image_series_gridder import ImageSeriesGridder @@ -34,14 +36,6 @@ def get_inputs_from_lims(host, image_series_id, output_root, job_queue, strategy return data -def write_or_print_outputs(data, parser): - data.update({'input_parameters': parser.args}) - if 'output_json' in parser.args: - parser.output(data, indent=2) - else: - print(parser.get_output_json(data)) - - def run_grid(args): try: @@ -136,4 +130,4 @@ def main(): if __name__ == '__main__': - main() \ No newline at end of file + main() diff --git a/allensdk/mouse_connectivity/grid/_schemas.py b/allensdk/mouse_connectivity/grid/_schemas.py index 6b324ec8c..29771076a 100755 --- a/allensdk/mouse_connectivity/grid/_schemas.py +++ b/allensdk/mouse_connectivity/grid/_schemas.py @@ -80,4 +80,4 @@ class OutputSchema(RaisingSchema): class OutputParameters(OutputSchema): - output_file_paths = Dict(required=True) + output_file_paths = List(String, required=True) From 255a9dd2b93fac841e15cbc6a6c02d5b15cf1981 Mon Sep 17 00:00:00 2001 From: aamster Date: Thu, 1 Apr 2021 07:44:59 -0700 Subject: [PATCH 18/86] flake8 --- .../brain_observatory/argschema_utilities.py | 24 +++-- .../ecephys/align_timestamps/__main__.py | 33 +++--- .../ecephys/copy_utility/_schemas.py | 70 +++++++----- .../current_source_density/_schemas.py | 102 +++++++++++++----- .../ecephys/lfp_subsampling/__main__.py | 66 +++++++----- .../ecephys/optotagging_table/__main__.py | 15 +-- .../ecephys/stimulus_analysis/__main__.py | 102 +++++++++++------- .../ecephys/stimulus_table/__main__.py | 63 +++++------ .../extract_running_speed/__main__.py | 23 ++-- .../gaze_mapping/_schemas.py | 8 +- .../ophys/trace_extraction/_schemas.py | 73 ++++++++----- allensdk/mouse_connectivity/grid/__main__.py | 77 ++++++------- allensdk/mouse_connectivity/grid/_schemas.py | 80 +++++++++----- 13 files changed, 449 insertions(+), 287 deletions(-) diff --git a/allensdk/brain_observatory/argschema_utilities.py b/allensdk/brain_observatory/argschema_utilities.py index cfb448efd..e581fdc46 100644 --- a/allensdk/brain_observatory/argschema_utilities.py +++ b/allensdk/brain_observatory/argschema_utilities.py @@ -1,12 +1,11 @@ -import json +import argparse import os import pathlib -import argparse import marshmallow -from marshmallow import RAISE, ValidationError from argschema import ArgSchemaParser from argschema.schemas import DefaultSchema +from marshmallow import RAISE, ValidationError class InputFile(marshmallow.fields.String): @@ -14,6 +13,7 @@ class InputFile(marshmallow.fields.String): that represent a desired input path to pathlib.Path. Also performs read access checking. """ + def _deserialize(self, value, attr, obj, **kwargs) -> pathlib.Path: return pathlib.Path(value) @@ -29,6 +29,7 @@ class OutputFile(marshmallow.fields.String): that represent a desired output file path to a pathlib.Path. Also performs write access checking. """ + def _deserialize(self, value, attr, obj, **kwargs) -> pathlib.Path: return pathlib.Path(value) @@ -48,7 +49,6 @@ def write_or_print_outputs(data, parser): def check_write_access_dir(dirpath): - if os.path.exists(dirpath): test_filepath = pathlib.Path(dirpath, 'test_file.txt') try: @@ -57,14 +57,16 @@ def check_write_access_dir(dirpath): os.remove(test_filepath) return True except PermissionError: - raise ValidationError(f'don\'t have permissions to write in directory {dirpath}') + raise ValidationError( + f'don\'t have permissions to write in directory {dirpath}') else: try: pathlib.Path(dirpath).mkdir(parents=True) pathlib.Path(dirpath).rmdir() return True except PermissionError: - raise ValidationError(f'Can\'t build path to requested location {dirpath}') + raise ValidationError( + f'Can\'t build path to requested location {dirpath}') raise RuntimeError('Unhandled case; this should not happen') @@ -101,7 +103,8 @@ def check_read_access(path): f.close() return True except Exception as err: - raise ValidationError(f'file at #{path} not readable (#{type(err)}: {err}') + raise ValidationError( + f'file at #{path} not readable (#{type(err)}: {err}') class RaisingSchema(DefaultSchema): @@ -120,7 +123,6 @@ def __init__(self, *args, **kwargs): def optional_lims_inputs(argv, input_schema, output_schema, lims_input_getter): - remaining_args = argv[1:] input_data = {} @@ -129,10 +131,12 @@ def optional_lims_inputs(argv, input_schema, output_schema, lims_input_getter): lims_parser.add_argument("--host", type=str, default="http://lims2") lims_parser.add_argument("--job_queue", type=str, default=None) lims_parser.add_argument("--strategy", type=str, default=None) - lims_parser.add_argument("--ecephys_session_id", type=int, default=None) + lims_parser.add_argument("--ecephys_session_id", type=int, + default=None) lims_parser.add_argument("--output_root", type=str, default=None) - lims_args, remaining_args = lims_parser.parse_known_args(remaining_args) + lims_args, remaining_args = lims_parser.parse_known_args( + remaining_args) remaining_args = [ item for item in remaining_args if item != "--get_inputs_from_lims" ] diff --git a/allensdk/brain_observatory/ecephys/align_timestamps/__main__.py b/allensdk/brain_observatory/ecephys/align_timestamps/__main__.py index 2df261d9e..31773553c 100644 --- a/allensdk/brain_observatory/ecephys/align_timestamps/__main__.py +++ b/allensdk/brain_observatory/ecephys/align_timestamps/__main__.py @@ -1,21 +1,16 @@ -import os -import sys -import copy - import numpy as np -from allensdk.brain_observatory.argschema_utilities import ArgSchemaParserPlus, \ - write_or_print_outputs -import argparse +from allensdk.brain_observatory.argschema_utilities import \ + ArgSchemaParserPlus, \ + write_or_print_outputs from ._schemas import InputParameters, OutputParameters from .barcode_sync_dataset import BarcodeSyncDataset +from .channel_states import extract_barcodes_from_states, \ + extract_splits_from_states from .probe_synchronizer import ProbeSynchronizer -from . import barcode -from .channel_states import extract_barcodes_from_states, extract_splits_from_states def align_timestamps(args): - sync_dataset = BarcodeSyncDataset.factory(args["sync_h5_path"]) sync_times, sync_codes = sync_dataset.extract_barcodes() @@ -70,26 +65,31 @@ def align_timestamps(args): for synchronizer in synchronizers: aligned_timestamps = synchronizer(aligned_timestamps) - print("total time shift: " + str(synchronizer.total_time_shift)) + print( + "total time shift: " + str(synchronizer.total_time_shift)) print( "actual sampling rate: " + str(synchronizer.global_probe_sampling_rate) ) np.save( - timestamp_file["output_path"], aligned_timestamps, allow_pickle=False + timestamp_file["output_path"], aligned_timestamps, + allow_pickle=False ) - mapped_files[timestamp_file["name"]] = timestamp_file["output_path"] + mapped_files[timestamp_file["name"]] = timestamp_file[ + "output_path"] lfp_sampling_rate = ( - probe["lfp_sampling_rate"] * synchronizer.sampling_rate_scale + probe["lfp_sampling_rate"] * synchronizer.sampling_rate_scale ) - this_probe_output_info["total_time_shift"] = synchronizer.total_time_shift + this_probe_output_info[ + "total_time_shift"] = synchronizer.total_time_shift this_probe_output_info[ "global_probe_sampling_rate" ] = synchronizer.global_probe_sampling_rate - this_probe_output_info["global_probe_lfp_sampling_rate"] = lfp_sampling_rate + this_probe_output_info[ + "global_probe_lfp_sampling_rate"] = lfp_sampling_rate this_probe_output_info["output_paths"] = mapped_files this_probe_output_info["name"] = probe["name"] @@ -99,7 +99,6 @@ def align_timestamps(args): def main(): - mod = ArgSchemaParserPlus( schema_type=InputParameters, output_schema_type=OutputParameters ) diff --git a/allensdk/brain_observatory/ecephys/copy_utility/_schemas.py b/allensdk/brain_observatory/ecephys/copy_utility/_schemas.py index 581a4d596..a40c5d088 100644 --- a/allensdk/brain_observatory/ecephys/copy_utility/_schemas.py +++ b/allensdk/brain_observatory/ecephys/copy_utility/_schemas.py @@ -1,12 +1,11 @@ import hashlib -from marshmallow import RAISE, ValidationError +from argschema import ArgSchema +from argschema.fields import LogLevel, String, Int, Nested, Boolean, List +from marshmallow import RAISE -from argschema import ArgSchema, ArgSchemaParser -from argschema.schemas import DefaultSchema -from argschema.fields import LogLevel, String, Int, DateTime, Nested, Boolean, Float, List - -from allensdk.brain_observatory.argschema_utilities import check_read_access, check_write_access, RaisingSchema +from allensdk.brain_observatory.argschema_utilities import check_read_access, \ + check_write_access, RaisingSchema available_hashers = { 'sha3_256': hashlib.sha3_256, @@ -14,36 +13,59 @@ None: None } + class FileToCopy(RaisingSchema): - source = String(required=True, validate=check_read_access, description='copy from here') - destination = String(required=True, validate=check_write_access, description='copy to here (full path, not just directory!)') - key = String(required=True, description='will be passed through to outputs, allowing a name or kind to be associated with this file') + source = String(required=True, validate=check_read_access, + description='copy from here') + destination = String(required=True, validate=check_write_access, + description='copy to here (full path, not just ' + 'directory!)') + key = String(required=True, + description='will be passed through to outputs, allowing a ' + 'name or kind to be associated with this file') class CopiedFile(RaisingSchema): source = String(required=True, description='copied from here') destination = String(required=True, description='copied to here') key = String(required=False, description='passed from inputs') - source_hash = List(Int, required=False) # int array vs bytes for JSONability + source_hash = List(Int, + required=False) # int array vs bytes for JSONability destination_hash = List(Int, required=False) class InputSchema(ArgSchema): class Meta: - unknown=RAISE - log_level = LogLevel(default='INFO', description='set the logging level of the module') - files = Nested(FileToCopy, many=True, required=True, description='files to be copied') - use_rsync = Boolean(default=True, - description='copy files using rsync rather than shutil (this is not likely to work if you are running windows!)' - ) - hasher_key = String(default='sha256', validate=lambda st: st in available_hashers, allow_none=True, - description='select a hash function to compute over base64-encoded pre- and post-copy files' - ) - raise_if_comparison_fails = Boolean(default=True, description='if a hash comparison fails, throw an error (vs. a warning)') - make_parent_dirs = Boolean(default=True, description='build missing parent directories for destination') - chmod = Int(default=775, description="destination files (and any created parents will have these permissions") - + unknown = RAISE + + log_level = LogLevel(default='INFO', + description='set the logging level of the module') + files = Nested(FileToCopy, many=True, required=True, + description='files to be copied') + use_rsync = Boolean(default=True, + description='copy files using rsync rather than ' + 'shutil (this is not likely to work if ' + 'you are running windows!)' + ) + hasher_key = String(default='sha256', + validate=lambda st: st in available_hashers, + allow_none=True, + description='select a hash function to compute over ' + 'base64-encoded pre- and post-copy files' + ) + raise_if_comparison_fails = Boolean(default=True, + description='if a hash comparison ' + 'fails, throw an error (' + 'vs. a warning)') + make_parent_dirs = Boolean(default=True, + description='build missing parent directories ' + 'for destination') + chmod = Int(default=775, + description="destination files (and any created parents will " + "have these permissions") + class OutputSchema(RaisingSchema): input_parameters = Nested(InputSchema) - files = Nested(CopiedFile, many=True, required=True, description='copied files') + files = Nested(CopiedFile, many=True, required=True, + description='copied files') diff --git a/allensdk/brain_observatory/ecephys/current_source_density/_schemas.py b/allensdk/brain_observatory/ecephys/current_source_density/_schemas.py index d1b5d4207..6d5c0b0bc 100644 --- a/allensdk/brain_observatory/ecephys/current_source_density/_schemas.py +++ b/allensdk/brain_observatory/ecephys/current_source_density/_schemas.py @@ -1,47 +1,94 @@ +import numpy as np from argschema import ArgSchema -from argschema.schemas import DefaultSchema from argschema.fields import Nested, String, Float, Int, List, Bool -import numpy as np +from argschema.schemas import DefaultSchema class ProbeInputParameters(DefaultSchema): name = String(required=True, help='Identifier for this probe.') - lfp_data_path = String(required=True, help='Path to lfp data for this probe') - lfp_timestamps_path = String(required=True, help="Path to aligned lfp timestamps for this probe.") - surface_channel = Int(required=True, help='Estimate of surface (pia boundary) channel index') - reference_channels = List(Int, many=True, help='Indices of reference channels for this probe') - csd_output_path = String(required=True, help='CSD output will be written here.') - sampling_rate = Float(required=True, help='sampling rate assessed on master clock') - total_channels = Int(default=384, help='Total channel count for this probe.') - surface_channel_adjustment = Int(default=40, help='Erring up in the surface channel estimate is less dangerous for the CSD calculation than erring down, so an adjustment is provided.') - spacing = Float(default=0.04, help='distance (in millimiters) between lengthwise-adjacent rows of recording sites on this probe.') - phase = String(required=True, help='The probe type (3a or PXI) which determines if channels need to be reordered') + lfp_data_path = String(required=True, + help='Path to lfp data for this probe') + lfp_timestamps_path = String(required=True, + help="Path to aligned lfp timestamps for " + "this probe.") + surface_channel = Int(required=True, + help='Estimate of surface (pia boundary) channel ' + 'index') + reference_channels = List(Int, many=True, + help='Indices of reference channels for this ' + 'probe') + csd_output_path = String(required=True, + help='CSD output will be written here.') + sampling_rate = Float(required=True, + help='sampling rate assessed on master clock') + total_channels = Int(default=384, + help='Total channel count for this probe.') + surface_channel_adjustment = Int(default=40, + help='Erring up in the surface channel ' + 'estimate is less dangerous for ' + 'the CSD calculation than erring ' + 'down, so an adjustment is ' + 'provided.') + spacing = Float(default=0.04, + help='distance (in millimiters) between ' + 'lengthwise-adjacent rows of recording sites on ' + 'this probe.') + phase = String(required=True, + help='The probe type (3a or PXI) which determines if ' + 'channels need to be reordered') class StimulusInputParameters(DefaultSchema): stimulus_table_path = String(required=True, help='Path to stimulus table') - key = String(required=True, help='CSD is calculated from a specific stimulus, defined (in part) by this key.') - index = Int(default=None, allow_none=True, help='CSD is calculated from a specific stimulus, defined (in part) by this index.') + key = String(required=True, + help='CSD is calculated from a specific stimulus, defined (' + 'in part) by this key.') + index = Int(default=None, allow_none=True, + help='CSD is calculated from a specific stimulus, defined (' + 'in part) by this index.') class InputParameters(ArgSchema): - stimulus = Nested(StimulusInputParameters, required=True, help='Defines the stimulus from which CSD is calculated') - probes = Nested(ProbeInputParameters, many=True, required=True, help='Probewise parameters.') - pre_stimulus_time = Float(required=True, help='how much time pre stimulus onset is used for CSD calculation ') - post_stimulus_time = Float(required=True, help='how much time post stimulus onset is used for CSD calculation ') - num_trials = Int(default=None, allow_none=True, help='Number of trials after stimulus onset from which to compute CSD') - volts_per_bit = Float(default=1.0, help='If the data are not in units of volts, they must be converted. In the past, this value was 0.195') - memmap = Bool(default=False, help='whether to memory map the data file on disk or load it directly to main memory') - memmap_thresh = Float(default=np.inf, help='files larger than this threshold (bytes) will be memmapped, regardless of the memmap setting.') - filter_cuts = List(Float, default=[5.0, 150.0], cli_as_single_argument=True, help='Cutoff frequencies for bandpass filter') + stimulus = Nested(StimulusInputParameters, required=True, + help='Defines the stimulus from which CSD is calculated') + probes = Nested(ProbeInputParameters, many=True, required=True, + help='Probewise parameters.') + pre_stimulus_time = Float(required=True, + help='how much time pre stimulus onset is used ' + 'for CSD calculation ') + post_stimulus_time = Float(required=True, + help='how much time post stimulus onset is ' + 'used for CSD calculation ') + num_trials = Int(default=None, allow_none=True, + help='Number of trials after stimulus onset from which ' + 'to compute CSD') + volts_per_bit = Float(default=1.0, + help='If the data are not in units of volts, ' + 'they must be converted. In the past, ' + 'this value was 0.195') + memmap = Bool(default=False, + help='whether to memory map the data file on disk or load ' + 'it directly to main memory') + memmap_thresh = Float(default=np.inf, + help='files larger than this threshold (bytes) ' + 'will be memmapped, regardless of the memmap ' + 'setting.') + filter_cuts = List(Float, default=[5.0, 150.0], + cli_as_single_argument=True, + help='Cutoff frequencies for bandpass filter') filter_order = Int(default=5, help='Order for bandpass filter') - reorder_channels = Bool(default=True, help='Determines whether LFP channels should be re-ordered') - noisy_channel_threshold = Float(default=1500.0, help='Threshold for removing noisy channels from analysis') + reorder_channels = Bool(default=True, + help='Determines whether LFP channels should be ' + 're-ordered') + noisy_channel_threshold = Float(default=1500.0, + help='Threshold for removing noisy ' + 'channels from analysis') class ProbeOutputParameters(DefaultSchema): name = String(required=True, help='Identifier for this probe.') - csd_path = String(required=True, help='Path to current source density file.') + csd_path = String(required=True, + help='Path to current source density file.') class OutputSchema(DefaultSchema): @@ -52,4 +99,5 @@ class OutputSchema(DefaultSchema): class OutputParameters(OutputSchema): - probe_outputs = Nested(ProbeOutputParameters, many=True, required=True, help='probewise outputs') + probe_outputs = Nested(ProbeOutputParameters, many=True, required=True, + help='probewise outputs') diff --git a/allensdk/brain_observatory/ecephys/lfp_subsampling/__main__.py b/allensdk/brain_observatory/ecephys/lfp_subsampling/__main__.py index f9688db32..7ac8c2e24 100644 --- a/allensdk/brain_observatory/ecephys/lfp_subsampling/__main__.py +++ b/allensdk/brain_observatory/ecephys/lfp_subsampling/__main__.py @@ -33,15 +33,19 @@ # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # -import numpy as np import logging -from ._schemas import InputParameters, OutputParameters -from allensdk.brain_observatory.ecephys.file_io.continuous_file import ContinuousFile -from allensdk.brain_observatory.argschema_utilities import ArgSchemaParserPlus, \ - write_or_print_outputs -from .subsampling import select_channels, subsample_timestamps, subsample_lfp, remove_lfp_offset, remove_lfp_noise +import numpy as np +from allensdk.brain_observatory.argschema_utilities import \ + ArgSchemaParserPlus, \ + write_or_print_outputs +from allensdk.brain_observatory.ecephys.file_io.continuous_file import \ + ContinuousFile +from ._schemas import InputParameters, OutputParameters +from .subsampling import select_channels, subsample_timestamps, \ + subsample_lfp, \ + remove_lfp_offset, remove_lfp_noise logger = logging.getLogger(__name__) @@ -57,7 +61,8 @@ def subsample(args): probe_outputs = [] for probe in args['probes']: logging.info("Sub-sampling LFP for " + probe['name']) - lfp_data_file = ContinuousFile(probe['lfp_input_file_path'], probe['lfp_timestamps_input_path'], + lfp_data_file = ContinuousFile(probe['lfp_input_file_path'], + probe['lfp_timestamps_input_path'], probe['total_channels']) logging.info("loading lfp data...") @@ -68,27 +73,31 @@ def subsample(args): lfp_channel_order = np.arange(0, probe['total_channels']) logging.info("selecting channels...") - channels_to_save, actual_channels = select_channels(probe['total_channels'], - probe['surface_channel'], - params['surface_padding'], - params['start_channel_offset'], - params['channel_stride'], - lfp_channel_order, - probe.get('noisy_channels', []), - params['remove_noisy_channels'], - probe['reference_channels'], - params['remove_reference_channels']) - - ts_subsampled = subsample_timestamps(timestamps, params['temporal_subsampling_factor']) + channels_to_save, actual_channels = select_channels( + probe['total_channels'], + probe['surface_channel'], + params['surface_padding'], + params['start_channel_offset'], + params['channel_stride'], + lfp_channel_order, + probe.get('noisy_channels', []), + params['remove_noisy_channels'], + probe['reference_channels'], + params['remove_reference_channels']) + + ts_subsampled = subsample_timestamps(timestamps, params[ + 'temporal_subsampling_factor']) logging.info("subsampling data...") - lfp_subsampled = subsample_lfp(lfp_raw, channels_to_save, params['temporal_subsampling_factor']) + lfp_subsampled = subsample_lfp(lfp_raw, channels_to_save, + params['temporal_subsampling_factor']) del lfp_raw logging.info("removing offset...") lfp_filtered = remove_lfp_offset(lfp_subsampled, - probe['lfp_sampling_rate'] / params['temporal_subsampling_factor'], + probe['lfp_sampling_rate'] / params[ + 'temporal_subsampling_factor'], params['cutoff_frequency'], params['filter_order']) @@ -97,11 +106,13 @@ def subsample(args): logging.info("Surface channel: " + str(probe['surface_channel'])) logging.info("removing noise...") - lfp = remove_lfp_noise(lfp_filtered, probe['surface_channel'], actual_channels) + lfp = remove_lfp_noise(lfp_filtered, probe['surface_channel'], + actual_channels) del lfp_filtered if params['remove_channels_out_of_brain']: - channels_to_keep = actual_channels < (probe['surface_channel'] + 10) + channels_to_keep = actual_channels < ( + probe['surface_channel'] + 10) actual_channels = actual_channels[channels_to_keep] lfp = lfp[:, channels_to_keep] @@ -112,14 +123,17 @@ def subsample(args): probe_outputs.append({'name': probe['name'], 'lfp_data_path': probe['lfp_data_path'], - 'lfp_timestamps_path': probe['lfp_timestamps_path'], - 'lfp_channel_info_path': probe['lfp_channel_info_path']}) + 'lfp_timestamps_path': probe[ + 'lfp_timestamps_path'], + 'lfp_channel_info_path': probe[ + 'lfp_channel_info_path']}) return {'probe_outputs': probe_outputs} def main(): - mod = ArgSchemaParserPlus(schema_type=InputParameters, output_schema_type=OutputParameters) + mod = ArgSchemaParserPlus(schema_type=InputParameters, + output_schema_type=OutputParameters) output = subsample(mod.args) write_or_print_outputs(data=output, parser=mod) diff --git a/allensdk/brain_observatory/ecephys/optotagging_table/__main__.py b/allensdk/brain_observatory/ecephys/optotagging_table/__main__.py index ff8ee2f4c..24c5813a2 100644 --- a/allensdk/brain_observatory/ecephys/optotagging_table/__main__.py +++ b/allensdk/brain_observatory/ecephys/optotagging_table/__main__.py @@ -1,6 +1,7 @@ import pandas as pd -from allensdk.brain_observatory.argschema_utilities import ArgSchemaParserPlus, \ +from allensdk.brain_observatory.argschema_utilities import \ + ArgSchemaParserPlus, \ write_or_print_outputs from allensdk.brain_observatory.ecephys.file_io.ecephys_sync_dataset import ( EcephysSyncDataset, @@ -9,7 +10,6 @@ def build_opto_table(args): - opto_file = pd.read_pickle(args['opto_pickle_path']) sync_file = EcephysSyncDataset.factory(args['sync_h5_path']) @@ -19,7 +19,9 @@ def build_opto_table(args): assert len(conditions) == len(levels) if len(start_times) > len(conditions): - raise ValueError(f"there are {len(start_times) - len(conditions)} extra optotagging sync times!") + raise ValueError( + f"there are {len(start_times) - len(conditions)} extra " + f"optotagging sync times!") optotagging_table = pd.DataFrame({ 'start_time': start_times, @@ -40,15 +42,16 @@ def build_opto_table(args): optotagging_table["stop_time"] = stop_times optotagging_table["stimulus_name"] = names optotagging_table["condition"] = conditions - optotagging_table["duration"] = optotagging_table["stop_time"] - optotagging_table["start_time"] + optotagging_table["duration"] = \ + optotagging_table["stop_time"] - optotagging_table["start_time"] optotagging_table.to_csv(args['output_opto_table_path'], index=False) return {'output_opto_table_path': args['output_opto_table_path']} def main(): - - mod = ArgSchemaParserPlus(schema_type=InputParameters, output_schema_type=OutputParameters) + mod = ArgSchemaParserPlus(schema_type=InputParameters, + output_schema_type=OutputParameters) output = build_opto_table(mod.args) write_or_print_outputs(data=output, parser=mod) diff --git a/allensdk/brain_observatory/ecephys/stimulus_analysis/__main__.py b/allensdk/brain_observatory/ecephys/stimulus_analysis/__main__.py index d90bbd65a..e5197864e 100644 --- a/allensdk/brain_observatory/ecephys/stimulus_analysis/__main__.py +++ b/allensdk/brain_observatory/ecephys/stimulus_analysis/__main__.py @@ -1,42 +1,43 @@ -from argschema import ArgSchemaParser -import time +import logging import os import pathlib -import pandas as pd +import time + import numpy as np -import logging +import pandas as pd +from argschema import ArgSchemaParser from allensdk.brain_observatory.argschema_utilities import \ write_or_print_outputs -from ..ecephys_session import EcephysSession -from .drifting_gratings import DriftingGratings -from .static_gratings import StaticGratings -from .natural_scenes import NaturalScenes -from .natural_movies import NaturalMovies from .dot_motion import DotMotion +from .drifting_gratings import DriftingGratings from .flashes import Flashes +from .natural_movies import NaturalMovies +from .natural_scenes import NaturalScenes from .receptive_field_mapping import ReceptiveFieldMapping +from .static_gratings import StaticGratings +from ..ecephys_session import EcephysSession try: from mpi4py import MPI + comm = MPI.COMM_WORLD MPI_rank = comm.Get_rank() MPI_size = comm.Get_size() barrier = comm.Barrier gather = comm.gather -except ModuleNotFoundError as e: +except ModuleNotFoundError: # Run without mpi4py installed MPI_rank = 0 MPI_size = 1 - barrier = lambda: None - gather = lambda data, root: data - + barrier = lambda: None # noqa F841 + gather = lambda data, root: data # noqa F841 logger = logging.getLogger(__name__) - # Map between json file subsections and StimAnalysis subclass -# TODO: Try to order this list by how long each subclass takes to finish. Helps spread work evenly across cores +# TODO: Try to order this list by how long each subclass takes to finish. +# Helps spread work evenly across cores stim_classes = [ ('receptive_field_mapping', ReceptiveFieldMapping), ('drifting_gratings', DriftingGratings), @@ -58,29 +59,38 @@ def load_session(nwb_path, stimulus_class, **session_params): "amplitude_cutoff_maximum": np.inf, "presence_ratio_minimum": -np.inf, "isi_violations_maximum": np.inf, - "filter_by_validity": False # actually you probably still want this one + "filter_by_validity": False + # actually you probably still want this one }) return stimulus_class(session, **session_params) """ -NOTE: There are two version of caclulate_stimulus_metrics, both should produce the same results but have different ways -of working across multiple cores. The best one to use will depend on the data and the limitations of lims/ - -caclulate_stimulus_metrics_ondisk - each core calculates the individual metrics and saves them to a temporary csv file. -Rank 0 then reads each csv and cobmines them into the final result. A more memory efficient way, however will be slower +NOTE: There are two version of caclulate_stimulus_metrics, both should +produce the same results but have different ways +of working across multiple cores. The best one to use will depend on the +data and the limitations of lims/ + +caclulate_stimulus_metrics_ondisk - each core calculates the individual +metrics and saves them to a temporary csv file. +Rank 0 then reads each csv and cobmines them into the final result. A more +memory efficient way, however will be slower due to the cost of reading/writing to disk multiple times. -caclulate_stimulus_metrics_gather - runs each metric across different cores, but uses the MPI Gather() method to send -all the combined dataframes to Rank 0 where it is collolated and saved to disk. Should run faster but can use up to +caclulate_stimulus_metrics_gather - runs each metric across different cores, +but uses the MPI Gather() method to send +all the combined dataframes to Rank 0 where it is collolated and saved to +disk. Should run faster but can use up to 2x the amount of memory. """ def calculate_stimulus_metrics_ondisk(args): - """Runs the individual metrics for a given session, combines and saves them into a single table. + """Runs the individual metrics for a given session, combines and saves + them into a single table. - Same as below except pass the metric tables between ranks by writing/reading to a file. + Same as below except pass the metric tables between ranks by + writing/reading to a file. """ log_info('ecephys: stimulus metrics module') start = time.time() @@ -88,24 +98,32 @@ def calculate_stimulus_metrics_ondisk(args): input_session_nwb = args['input_session_nwb'] output_file = args['output_file'] - # For each stimulus class that needs to be processed; calculate and save the metrics on a different rank (unless + # For each stimulus class that needs to be processed; calculate and save + # the metrics on a different rank (unless # MPI_size is small and one rank has to process two or more metrics). def _temp_csv_file(stim_class): - # filename to save temporary stim_analysis csv files before being merged into final + # filename to save temporary stim_analysis csv files before being + # merged into final output_dir = pathlib.Path(output_file).parents[0] session_name = pathlib.Path(input_session_nwb).stem - return os.path.join(output_dir, '{}.{}.csv'.format(session_name, stim_class)) + return os.path.join(output_dir, + '{}.{}.csv'.format(session_name, stim_class)) relevant_stim_class = [(sc[0], sc[1], _temp_csv_file(sc[0])) - for sc in stim_classes if sc[0] in args] # only stims specified in the input json - for sc_name, stim_class, tmp_csv in relevant_stim_class[MPI_rank::MPI_size]: - analysis_obj = load_session(input_session_nwb, stim_class, **args[sc_name]) + for sc in stim_classes if sc[ + 0] in args] # only stims specified in the + # input json + for sc_name, stim_class, tmp_csv in relevant_stim_class[ + MPI_rank::MPI_size]: + analysis_obj = load_session(input_session_nwb, stim_class, + **args[sc_name]) # analysis_obj = stim_class(input_session_nwb, **args[sc_name]) analysis_obj.metrics.to_csv(tmp_csv) barrier() # wait till all the csv files have been created - # Have the first rank go through all the created csv files and merge into one + # Have the first rank go through all the created csv files and merge + # into one if MPI_rank == 0: final_table = pd.read_csv(relevant_stim_class[0][2]) for _, _, tmp_csv in relevant_stim_class[1:]: @@ -119,7 +137,7 @@ def _temp_csv_file(stim_class): if os.path.exists(tmp_csv): try: os.remove(tmp_csv) - except Exception as e: + except Exception: pass barrier() @@ -130,7 +148,8 @@ def _temp_csv_file(stim_class): def calculate_stimulus_metrics_gather(args): - """Runs the individual metrics for a given session, combines and saves them into a single table. + """Runs the individual metrics for a given session, combines and saves + them into a single table. Same as above but uses MPI Gather to send the dataframes across ranks """ @@ -140,12 +159,15 @@ def calculate_stimulus_metrics_gather(args): input_session_nwb = args['input_session_nwb'] output_file = args['output_file'] - # Divide the work across the ranks, calculate each metric and combine all the result on each rank. + # Divide the work across the ranks, calculate each metric and combine + # all the result on each rank. combined_df = None - relevant_stim_class = [(sc[0], sc[1]) for sc in stim_classes if sc[0] in args] # metrics for this rank + relevant_stim_class = [(sc[0], sc[1]) for sc in stim_classes if + sc[0] in args] # metrics for this rank if MPI_rank < len(relevant_stim_class): for sc_name, stim_class in relevant_stim_class[MPI_rank::MPI_size]: - analysis_obj = load_session(input_session_nwb, stim_class, **args[sc_name]) + analysis_obj = load_session(input_session_nwb, stim_class, + **args[sc_name]) analysis_df = analysis_obj.metrics if combined_df is None: @@ -160,7 +182,8 @@ def calculate_stimulus_metrics_gather(args): execution_time = time.time() - start return {"execution_time": execution_time} - # Use MPI Gather to send the combined_df on each rank to Rank 0 where it will be collolated and saved + # Use MPI Gather to send the combined_df on each rank to Rank 0 where it + # will be collolated and saved all_ranks_data = gather(combined_df, root=0) if MPI_rank == 0: final_df = all_ranks_data[0] @@ -179,7 +202,8 @@ def calculate_stimulus_metrics_gather(args): def main(): from ._schemas import InputParameters, OutputParameters - mod = ArgSchemaParser(schema_type=InputParameters, output_schema_type=OutputParameters) + mod = ArgSchemaParser(schema_type=InputParameters, + output_schema_type=OutputParameters) # output = calculate_stimulus_metrics_ondisk(mod.args) output = calculate_stimulus_metrics_gather(mod.args) if MPI_rank == 0: diff --git a/allensdk/brain_observatory/ecephys/stimulus_table/__main__.py b/allensdk/brain_observatory/ecephys/stimulus_table/__main__.py index b7db75cae..167cbfe89 100644 --- a/allensdk/brain_observatory/ecephys/stimulus_table/__main__.py +++ b/allensdk/brain_observatory/ecephys/stimulus_table/__main__.py @@ -1,19 +1,16 @@ import functools -import warnings -import re -import pandas as pd import numpy as np +from allensdk.brain_observatory.argschema_utilities import \ + ArgSchemaParserPlus, \ + write_or_print_outputs from allensdk.brain_observatory.ecephys.file_io.ecephys_sync_dataset import ( EcephysSyncDataset, ) -from allensdk.brain_observatory.argschema_utilities import ArgSchemaParserPlus, \ - write_or_print_outputs from allensdk.brain_observatory.ecephys.file_io.stim_file import ( CamStimOnePickleStimFile, ) - from . import ephys_pre_spikes from . import naming_utilities from . import output_validation @@ -21,32 +18,33 @@ def build_stimulus_table( - stimulus_pkl_path, - sync_h5_path, - frame_time_strategy, - minimum_spontaneous_activity_duration, - extract_const_params_from_repr, - drop_const_params, - maximum_expected_spontanous_activity_duration, - stimulus_name_map, - column_name_map, - output_stimulus_table_path, - output_frame_times_path, - fail_on_negative_duration, - **kwargs + stimulus_pkl_path, + sync_h5_path, + frame_time_strategy, + minimum_spontaneous_activity_duration, + extract_const_params_from_repr, + drop_const_params, + maximum_expected_spontanous_activity_duration, + stimulus_name_map, + column_name_map, + output_stimulus_table_path, + output_frame_times_path, + fail_on_negative_duration, + **kwargs ): - stim_file = CamStimOnePickleStimFile.factory(stimulus_pkl_path) sync_dataset = EcephysSyncDataset.factory(sync_h5_path) - frame_times = sync_dataset.extract_frame_times(strategy=frame_time_strategy) + frame_times = sync_dataset.extract_frame_times( + strategy=frame_time_strategy) + + def seconds_to_frames(seconds): + return \ + (np.array(seconds) + stim_file.pre_blank_sec) * \ + stim_file.frames_per_second - seconds_to_frames = ( - lambda seconds: (np.array(seconds) + stim_file.pre_blank_sec) - * stim_file.frames_per_second - ) minimum_spontaneous_activity_duration = ( - minimum_spontaneous_activity_duration / stim_file.frames_per_second + minimum_spontaneous_activity_duration / stim_file.frames_per_second ) stimulus_tabler = functools.partial( @@ -67,19 +65,23 @@ def build_stimulus_table( stim_table_full, frame_times, stim_file.frames_per_second, True ) - output_validation.validate_epoch_durations(stim_table_full, fail_on_negative_durations=fail_on_negative_duration) + output_validation.validate_epoch_durations( + stim_table_full, fail_on_negative_durations=fail_on_negative_duration) output_validation.validate_max_spontaneous_epoch_duration( stim_table_full, maximum_expected_spontanous_activity_duration ) stim_table_full = naming_utilities.collapse_columns(stim_table_full) stim_table_full = naming_utilities.drop_empty_columns(stim_table_full) - stim_table_full = naming_utilities.standardize_movie_numbers(stim_table_full) - stim_table_full = naming_utilities.add_number_to_shuffled_movie(stim_table_full) + stim_table_full = naming_utilities.standardize_movie_numbers( + stim_table_full) + stim_table_full = naming_utilities.add_number_to_shuffled_movie( + stim_table_full) stim_table_full = naming_utilities.map_stimulus_names( stim_table_full, stimulus_name_map ) - stim_table_full = naming_utilities.map_column_names(stim_table_full, column_name_map) + stim_table_full = naming_utilities.map_column_names(stim_table_full, + column_name_map) stim_table_full.to_csv(output_stimulus_table_path, index=False) np.save(output_frame_times_path, frame_times, allow_pickle=False) @@ -90,7 +92,6 @@ def build_stimulus_table( def main(): - mod = ArgSchemaParserPlus( schema_type=InputParameters, output_schema_type=OutputSchema ) diff --git a/allensdk/brain_observatory/extract_running_speed/__main__.py b/allensdk/brain_observatory/extract_running_speed/__main__.py index 6e6680aee..43b9ea375 100644 --- a/allensdk/brain_observatory/extract_running_speed/__main__.py +++ b/allensdk/brain_observatory/extract_running_speed/__main__.py @@ -3,14 +3,13 @@ import numpy as np import pandas as pd -from allensdk.brain_observatory.sync_dataset import Dataset from allensdk.brain_observatory import sync_utilities -from allensdk.brain_observatory.argschema_utilities import ArgSchemaParserPlus, \ +from allensdk.brain_observatory.argschema_utilities import \ + ArgSchemaParserPlus, \ write_or_print_outputs - +from allensdk.brain_observatory.sync_dataset import Dataset from ._schemas import InputParameters, OutputParameters - DEGREES_TO_RADIANS = np.pi / 180.0 @@ -26,11 +25,11 @@ def check_encoder(parent, key): def running_from_stim_file(stim_file, key, expected_length): if "behavior" in stim_file["items"] and check_encoder( - stim_file["items"]["behavior"], key + stim_file["items"]["behavior"], key ): return stim_file["items"]["behavior"]["encoders"][0][key][:] if "foraging" in stim_file["items"] and check_encoder( - stim_file["items"]["foraging"], key + stim_file["items"]["foraging"], key ): return stim_file["items"]["foraging"]["encoders"][0][key][:] if key in stim_file: @@ -49,9 +48,9 @@ def angular_to_linear_velocity(angular_velocity, radius): def extract_running_speeds( - frame_times, dx_deg, vsig, vin, wheel_radius, subject_position, use_median_duration=False + frame_times, dx_deg, vsig, vin, wheel_radius, subject_position, + use_median_duration=False ): - # the first interval does not have a known start time, so we can't compute # an average velocity from dx dx_rad = degrees_to_radians(dx_deg[1:]) @@ -86,10 +85,9 @@ def extract_running_speeds( def main( - stimulus_pkl_path, sync_h5_path, output_path, wheel_radius, - subject_position, use_median_duration, **kwargs + stimulus_pkl_path, sync_h5_path, output_path, wheel_radius, + subject_position, use_median_duration, **kwargs ): - stim_file = pd.read_pickle(stimulus_pkl_path) sync_dataset = Dataset(sync_h5_path) @@ -102,7 +100,7 @@ def main( "rising", Dataset.FRAME_KEYS, units="seconds" ) - # occasionally an extra set of frame times are acquired after the rest of + # occasionally an extra set of frame times are acquired after the rest of # the signals. We detect and remove these frame_times = sync_utilities.trim_discontiguous_times(frame_times) num_raw_timestamps = len(frame_times) @@ -141,7 +139,6 @@ def main( if __name__ == "__main__": - mod = ArgSchemaParserPlus( schema_type=InputParameters, output_schema_type=OutputParameters ) diff --git a/allensdk/brain_observatory/gaze_mapping/_schemas.py b/allensdk/brain_observatory/gaze_mapping/_schemas.py index d8c98a00b..67829c7bb 100644 --- a/allensdk/brain_observatory/gaze_mapping/_schemas.py +++ b/allensdk/brain_observatory/gaze_mapping/_schemas.py @@ -9,7 +9,6 @@ class InputSchema(ArgSchema): - # ============== Required fields ============== input_file = InputFile( required=True, @@ -104,6 +103,7 @@ class InputSchema(ArgSchema): class OutputSchema(RaisingSchema): input_parameters = Nested(InputSchema) screen_mapping_file = OutputFile(required=True, - description=('Full save path of output h5 ' - 'file that will be created ' - 'by this module.')) + description=( + 'Full save path of output h5 ' + 'file that will be created ' + 'by this module.')) diff --git a/allensdk/brain_observatory/ophys/trace_extraction/_schemas.py b/allensdk/brain_observatory/ophys/trace_extraction/_schemas.py index d6a1b49da..89c6528b7 100644 --- a/allensdk/brain_observatory/ophys/trace_extraction/_schemas.py +++ b/allensdk/brain_observatory/ophys/trace_extraction/_schemas.py @@ -1,14 +1,15 @@ -from marshmallow import RAISE, ValidationError +from argschema import ArgSchema +from argschema.fields import LogLevel, String, Nested, Boolean, Float, List, \ + Integer +from marshmallow import RAISE -from argschema import ArgSchema, ArgSchemaParser -from argschema.schemas import DefaultSchema -from argschema.fields import LogLevel, String, Nested, Boolean, Float, List, Integer - -from allensdk.brain_observatory.argschema_utilities import check_read_access, check_write_access, RaisingSchema +from allensdk.brain_observatory.argschema_utilities import RaisingSchema class MotionBorder(RaisingSchema): - x0 = Float(default=0.0, description='') # TODO: be really certain about how these relate to physical space and then write it here + x0 = Float(default=0.0, + description='') # TODO: be really certain about how these + # relate to physical space and then write it here x1 = Float(default=0.0, description='') y0 = Float(default=0.0, description='') y1 = Float(default=0.0, description='') @@ -16,13 +17,20 @@ class MotionBorder(RaisingSchema): class Roi(RaisingSchema): mask = List(List(Boolean), required=True, description='raster mask') - y = Integer(required=True, description='y position (pixels) of mask\'s bounding box') - x = Integer(required=True, description='x position (pixels) of mask\'s bounding box') - width = Integer(required=True, description='width (pixels)of mask\'s bounding box') - height = Integer(required=True, description='height (pixels) of mask\'s bounding box') + y = Integer(required=True, + description='y position (pixels) of mask\'s bounding box') + x = Integer(required=True, + description='x position (pixels) of mask\'s bounding box') + width = Integer(required=True, + description='width (pixels)of mask\'s bounding box') + height = Integer(required=True, + description='height (pixels) of mask\'s bounding box') valid = Boolean(default=True, description='Is this Roi known to be valid?') - id = Integer(required=True, description='unique integer identifier for this Roi') - mask_page = Integer(default=-1, description='') # TODO: this isn't in the examples I'm looking at. What is it? + id = Integer(required=True, + description='unique integer identifier for this Roi') + mask_page = Integer(default=-1, + description='') # TODO: this isn't in the examples + # I'm looking at. What is it? class ExclusionLabel(RaisingSchema): @@ -32,18 +40,35 @@ class ExclusionLabel(RaisingSchema): class InputSchema(ArgSchema): class Meta: - unknown=RAISE - log_level = LogLevel(default='INFO', description='set the logging level of the module') - motion_border = Nested(MotionBorder, required=True, description='border widths - pixels outside the border are considered invalid') - storage_directory = String(required=True, description='used to set output directory') - motion_corrected_stack = String(required=True, description='path to h5 file containing motion corrected image stack') - rois = Nested(Roi, many=True, description='specifications of individual regions of interest') - log_0 = String(required=True, description='path to motion correction output csv') # TODO: is this redundant with motion border? + unknown = RAISE + + log_level = LogLevel(default='INFO', + description='set the logging level of the module') + motion_border = Nested(MotionBorder, required=True, + description='border widths - pixels outside the ' + 'border are considered invalid') + storage_directory = String(required=True, + description='used to set output directory') + motion_corrected_stack = String(required=True, + description='path to h5 file containing ' + 'motion corrected image stack') + rois = Nested(Roi, many=True, + description='specifications of individual regions of ' + 'interest') + log_0 = String(required=True, + description='path to motion correction output csv') # + # TODO: is this redundant with motion border? class OutputSchema(RaisingSchema): input_parameters = Nested(InputSchema) - neuropil_trace_file = String(required=True, description='path to output h5 file containing neuropil traces') # TODO rename these to _path - roi_trace_file = String(required=True, description='path to output h5 file containing roi traces') - exclusion_labels = Nested(ExclusionLabel, many=True, description='a report of roi-wise problems detected during extraction') - \ No newline at end of file + neuropil_trace_file = String( + required=True, + description='path to output h5 file containing neuropil traces') # + # TODO rename these to _path + roi_trace_file = String( + required=True, + description='path to output h5 file containing roi traces') + exclusion_labels = Nested( + ExclusionLabel, many=True, + description='a report of roi-wise problems detected during extraction') diff --git a/allensdk/mouse_connectivity/grid/__main__.py b/allensdk/mouse_connectivity/grid/__main__.py index aa2fedad5..a2c14804c 100755 --- a/allensdk/mouse_connectivity/grid/__main__.py +++ b/allensdk/mouse_connectivity/grid/__main__.py @@ -1,21 +1,20 @@ -import logging -import pprint -import sys import argparse +import logging import os +import sys import argschema import requests from allensdk.brain_observatory.argschema_utilities import \ write_or_print_outputs -from ._schemas import InputParameters, OutputParameters from . import cases +from ._schemas import InputParameters, OutputParameters from .image_series_gridder import ImageSeriesGridder -def get_inputs_from_lims(host, image_series_id, output_root, job_queue, strategy): - +def get_inputs_from_lims(host, image_series_id, output_root, job_queue, + strategy): uri = ''.join(''' {}/input_jsons? object_id={}& @@ -29,15 +28,17 @@ def get_inputs_from_lims(host, image_series_id, output_root, job_queue, strategy if len(data) == 1 and 'error' in data: raise ValueError('bad request uri: {} ({})'.format(uri, data['error'])) - data['storage_directory'] = os.path.join(output_root, os.path.split(data['storage_directory'])[-1]) - data['grid_prefix'] = os.path.join(output_root, os.path.split(data['grid_prefix'])[-1]) - data['accumulator_prefix'] = os.path.join(output_root, os.path.split(data['accumulator_prefix'])[-1]) + data['storage_directory'] = os.path.join(output_root, os.path.split( + data['storage_directory'])[-1]) + data['grid_prefix'] = os.path.join(output_root, + os.path.split(data['grid_prefix'])[-1]) + data['accumulator_prefix'] = os.path.join(output_root, os.path.split( + data['accumulator_prefix'])[-1]) return data def run_grid(args): - try: case = cases[args['case']] except KeyError: @@ -45,15 +46,14 @@ def run_grid(args): raise sub_images = args['sub_images'] - - input_dimensions = [sub_images[0]['dimensions']['column'], - sub_images[0]['dimensions']['row'], - args['sub_image_count']] + input_dimensions = [sub_images[0]['dimensions']['column'], + sub_images[0]['dimensions']['row'], + args['sub_image_count']] - input_spacing = [sub_images[0]['spacing']['column'], - sub_images[0]['spacing']['row'], - args['image_series_slice_spacing']] + input_spacing = [sub_images[0]['spacing']['column'], + sub_images[0]['spacing']['row'], + args['image_series_slice_spacing']] for ii, si in enumerate(sub_images): del si['dimensions'] @@ -65,12 +65,12 @@ def run_grid(args): len(sub_images), [si['specimen_tissue_index'] for si in sub_images]) ) - output_dimensions = [args['reference_dimensions']['slice'], - args['reference_dimensions']['row'], + output_dimensions = [args['reference_dimensions']['slice'], + args['reference_dimensions']['row'], args['reference_dimensions']['column']] - output_spacing = [args['reference_spacing']['slice'], - args['reference_spacing']['row'], + output_spacing = [args['reference_spacing']['slice'], + args['reference_spacing']['row'], args['reference_spacing']['column']] subimage_kwargs = {'cls': case['subimage']} @@ -78,15 +78,15 @@ def run_grid(args): subimage_kwargs['filter_bit'] = args['filter_bit'] gridder = ImageSeriesGridder( - in_dims=input_dimensions, - in_spacing=input_spacing, - out_dims=output_dimensions, - out_spacing=output_spacing, - reduce_level=args['reduce_level'], - subimages=sub_images, - subimage_kwargs=subimage_kwargs, - nprocesses=args['nprocesses'], - affine_params=args['affine_params'], + in_dims=input_dimensions, + in_spacing=input_spacing, + out_dims=output_dimensions, + out_spacing=output_spacing, + reduce_level=args['reduce_level'], + subimages=sub_images, + subimage_kwargs=subimage_kwargs, + nprocesses=args['nprocesses'], + affine_params=args['affine_params'], dfmfld_path=args['deformation_field_path'] ) @@ -94,14 +94,15 @@ def run_grid(args): gridder.build_coarse_grids() writer = case['writer'] - paths = writer(gridder, args['grid_prefix'], args['accumulator_prefix'], target_spacings=args['target_spacings']) + paths = writer(gridder, args['grid_prefix'], args['accumulator_prefix'], + target_spacings=args['target_spacings']) return {'output_file_paths': paths} def main(): - - logging.basicConfig(format='%(asctime)s - %(process)s - %(levelname)s - %(message)s') + logging.basicConfig( + format='%(asctime)s - %(process)s - %(levelname)s - %(message)s') # TODO replace with argschema implementation of multisource parser remaining_args = sys.argv[1:] @@ -110,12 +111,14 @@ def main(): lims_parser = argparse.ArgumentParser(add_help=False) lims_parser.add_argument('--host', type=str, default='http://lims2') lims_parser.add_argument('--job_queue', type=str, default=None) - lims_parser.add_argument('--strategy', type=str,default= None) + lims_parser.add_argument('--strategy', type=str, default=None) lims_parser.add_argument('--image_series_id', type=int, default=None) - lims_parser.add_argument('--output_root', type=str, default= None) + lims_parser.add_argument('--output_root', type=str, default=None) - lims_args, remaining_args = lims_parser.parse_known_args(remaining_args) - remaining_args = [item for item in remaining_args if item != '--get_inputs_from_lims'] + lims_args, remaining_args = lims_parser.parse_known_args( + remaining_args) + remaining_args = [item for item in remaining_args if + item != '--get_inputs_from_lims'] input_data = get_inputs_from_lims(**lims_args.__dict__) parser = argschema.ArgSchemaParser( diff --git a/allensdk/mouse_connectivity/grid/_schemas.py b/allensdk/mouse_connectivity/grid/_schemas.py index 29771076a..02228dd93 100755 --- a/allensdk/mouse_connectivity/grid/_schemas.py +++ b/allensdk/mouse_connectivity/grid/_schemas.py @@ -1,9 +1,7 @@ -from marshmallow import RAISE -from argschema import ArgSchema, ArgSchemaParser +from argschema import ArgSchema +from argschema.fields import Nested, String, Float, Dict, Int, List, LogLevel from argschema.schemas import DefaultSchema -from argschema.fields import Nested, InputDir, String, Float, Dict, Int, List, Bool, LogLevel -import numpy as np - +from marshmallow import RAISE VALID_CASES = ( 'classic', @@ -14,7 +12,7 @@ class RaisingSchema(DefaultSchema): class META: - unknown=RAISE + unknown = RAISE class ImageSpacing(RaisingSchema): @@ -50,34 +48,58 @@ class SubImage(RaisingSchema): class InputParameters(ArgSchema): class Meta: - unknown=RAISE - - log_level = LogLevel(default='INFO',description="set the logging level of the module") - case = String(required=True, validate=lambda s: s in VALID_CASES, help='select a use case to run') - sub_images = Nested(SubImage, required=True, many=True, help='Sub images composing this image series') - affine_params = List(Float, help='Parameters of affine image stack to reference space transform.') - deformation_field_path = String(required=True, - help='Path to parameters of the deformable local transform from affine-transformed image stack to reference space transform.' - ) - image_series_slice_spacing = Float(required=True, help='Distance (microns) between successive images in this series.') - target_spacings = List(Float, required=True, help='For each volume produced, downsample to this isometric resolution') - reference_spacing = Nested(ReferenceSpacing, required=True, help='Native spacing of reference space (microns).') - reference_dimensions = Nested(ReferenceDimensions, required=True, help='Native dimensions of reference space.') + unknown = RAISE + + log_level = LogLevel(default='INFO', + description="set the logging level of the module") + case = String(required=True, validate=lambda s: s in VALID_CASES, + help='select a use case to run') + sub_images = Nested(SubImage, required=True, many=True, + help='Sub images composing this image series') + affine_params = List(Float, + help='Parameters of affine image stack to reference ' + 'space transform.') + deformation_field_path = String(required=True, + help='Path to parameters of the ' + 'deformable local transform from ' + 'affine-transformed image stack to ' + 'reference space transform.' + ) + image_series_slice_spacing = Float(required=True, + help='Distance (microns) between ' + 'successive images in this ' + 'series.') + target_spacings = List(Float, required=True, + help='For each volume produced, downsample to ' + 'this isometric resolution') + reference_spacing = Nested(ReferenceSpacing, required=True, + help='Native spacing of reference space (' + 'microns).') + reference_dimensions = Nested(ReferenceDimensions, required=True, + help='Native dimensions of reference space.') sub_image_count = Int(required=True, help='Expected number of sub images') grid_prefix = String(required=True, help='Write output grid files here') - accumulator_prefix = String(required=True, help='If this run produces accumulators, write them here.') - storage_directory = String(required=False, help='Storage directory for this image series. Not used') - filter_bit = Int(default=None, allow_none=True, help='if provided, signals that pixels with this bit high have passed the optional post-filter stage') + accumulator_prefix = String(required=True, + help='If this run produces accumulators, ' + 'write them here.') + storage_directory = String(required=False, + help='Storage directory for this image ' + 'series. Not used') + filter_bit = Int(default=None, allow_none=True, + help='if provided, signals that pixels with this bit ' + 'high have passed the optional post-filter stage') nprocesses = Int(default=8, help='spawn this many worker subprocesses') - reduce_level = Int(default=0, help='power of two by which to downsample each input axis') + reduce_level = Int(default=0, + help='power of two by which to downsample each input ' + 'axis') -class OutputSchema(RaisingSchema): - input_parameters = Nested(InputParameters, - description=("Input parameters the module " - "was run with"), - required=True) +class OutputSchema(RaisingSchema): + input_parameters = Nested(InputParameters, + description=("Input parameters the module " + "was run with"), + required=True) -class OutputParameters(OutputSchema): +class OutputParameters(OutputSchema): output_file_paths = List(String, required=True) From f39fc147b7ea7b91de8f23957f4b052b873cb8f8 Mon Sep 17 00:00:00 2001 From: Doug Ollerenshaw Date: Thu, 1 Apr 2021 08:18:12 -0700 Subject: [PATCH 19/86] add warning back to sync_dataset, get rid or deprecated keys --- allensdk/brain_observatory/sync_dataset.py | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/allensdk/brain_observatory/sync_dataset.py b/allensdk/brain_observatory/sync_dataset.py index 539e87ac5..4827649fa 100644 --- a/allensdk/brain_observatory/sync_dataset.py +++ b/allensdk/brain_observatory/sync_dataset.py @@ -103,14 +103,23 @@ class Dataset(object): "cam1_exposure", "behavior_monitoring") - DEPRECATED_KEYS = {"cam2_exposure", - "eyetracking", - "eye_tracking", - "cam1_exposure", - "behavior_monitoring"} + DEPRECATED_KEYS = {} def __init__(self, path): self.dfile = self.load(path) + self._check_line_labels() + + def _check_line_labels(self): + if hasattr(self, "line_labels"): + deprecated_keys = set(self.line_labels) & self.DEPRECATED_KEYS + if deprecated_keys: + warnings.warn((f"The loaded sync file contains the " + f"following deprecated line label keys: " + f"{deprecated_keys}. Consider updating the sync " + f"file line labels."), stacklevel=2) + else: + warnings.warn((f"The loaded sync file has no line labels and may " + f"not be valid."), stacklevel=2) def _process_times(self): """ From 383b9da4aeb2e4f73ce52f04caf78d46ff5ba2e7 Mon Sep 17 00:00:00 2001 From: Doug Ollerenshaw Date: Thu, 1 Apr 2021 08:32:10 -0700 Subject: [PATCH 20/86] ticket 1502 - linting --- allensdk/brain_observatory/sync_dataset.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/allensdk/brain_observatory/sync_dataset.py b/allensdk/brain_observatory/sync_dataset.py index 4827649fa..cbd31312f 100644 --- a/allensdk/brain_observatory/sync_dataset.py +++ b/allensdk/brain_observatory/sync_dataset.py @@ -20,6 +20,7 @@ import h5py as h5 import numpy as np +import warnings import logging logger = logging.getLogger(__name__) @@ -115,11 +116,11 @@ def _check_line_labels(self): if deprecated_keys: warnings.warn((f"The loaded sync file contains the " f"following deprecated line label keys: " - f"{deprecated_keys}. Consider updating the sync " - f"file line labels."), stacklevel=2) + f"{deprecated_keys}. Consider updating the " + f"sync file line labels."), stacklevel=2) else: - warnings.warn((f"The loaded sync file has no line labels and may " - f"not be valid."), stacklevel=2) + warnings.warn(("The loaded sync file has no line labels and may " + "not be valid."), stacklevel=2) def _process_times(self): """ From 6ff5dba5e7f9240bde1a5cb4ff23285875dc04c5 Mon Sep 17 00:00:00 2001 From: aamster Date: Thu, 1 Apr 2021 09:20:00 -0700 Subject: [PATCH 21/86] argschema version is upper bounded --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 9f5da6185..63baf0595 100644 --- a/requirements.txt +++ b/requirements.txt @@ -15,7 +15,7 @@ scikit-image>=0.14.0,<0.17.0 scikit-build<1.0.0 statsmodels==0.9.0 simpleitk<2.0.0 -argschema==2.0.2 +argschema<3.0.0 marshmallow==3.0.0rc6 glymur==0.8.19 xarray<0.16.0 From f40296be08b34a42cda3313ea97c89b4223ba949 Mon Sep 17 00:00:00 2001 From: aamster Date: Thu, 1 Apr 2021 10:12:19 -0700 Subject: [PATCH 22/86] Fixes test by sorting dfs prior to comparison --- .../test_behavior_project_metadata_writer.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/allensdk/test/brain_observatory/behavior/behavior_project_cache/test_behavior_project_metadata_writer.py b/allensdk/test/brain_observatory/behavior/behavior_project_cache/test_behavior_project_metadata_writer.py index 41fd73b5e..5b56e96b6 100644 --- a/allensdk/test/brain_observatory/behavior/behavior_project_cache/test_behavior_project_metadata_writer.py +++ b/allensdk/test/brain_observatory/behavior/behavior_project_cache/test_behavior_project_metadata_writer.py @@ -29,6 +29,13 @@ def convert_strings_to_lists(df, is_session=True): .apply(lambda x: literal_eval(x)) +def sort_df(df: pd.DataFrame, sort_col: str): + """Sorts df for comparison""" + return df.sort_values(sort_col)\ + .reset_index()\ + .drop('index', axis=1) + + @pytest.mark.requires_bamboo def test_metadata(): release_date = '2021-03-25' @@ -48,10 +55,12 @@ def test_metadata(): # test behavior expected = pd.read_pickle(os.path.join(expected_path, 'behavior_session_table.pkl')) + expected = sort_df(df=expected, sort_col='behavior_session_id') obtained = pd.read_csv(os.path.join(tmp_dir, 'behavior_session_table.csv'), dtype={'mouse_id': str}, parse_dates=['date_of_acquisition']) + obtained = sort_df(df=obtained, sort_col='behavior_session_id') convert_strings_to_lists(df=obtained) pd.testing.assert_frame_equal(expected, obtained) @@ -59,10 +68,12 @@ def test_metadata(): # test ophys session expected = pd.read_pickle(os.path.join(expected_path, 'ophys_session_table.pkl')) + expected = sort_df(df=expected, sort_col='ophys_session_id') obtained = pd.read_csv(os.path.join(tmp_dir, 'ophys_session_table.csv'), dtype={'mouse_id': str}, parse_dates=['date_of_acquisition']) + obtained = sort_df(df=obtained, sort_col='ophys_session_id') convert_strings_to_lists(df=obtained) pd.testing.assert_frame_equal(expected, obtained) @@ -70,10 +81,12 @@ def test_metadata(): # test ophys experiment expected = pd.read_pickle(os.path.join(expected_path, 'ophys_experiment_table.pkl')) + expected = sort_df(df=expected, sort_col='ophys_experiment_id') obtained = pd.read_csv(os.path.join(tmp_dir, 'ophys_experiment_table.csv'), dtype={'mouse_id': str}, parse_dates=['date_of_acquisition']) + obtained = sort_df(df=obtained, sort_col='ophys_experiment_id') convert_strings_to_lists(df=obtained, is_session=False) pd.testing.assert_frame_equal(expected, obtained) From 275e10d6d21d0cbf3389a2e6e0a043c344744f74 Mon Sep 17 00:00:00 2001 From: Doug Ollerenshaw Date: Thu, 1 Apr 2021 13:19:50 -0700 Subject: [PATCH 23/86] ticket 1502 - add link to sync file documentation in sync_dataset/Dataset docstring --- allensdk/brain_observatory/sync_dataset.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/allensdk/brain_observatory/sync_dataset.py b/allensdk/brain_observatory/sync_dataset.py index cbd31312f..6c89a91b7 100644 --- a/allensdk/brain_observatory/sync_dataset.py +++ b/allensdk/brain_observatory/sync_dataset.py @@ -86,6 +86,12 @@ class Dataset(object): ... logger.info(dset.meta_data) ... dset.stats() + The sync file documentation from MPE can be found at + sharepoint > Instrumentation > Shared Documents > Sync_line_labels_discussion_2020-01-27-.xlsx # NOQA E501 + Direct link: + https://alleninstitute.sharepoint.com/:x:/s/Instrumentation/ES2bi1xJ3E9NupX-zQeXTlYBS2mVVySycfbCQhsD_jPMUw?e=Z9jCwH + + """ FRAME_KEYS = ('frames', 'stim_vsync') PHOTODIODE_KEYS = ('photodiode', 'stim_photodiode') From 87cbe1e2fb01a03354798cb780ca1688d6dd9cd1 Mon Sep 17 00:00:00 2001 From: aamster Date: Thu, 1 Apr 2021 14:15:41 -0700 Subject: [PATCH 24/86] change {} to set() --- allensdk/brain_observatory/sync_dataset.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/allensdk/brain_observatory/sync_dataset.py b/allensdk/brain_observatory/sync_dataset.py index 6c89a91b7..9f1b9d259 100644 --- a/allensdk/brain_observatory/sync_dataset.py +++ b/allensdk/brain_observatory/sync_dataset.py @@ -110,7 +110,7 @@ class Dataset(object): "cam1_exposure", "behavior_monitoring") - DEPRECATED_KEYS = {} + DEPRECATED_KEYS = set() def __init__(self, path): self.dfile = self.load(path) From 950f6f9be71ce93a65456d6aecf7fc0680fabae6 Mon Sep 17 00:00:00 2001 From: Dan Date: Fri, 2 Apr 2021 05:38:48 -0700 Subject: [PATCH 25/86] fixes test after key changes in #2074 --- .../brain_observatory/test_time_sync.py | 34 ++++++++----------- 1 file changed, 14 insertions(+), 20 deletions(-) diff --git a/allensdk/test/internal/brain_observatory/test_time_sync.py b/allensdk/test/internal/brain_observatory/test_time_sync.py index db106e08b..5de119735 100644 --- a/allensdk/test/internal/brain_observatory/test_time_sync.py +++ b/allensdk/test/internal/brain_observatory/test_time_sync.py @@ -574,11 +574,9 @@ def mock_read_pickle(*args, **kwargs): "stimulus": "stim_vsync", "eye_camera": "cam2_exposure", "behavior_camera": "cam1_exposure", - "lick_sensor": "lick_sensor"}, - [('root', 30, 'Could not find valid lines for the ' - 'following data sources'), - ('root', 30, "acquiring (valid line label(s) = " - "['2p_acquiring']")]), + "lick_sensor": "lick_sensor", + "acquiring": "acq_trigger"}, + []), (None, ['2p_vsync', 'stim_vsync', 'photodiode', 'acq_trigger', 'behavior_monitoring', 'eye_tracking', 'lick_1'], @@ -588,11 +586,9 @@ def mock_read_pickle(*args, **kwargs): "stimulus": "stim_vsync", "eye_camera": "eye_tracking", "behavior_camera": "behavior_monitoring", - "lick_sensor": "lick_1"}, - [('root', 30, 'Could not find valid lines for the ' - 'following data sources'), - ('root', 30, "acquiring (valid line label(s) = " - "['2p_acquiring']")]), + "lick_sensor": "lick_1", + "acquiring": "acq_trigger"}, + []), (None, ['2p_vsync', 'stim_vsync', 'photodiode', 'acq_trigger', '', 'behavior_monitoring', 'lick_1'], @@ -601,14 +597,13 @@ def mock_read_pickle(*args, **kwargs): "2p": "2p_vsync", "stimulus": "stim_vsync", "behavior_camera": "behavior_monitoring", - "lick_sensor": "lick_1"}, + "lick_sensor": "lick_1", + "acquiring": "acq_trigger"}, [('root', 30, 'Could not find valid lines for the ' 'following data sources'), ('root', 30, "eye_camera (valid line label(s) = " "['cam2_exposure', 'eye_tracking', " - "'eye_frame_received']"), - ('root', 30, "acquiring (valid line label(s) = " - "['2p_acquiring']")]), + "'eye_frame_received']")]), (None, [], {}, [('root', 30, 'Could not find valid lines for the ' @@ -627,7 +622,7 @@ def mock_read_pickle(*args, **kwargs): "'behavior_monitoring', " "'beh_frame_received']"), ('root', 30, "acquiring (valid line label(s) = " - "['2p_acquiring']"), + "['2p_acquiring', 'acq_trigger']"), ('root', 30, "lick_sensor (valid line label(s) = " "['lick_1', 'lick_sensor']")]), (None, ['', 'stim_vsync', 'photodiode', 'acq_trigger', @@ -638,13 +633,12 @@ def mock_read_pickle(*args, **kwargs): "stimulus": "stim_vsync", "eye_camera": "eye_tracking", "behavior_camera": "cam1_exposure", - "lick_sensor": "lick_1"}, + "lick_sensor": "lick_1", + "acquiring": "acq_trigger"}, [('root', 30, 'Could not find valid lines for the ' 'following data sources'), ('root', 30, "2p (valid line label(s) = " - "['2p_vsync']"), - ('root', 30, "acquiring (valid line label(s) = " - "['2p_acquiring']")]), + "['2p_vsync']")]), (None, ['barcode_ephys', 'vsync_stim', 'stim_photodiode', 'stim_running', 'beh_frame_received', 'eye_frame_received', @@ -665,7 +659,7 @@ def mock_read_pickle(*args, **kwargs): ('root', 30, "2p (valid line label(s) = " "['2p_vsync']"), ('root', 30, "acquiring (valid line label(s) = " - "['2p_acquiring']")]) + "['2p_acquiring', 'acq_trigger']")]) ]) def test_get_keys(sync_dset, line_labels, expected_line_labels, expected_log, caplog): From 86ef83b57c5429dbeed858b3f6e6fcbc05396029 Mon Sep 17 00:00:00 2001 From: Dan Date: Fri, 2 Apr 2021 06:10:06 -0700 Subject: [PATCH 26/86] pep8 --- .../brain_observatory/test_time_sync.py | 336 +++++++++--------- 1 file changed, 174 insertions(+), 162 deletions(-) diff --git a/allensdk/test/internal/brain_observatory/test_time_sync.py b/allensdk/test/internal/brain_observatory/test_time_sync.py index 5de119735..f332cd0fd 100644 --- a/allensdk/test/internal/brain_observatory/test_time_sync.py +++ b/allensdk/test/internal/brain_observatory/test_time_sync.py @@ -20,13 +20,12 @@ if not os.path.exists(test_data["nikon"]["sync_file"]): data_skip = True -### Functions from lims2_modules ophys_time_sync.py for regression testing +# Functions from lims2_modules ophys_time_sync.py for regression testing MIN_BOUND = .03 MAX_BOUND = .04 -### Mock keys mock_keys = { "photodiode": "photodiode", "2p": "2p_vsync", @@ -37,6 +36,7 @@ "lick_sensor": "lick_1" } + class MockSyncDataset(Dataset): """ Mock the Dataset class so it doesn't load an h5 file upon @@ -44,7 +44,7 @@ class MockSyncDataset(Dataset): """ def __init__(self, data, line_labels=None): self.dfile = data - self.line_labels=line_labels + self.line_labels = line_labels def mock_get_real_photodiode_events(data, key): @@ -59,31 +59,35 @@ def calculate_stimulus_alignment(stim_time, valid_twop_vsync_fall): stimulus_alignment = np.empty(len(stim_time)) for index in range(len(stim_time)): - crossings = np.nonzero(np.ediff1d(np.sign(valid_twop_vsync_fall - stim_time[index])) > 0) + crossings = np.nonzero( + np.ediff1d( + np.sign(valid_twop_vsync_fall - stim_time[index])) > 0) try: stimulus_alignment[index] = int(crossings[0][0]) - except: + except: # noqa: E722 stimulus_alignment[index] = np.NaN return stimulus_alignment def calculate_valid_twop_vsync_fall(sync_data, sample_frequency): - twop_vsync_fall = sync_data.get_falling_edges('2p_vsync') / sample_frequency + twop_vsync_fall = sync_data.get_falling_edges('2p_vsync') /\ + sample_frequency if len(twop_vsync_fall) == 0: - raise ValueError('Error: twop_vsync_fall length is 0, possible invalid, missing, and/or bad data') + raise ValueError('Error: twop_vsync_fall length is 0, possible ' + 'invalid, missing, and/or bad data') ophys_start = twop_vsync_fall[0] - - valid_twop_vsync_fall = twop_vsync_fall[np.where(twop_vsync_fall > ophys_start)[0]] + valid_twop_vsync_fall = twop_vsync_fall[np.where( + twop_vsync_fall > ophys_start)[0]] return valid_twop_vsync_fall def calculate_stim_vsync_fall(sync_data, sample_frequency): - stim_vsync_fall = sync_data.get_falling_edges('stim_vsync')[0:] / sample_frequency - + stim_vsync_fall = sync_data.get_falling_edges('stim_vsync')[0:] /\ + sample_frequency return stim_vsync_fall @@ -97,61 +101,63 @@ def find_start(twop_vsync_fall): index = 0 for value in twop_vsync_fall: if not found_start: - if prev_value != None: + if prev_value is not None: diff = value - prev_value - if diff < MIN_BOUND or diff > MAX_BOUND: if in_start_frames: in_start_frames = False - elif not in_start_frames: - found_start = True - start_index = index + start_index = index prev_value = value - index+= 1 + index += 1 return start_index -def sync_camera_stimulus(sync_data, sample_frequency, camera, ophys_experiment_id): - twop_vsync_fall = sync_data.get_falling_edges('2p_vsync') / sample_frequency - - if len(twop_vsync_fall) == 0: - raise ValueError('Error: twop_vsync_fall length is 0, possible invalid, missing, and/or bad data') - - try: - twop_acquiring = sync_data.get_rising_edges('2p_acquiring') - ophys_start = twop_acquiring / sample_frequency - except: - ophys_start = [find_start(twop_vsync_fall)] - - twop_vsync_fall = twop_vsync_fall[np.where(twop_vsync_fall > ophys_start)[0]] - - cam_fall = None +def sync_camera_stimulus(sync_data, sample_frequency, camera, + ophys_experiment_id): + twop_vsync_fall = sync_data.get_falling_edges('2p_vsync') /\ + sample_frequency - if camera == 1: - cam_fall = sync_data.get_falling_edges('cam1_exposure') / sample_frequency - elif camera == 2: - cam_fall = sync_data.get_falling_edges('cam2_exposure') / sample_frequency - else: - raise ValueError('Error: camera value ' + str(camera) + ' is invalid') - - frames = np.zeros((len(twop_vsync_fall), 1)) + if len(twop_vsync_fall) == 0: + raise ValueError('Error: twop_vsync_fall length is 0, ' + 'possible invalid, missing, and/or bad data') + + try: + twop_acquiring = sync_data.get_rising_edges('2p_acquiring') + ophys_start = twop_acquiring / sample_frequency + except: # noqa: E722 + ophys_start = [find_start(twop_vsync_fall)] + + twop_vsync_fall = twop_vsync_fall[np.where( + twop_vsync_fall > ophys_start)[0]] + + cam_fall = None + + if camera == 1: + cam_fall = sync_data.get_falling_edges('cam1_exposure') /\ + sample_frequency + elif camera == 2: + cam_fall = sync_data.get_falling_edges('cam2_exposure') /\ + sample_frequency + else: + raise ValueError(f'Error: camera value {camera} is invalid') + frames = np.zeros((len(twop_vsync_fall), 1)) - for i in range(len(frames)): - crossings = np.nonzero(np.ediff1d(np.sign(cam_fall - twop_vsync_fall[i])) > 0) + for i in range(len(frames)): + crossings = np.nonzero( + np.ediff1d(np.sign(cam_fall - twop_vsync_fall[i])) > 0) + try: + frames[i] = crossings[0][0] + except: # noqa: E722 + frames[i] = np.NaN - try: - frames[i] = crossings[0][0] - except: - frames[i] = np.NaN - - return frames + return frames -### End of regression functions +# End of regression functions @pytest.fixture @@ -187,7 +193,7 @@ def test_get_alignment_array(): alignment = ts.get_alignment_array(bigger, smaller) assert np.all(~np.isnan(alignment)) assert np.all(bigger[alignment.astype(int)] < smaller) - + alignment = ts.get_alignment_array(smaller, bigger) assert np.all(np.isnan(alignment[bigger <= 0.2])) assert np.all(np.isnan(alignment[bigger >= 50])) @@ -236,7 +242,7 @@ def test_regression_calculate_stimulus_alignment(nikon_input, aligner.ophys_timestamps) new_align = ts.get_alignment_array(aligner.ophys_timestamps, aligner.stim_timestamps) - + # Old alignment assigned simultaneous stim frames to the previous ophys # frame. Methods should only differ when ophys and stim are identical. mismatch = old_align != new_align @@ -259,12 +265,13 @@ def test_regression_calculate_camera_alignment(nikon_input, new_eye_align = ts.get_alignment_array(aligner.eye_video_timestamps, aligner.ophys_timestamps[1:], int_method=np.ceil) - mismatch = np.where(old_eye_align[:,0] != new_eye_align) - mis_e = aligner.eye_video_timestamps[new_eye_align[mismatch].astype(int)] + mismatch = np.where(old_eye_align[:, 0] != new_eye_align) + mis_e = \ + aligner.eye_video_timestamps[new_eye_align[mismatch].astype(int)] mis_o = aligner.ophys_timestamps[1:][mismatch] mis_o_plus = aligner.ophys_timestamps[1:][(mismatch[0]+1,)] # New method should only disagree when old method was wrong (old method - # set an eye tracking frame to an earlier ophys frame). + # set an eye tracking frame to an earlier ophys frame). assert np.all(mis_o < mis_e) assert np.all(mis_o_plus >= mis_e) # Occurence of mismatch should be rare @@ -350,11 +357,12 @@ def test_get_corrected_stim_times(stim_data_length, start_delay): with patch.object(ts, "calculate_monitor_delay", return_value=ASSUMED_DELAY): with patch.object(ts.Dataset, "get_falling_edges", - return_value=true_falling) as mock_falling: + return_value=true_falling): with patch.object(ts.Dataset, "get_rising_edges", - return_value=true_rising) as mock_rising: + return_value=true_rising) as mock_rising: with patch("logging.info") as mock_log: - times, delta, stim_delay = aligner.corrected_stim_timestamps + times, delta, stim_delay = \ + aligner.corrected_stim_timestamps if stim_data_length is None: mock_log.assert_called_once() @@ -388,9 +396,9 @@ def test_get_corrected_ophys_times_nikon(ophys_data_length): aligner.ophys_data_length = ophys_data_length with patch.object(ts.Dataset, "get_falling_edges", - return_value=true_times) as mock_times: + return_value=true_times): with patch.object(ts.Dataset, "get_rising_edges", - return_value=[0]) as mock_acquiring: + return_value=[0]): with patch("logging.info") as mock_log: if ophys_data_length is not None and \ ophys_data_length > len(true_times): @@ -417,7 +425,7 @@ def test_get_corrected_ophys_times_nikon(ophys_data_length): @pytest.mark.skipif(data_skip, reason="No sync or data") def test_module(input_json): with patch("sys.argv", ["test_run", input_json]): - with patch("logging.info") as mock_logging: + with patch("logging.info"): run_ophys_time_sync.main() with open(input_json, "r") as f: @@ -522,8 +530,10 @@ def test_find_last_n(arr, cond, n, expected): [ ([0.25, 0.5, 0.75, 1., 2., 3., 5., 5.75], [1., 2., 3.]), ([1., 2., 3., 4.], [1., 2., 3., 4.]), - ([0.25, 1., 2., 2.1, 2.2, 3., 4., 5.], [3., 4., 5.]), # false alarm start - ([0.25, 1., 2., 3., 4., 4.5, 5.1, 6.1], [1., 2., 3., 4.]), # false alarm end + # false alarm start + ([0.25, 1., 2., 2.1, 2.2, 3., 4., 5.], [3., 4., 5.]), + # false alarm end + ([0.25, 1., 2., 3., 4., 4.5, 5.1, 6.1], [1., 2., 3., 4.]), ], ) def test_get_photodiode_events(sync_dset, expected, monkeypatch): @@ -563,104 +573,108 @@ def mock_read_pickle(*args, **kwargs): assert obtained == expected -@pytest.mark.parametrize("sync_dset, line_labels, expected_line_labels," - "expected_log", - [(None, ['2p_vsync', 'stim_vsync', 'stim_photodiode', - 'acq_trigger', '', 'cam1_exposure', - 'cam2_exposure', 'lick_sensor'], - { - "photodiode": "stim_photodiode", - "2p": "2p_vsync", - "stimulus": "stim_vsync", - "eye_camera": "cam2_exposure", - "behavior_camera": "cam1_exposure", - "lick_sensor": "lick_sensor", - "acquiring": "acq_trigger"}, - []), - (None, ['2p_vsync', 'stim_vsync', 'photodiode', - 'acq_trigger', 'behavior_monitoring', - 'eye_tracking', 'lick_1'], - { - "photodiode": "photodiode", - "2p": "2p_vsync", - "stimulus": "stim_vsync", - "eye_camera": "eye_tracking", - "behavior_camera": "behavior_monitoring", - "lick_sensor": "lick_1", - "acquiring": "acq_trigger"}, - []), - (None, ['2p_vsync', 'stim_vsync', 'photodiode', - 'acq_trigger', '', 'behavior_monitoring', - 'lick_1'], - { - "photodiode": "photodiode", - "2p": "2p_vsync", - "stimulus": "stim_vsync", - "behavior_camera": "behavior_monitoring", - "lick_sensor": "lick_1", - "acquiring": "acq_trigger"}, - [('root', 30, 'Could not find valid lines for the ' - 'following data sources'), - ('root', 30, "eye_camera (valid line label(s) = " - "['cam2_exposure', 'eye_tracking', " - "'eye_frame_received']")]), - (None, [], - {}, - [('root', 30, 'Could not find valid lines for the ' - 'following data sources'), - ('root', 30, "photodiode (valid line label(s) = " - "['stim_photodiode', 'photodiode']"), - ('root', 30, "2p (valid line label(s) = " - "['2p_vsync']"), - ('root', 30, "stimulus (valid line label(s) = " - "['stim_vsync', 'vsync_stim']"), - ('root', 30, "eye_camera (valid line label(s) = " - "['cam2_exposure', 'eye_tracking', " - "'eye_frame_received']"), - ('root', 30, "behavior_camera (valid line label(s) " - "= ['cam1_exposure', " - "'behavior_monitoring', " - "'beh_frame_received']"), - ('root', 30, "acquiring (valid line label(s) = " - "['2p_acquiring', 'acq_trigger']"), - ('root', 30, "lick_sensor (valid line label(s) = " - "['lick_1', 'lick_sensor']")]), - (None, ['', 'stim_vsync', 'photodiode', 'acq_trigger', - 'eye_tracking', 'lick_1', 'acq_trigger', - 'cam1_exposure'], - { - "photodiode": "photodiode", - "stimulus": "stim_vsync", - "eye_camera": "eye_tracking", - "behavior_camera": "cam1_exposure", - "lick_sensor": "lick_1", - "acquiring": "acq_trigger"}, - [('root', 30, 'Could not find valid lines for the ' - 'following data sources'), - ('root', 30, "2p (valid line label(s) = " - "['2p_vsync']")]), - (None, ['barcode_ephys', 'vsync_stim', - 'stim_photodiode', 'stim_running', - 'beh_frame_received', 'eye_frame_received', - 'face_frame_received', 'stim_running_opto', - 'stim_trial_opto', 'face_came_frame_readout', - 'eye_cam_frame_readout', - 'beh_cam_frame_readout', 'face_cam_exposing', - 'eye_cam_exposing', 'beh_cam_exposing', - 'lick_sensor'], - { - "photodiode": "stim_photodiode", - "stimulus": "vsync_stim", - "eye_camera": "eye_frame_received", - "behavior_camera": "beh_frame_received", - "lick_sensor": "lick_sensor"}, - [('root', 30, 'Could not find valid lines for the ' - 'following data sources'), - ('root', 30, "2p (valid line label(s) = " - "['2p_vsync']"), - ('root', 30, "acquiring (valid line label(s) = " - "['2p_acquiring', 'acq_trigger']")]) -]) +@pytest.mark.parametrize( + "sync_dset, line_labels, expected_line_labels, expected_log", + [ + (None, ['2p_vsync', 'stim_vsync', 'stim_photodiode', + 'acq_trigger', '', 'cam1_exposure', + 'cam2_exposure', 'lick_sensor'], + { + "photodiode": "stim_photodiode", + "2p": "2p_vsync", + "stimulus": "stim_vsync", + "eye_camera": "cam2_exposure", + "behavior_camera": "cam1_exposure", + "lick_sensor": "lick_sensor", + "acquiring": "acq_trigger"}, + []), + (None, ['2p_vsync', 'stim_vsync', 'photodiode', + 'acq_trigger', 'behavior_monitoring', + 'eye_tracking', 'lick_1'], + { + "photodiode": "photodiode", + "2p": "2p_vsync", + "stimulus": "stim_vsync", + "eye_camera": "eye_tracking", + "behavior_camera": "behavior_monitoring", + "lick_sensor": "lick_1", + "acquiring": "acq_trigger"}, + []), + (None, ['2p_vsync', 'stim_vsync', 'photodiode', + 'acq_trigger', '', 'behavior_monitoring', + 'lick_1'], + { + "photodiode": "photodiode", + "2p": "2p_vsync", + "stimulus": "stim_vsync", + "behavior_camera": "behavior_monitoring", + "lick_sensor": "lick_1", + "acquiring": "acq_trigger"}, + [('root', 30, 'Could not find valid lines for the ' + 'following data sources'), + ('root', 30, "eye_camera (valid line label(s) = " + "['cam2_exposure', 'eye_tracking', " + "'eye_frame_received']")]), + (None, [], + {}, + [('root', 30, + 'Could not find valid lines for the ' + 'following data sources'), + ('root', 30, + "photodiode (valid line label(s) = " + "['stim_photodiode', 'photodiode']"), + ('root', 30, + "2p (valid line label(s) = ['2p_vsync']"), + ('root', 30, + "stimulus (valid line label(s) = " + "['stim_vsync', 'vsync_stim']"), + ('root', 30, + "eye_camera (valid line label(s) = " + "['cam2_exposure', 'eye_tracking', 'eye_frame_received']"), + ('root', 30, "behavior_camera (valid line label(s) " + "= ['cam1_exposure', " + "'behavior_monitoring', " + "'beh_frame_received']"), + ('root', 30, "acquiring (valid line label(s) = " + "['2p_acquiring', 'acq_trigger']"), + ('root', 30, "lick_sensor (valid line label(s) = " + "['lick_1', 'lick_sensor']")]), + (None, ['', 'stim_vsync', 'photodiode', 'acq_trigger', + 'eye_tracking', 'lick_1', 'acq_trigger', + 'cam1_exposure'], + { + "photodiode": "photodiode", + "stimulus": "stim_vsync", + "eye_camera": "eye_tracking", + "behavior_camera": "cam1_exposure", + "lick_sensor": "lick_1", + "acquiring": "acq_trigger"}, + [('root', 30, 'Could not find valid lines for the ' + 'following data sources'), + ('root', 30, "2p (valid line label(s) = " + "['2p_vsync']")]), + (None, ['barcode_ephys', 'vsync_stim', + 'stim_photodiode', 'stim_running', + 'beh_frame_received', 'eye_frame_received', + 'face_frame_received', 'stim_running_opto', + 'stim_trial_opto', 'face_came_frame_readout', + 'eye_cam_frame_readout', + 'beh_cam_frame_readout', 'face_cam_exposing', + 'eye_cam_exposing', 'beh_cam_exposing', + 'lick_sensor'], + { + "photodiode": "stim_photodiode", + "stimulus": "vsync_stim", + "eye_camera": "eye_frame_received", + "behavior_camera": "beh_frame_received", + "lick_sensor": "lick_sensor"}, + [('root', 30, 'Could not find valid lines for the ' + 'following data sources'), + ('root', 30, "2p (valid line label(s) = " + "['2p_vsync']"), + ('root', 30, "acquiring (valid line label(s) = " + "['2p_acquiring', 'acq_trigger']")]) + ]) def test_get_keys(sync_dset, line_labels, expected_line_labels, expected_log, caplog): """ @@ -677,5 +691,3 @@ def test_get_keys(sync_dset, line_labels, expected_line_labels, expected_log, keys = ts.get_keys(ds) assert keys == expected_line_labels assert caplog.record_tuples == expected_log - - From f6ff090eff6279a233d4a6766e7bee61c12a54b1 Mon Sep 17 00:00:00 2001 From: Dan Kapner <32312979+djkapner@users.noreply.github.com> Date: Fri, 9 Apr 2021 14:52:01 -0700 Subject: [PATCH 27/86] =?UTF-8?q?bumps=20simpleitk=20to=20latest=20release?= =?UTF-8?q?,=20preparing=20fr=20python=203.8=20and=203.9=20su=E2=80=A6=20(?= =?UTF-8?q?#2070)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Migrate code to latest SimpleITK release, preparing for python 3.8 and 3.9 support * Reformat to fix linting errors * Bugfix for the glymur methods in image utilities Co-authored-by: matyasz --- .../grid/utilities/image_utilities.py | 148 ++++++++++-------- doc_requirements.txt | 2 +- requirements.txt | 2 +- 3 files changed, 84 insertions(+), 68 deletions(-) diff --git a/allensdk/mouse_connectivity/grid/utilities/image_utilities.py b/allensdk/mouse_connectivity/grid/utilities/image_utilities.py index eaef9d3ed..cc620649a 100755 --- a/allensdk/mouse_connectivity/grid/utilities/image_utilities.py +++ b/allensdk/mouse_connectivity/grid/utilities/image_utilities.py @@ -6,7 +6,6 @@ from six import iteritems import numpy as np import SimpleITK as sitk -from PIL import Image, ImageDraw from skimage.draw import polygon from allensdk.config.manifest import Manifest @@ -37,19 +36,19 @@ def set_image_spacing(image, spacing, origin=True): image.SetSpacing(spacing.tolist()) if origin: - image.SetOrigin( (0.5 * spacing).tolist() ) + image.SetOrigin((0.5 * spacing).tolist()) def new_image(dims, spacing, dtype, origin=True): ''' ''' - + if len(dims) == 2: image = sitk.Image(dims[0], dims[1], dtype) elif len(dims) == 3: image = sitk.Image(dims[0], dims[1], dims[2], dtype) set_image_spacing(image, spacing, origin) - + return image @@ -59,58 +58,60 @@ def image_from_array(array, spacing, origin=True): image = sitk.GetImageFromArray(array) set_image_spacing(image, spacing, origin) - + return image def np_sitk_convert(np_type): ''' ''' - + return NUMPY_SITK_TYPE_LOOKUP[np_type] - - + + def sitk_np_convert(sitk_type): ''' ''' - + return SITK_NUMPY_TYPE_LOOKUP[sitk_type] - - + + # Math - - + + def compute_coarse_parameters(in_dims, in_spacing, out_spacing, reduce_level): ''' ''' - + reduce_factor = pow(2, reduce_level) fradius = np.divide(out_spacing, in_spacing) / 2.0 / reduce_factor - + coarse_grid_radius = np.round(fradius) coarse_grid_size = (coarse_grid_radius * 2 + 1) * reduce_factor - + coarse_grid_spacing = np.multiply(in_spacing, coarse_grid_size) - coarse_grid_dims = np.ceil( np.divide(in_dims, coarse_grid_size) ).astype(int) - + coarse_grid_dims = np.ceil( + np.divide(in_dims, coarse_grid_size) + ).astype(int) + return coarse_grid_dims, coarse_grid_spacing, coarse_grid_radius - - + + def block_apply(in_image, out_shape, dtype, blocks, fn): ''' ''' - + out_image = np.zeros(out_shape, dtype=dtype) - + for ii, row_block in enumerate(blocks[0]): for jj, col_block in enumerate(blocks[1]): - - out_image[ii, jj] = fn(in_image[row_block[0]:row_block[1], + + out_image[ii, jj] = fn(in_image[row_block[0]:row_block[1], col_block[0]:col_block[1]]) - + return out_image - - + + def grid_image_blocks(in_shape, in_spacing, out_spacing): ''' ''' @@ -122,45 +123,49 @@ def grid_image_blocks(in_shape, in_spacing, out_spacing): in_shape[dim]*in_spacing[dim], in_spacing[dim]) - out_px_edges = np.arange(out_spacing[dim], (in_shape[dim]-0.5)*in_spacing[dim], out_spacing[dim]) + dig = np.digitize(in_px_centers, out_px_edges) - dig = np.digitize(in_px_centers, out_px_edges) - - inds = np.where(np.diff(dig)>0)[0]+1 + inds = np.where(np.diff(dig) > 0)[0] + 1 inds = [0] + inds.tolist() + [in_shape[dim]] - dim_blocks = [ (int(inds[i]), int(inds[i+1])) for i in range(len(inds)-1) ] + dim_blocks = [ + (int(inds[i]), int(inds[i+1])) for i in range(len(inds)-1) + ] out_shape.append(len(dim_blocks)) blocks.append(dim_blocks) - + return blocks, out_shape - - + + # Polygons - - + + def rasterize_polygons(shape, scale, polys): canvas = np.zeros(shape, dtype=np.uint8) for points in polys: - - rpts = np.array([int(np.around(item[1] * scale[1])) for item in points]) - cpts = np.array([int(np.around(item[0] * scale[0])) for item in points]) + + rpts = np.array([ + int(np.around(item[1] * scale[1])) for item in points + ]) + cpts = np.array([ + int(np.around(item[0] * scale[0])) for item in points + ]) poly = polygon(rpts, cpts) canvas[poly] = 1 - + return canvas # Transforms - - + + def resample_into_volume(image, transform, z, vol, dtype=sitk.sitkFloat32): ''' ''' @@ -170,24 +175,25 @@ def resample_into_volume(image, transform, z, vol, dtype=sitk.sitkFloat32): timage = sitk.Resample(image, transform, sitk.sitkLinear, 0.0, dtype) tvol = sitk.JoinSeries(timage) - return sitk.Paste(vol, tvol, tvol.GetSize(), destinationIndex=[0,0,z]) - - + return sitk.Paste(vol, tvol, tvol.GetSize(), destinationIndex=[0, 0, z]) + + def build_affine_transform(aff_params): ''' ''' xfm = sitk.AffineTransform(3) xfm.SetParameters(aff_params) - + return xfm - - + + def build_composite_transform(dfmfield=None, aff_params=None): ''' ''' - if dfmfield is not None and dfmfield.GetPixelIDValue() != sitk.sitkVectorFloat64: + if dfmfield is not None and \ + dfmfield.GetPixelIDValue() != sitk.sitkVectorFloat64: dfmfield = sitk.Cast(dfmfield, sitk.sitkVectorFloat64) if dfmfield is None and aff_params is None: @@ -200,27 +206,30 @@ def build_composite_transform(dfmfield=None, aff_params=None): dfmxfm = sitk.DisplacementFieldTransform(dfmfield) affxfm = build_affine_transform(aff_params) - transform = sitk.Transform() - transform.AddTransform(affxfm) - transform.AddTransform(dfmxfm) + transform = sitk.CompositeTransform([affxfm, dfmxfm]) return transform - - + + def resample_volume(volume, dims, spacing, interpolator=None, transform=None): ''' ''' if transform is None: - transform = sitk.Transform() + transform = sitk.Transform() if interpolator is None: interpolator = sitk.sitkLinear - + ref = new_image(dims, spacing, sitk.sitkFloat32, False) return sitk.Resample(volume, ref, transform, interpolator) -def write_volume(volume, name, prefix=None, specify_resolution=None, extension='.nrrd', paths=None): +def write_volume(volume, + name, + prefix=None, + specify_resolution=None, + extension='.nrrd', + paths=None): if prefix is None: path = name @@ -228,12 +237,13 @@ def write_volume(volume, name, prefix=None, specify_resolution=None, extension=' path = os.path.join(prefix, name) if specify_resolution is not None: - if isinstance(specify_resolution, (float, np.floating)) and specify_resolution % 1.0 == 0: + if isinstance(specify_resolution, (float, np.floating)) and \ + specify_resolution % 1.0 == 0: specify_resolution = int(specify_resolution) path = path + '_{0}'.format(specify_resolution) path = path + extension - + logging.info('writing {0} volume to {1}'.format(name, path)) Manifest.safe_make_parent_dirs(path) volume.SetOrigin([0, 0, 0]) @@ -248,22 +258,28 @@ def __read_segmentation_image_with_kakadu(path): raise OSError('file not found at {}'.format(path)) return jpeg_twok.read(path).T + def __read_intensity_image_with_kakadu(path, reduce_level, channel): if not os.path.exists(path): raise OSError('file not found at {}'.format(path)) return jpeg_twok.read(path, reduce_level, channel).T + def __read_segmentation_image_with_glymur(path): return glymur.Jp2k(path)[:] -def __read_intensity_image_with_glymur(): - image = glymur.Jp2k(path)[:] + +def __read_intensity_image_with_glymur(path): + return glymur.Jp2k(path)[:] try: - # we use a proprietary library called kakadu internally (jpeg_twok is a python interface around that library) - # kakadu offers really good performance as well as support for advanced jp2 features - # however, since it is proprietary, we can't share it alongside the allensdk, + # we use a proprietary library called kakadu internally + # (jpeg_twok is a python interface around that library) + # kakadu offers really good performance as well as support for + # advanced jp2 features + # however, since it is proprietary, we can't share it + # alongside the allensdk, # so we default to glymur (a python openjpeg) for external users. sys.path.append('/shared/bioapps/itk/itk_shared/jp2/build') import jpeg_twok @@ -272,4 +288,4 @@ def __read_intensity_image_with_glymur(): except failed_import: import glymur read_segmentation_image = __read_segmentation_image_with_glymur - read_intensity_image = __read_intensity_image_with_glymur \ No newline at end of file + read_intensity_image = __read_intensity_image_with_glymur diff --git a/doc_requirements.txt b/doc_requirements.txt index bf4725208..185475a97 100644 --- a/doc_requirements.txt +++ b/doc_requirements.txt @@ -24,7 +24,7 @@ simplejson>=3.10.0,<4.0.0 scikit-image>=0.14.0,<0.17.0 scikit-build<1.0.0 statsmodels==0.9.0 -simpleitk<2.0.0 +simpleitk>=2.0.2,<3.0.0 argschema<2.0.0 marshmallow==3.0.0rc6 glymur==0.8.19 diff --git a/requirements.txt b/requirements.txt index 63baf0595..bac4e8ebc 100644 --- a/requirements.txt +++ b/requirements.txt @@ -14,7 +14,7 @@ simplejson>=3.10.0,<4.0.0 scikit-image>=0.14.0,<0.17.0 scikit-build<1.0.0 statsmodels==0.9.0 -simpleitk<2.0.0 +simpleitk>=2.0.2,<3.0.0 argschema<3.0.0 marshmallow==3.0.0rc6 glymur==0.8.19 From eb7606022d5ff028f8d4fbd2e91b43beb22df52d Mon Sep 17 00:00:00 2001 From: aamster Date: Tue, 13 Apr 2021 14:53:55 -0700 Subject: [PATCH 28/86] Fixes issue in which the session_type in mtrain was missing for certain sessions. --- .../data_io/behavior_project_lims_api.py | 4 +++- .../tables/ophys_mixin.py | 7 +++++++ .../tables/sessions_table.py | 7 +++++++ .../test_behavior_project_metadata_writer.py | 4 +++- .../expected/ophys_experiment_table.pkl | Bin 389080 -> 399298 bytes .../expected/ophys_session_table.pkl | Bin 87134 -> 91826 bytes 6 files changed, 20 insertions(+), 2 deletions(-) diff --git a/allensdk/brain_observatory/behavior/behavior_project_cache/project_apis/data_io/behavior_project_lims_api.py b/allensdk/brain_observatory/behavior/behavior_project_cache/project_apis/data_io/behavior_project_lims_api.py index 5a3df0af6..731232e2a 100644 --- a/allensdk/brain_observatory/behavior/behavior_project_cache/project_apis/data_io/behavior_project_lims_api.py +++ b/allensdk/brain_observatory/behavior/behavior_project_cache/project_apis/data_io/behavior_project_lims_api.py @@ -347,6 +347,7 @@ def _get_ophys_experiment_table(self) -> pd.DataFrame: SELECT oe.id as ophys_experiment_id, os.id as ophys_session_id, + os.stimulus_name as session_type, bs.id as behavior_session_id, oec.visual_behavior_experiment_container_id as ophys_container_id, @@ -397,7 +398,8 @@ def _get_ophys_session_table(self) -> pd.DataFrame: pr.code as project_code, os.name as session_name, os.date_of_acquisition, - os.specimen_id + os.specimen_id, + os.stimulus_name as session_type FROM ophys_sessions os JOIN behavior_sessions bs ON os.id = bs.ophys_session_id LEFT OUTER JOIN projects pr ON pr.id = os.project_id diff --git a/allensdk/brain_observatory/behavior/behavior_project_cache/tables/ophys_mixin.py b/allensdk/brain_observatory/behavior/behavior_project_cache/tables/ophys_mixin.py index 980c2de6f..68438bba2 100644 --- a/allensdk/brain_observatory/behavior/behavior_project_cache/tables/ophys_mixin.py +++ b/allensdk/brain_observatory/behavior/behavior_project_cache/tables/ophys_mixin.py @@ -11,3 +11,10 @@ def __init__(self): self._df = self._df.drop( ['date_of_acquisition_behavior', 'date_of_acquisition_ophys'], axis=1) + + # Prioritize ophys session_type + self._df['session_type'] = \ + self._df['session_type_ophys'] + self._df = self._df.drop( + ['session_type_behavior', + 'session_type_ophys'], axis=1) diff --git a/allensdk/brain_observatory/behavior/behavior_project_cache/tables/sessions_table.py b/allensdk/brain_observatory/behavior/behavior_project_cache/tables/sessions_table.py index 6f3c174fd..d97903156 100644 --- a/allensdk/brain_observatory/behavior/behavior_project_cache/tables/sessions_table.py +++ b/allensdk/brain_observatory/behavior/behavior_project_cache/tables/sessions_table.py @@ -76,6 +76,13 @@ def postprocess_additional(self): self._df = self._df.drop(['date_of_acquisition_behavior', 'date_of_acquisition_ophys'], axis=1) + # Prioritize behavior session_type + self._df['session_type'] = \ + self._df['session_type_behavior'] + self._df = self._df.drop( + ['session_type_behavior', + 'session_type_ophys'], axis=1) + def __add_session_number(self): """Parses session number from session type and and adds to dataframe""" diff --git a/allensdk/test/brain_observatory/behavior/behavior_project_cache/test_behavior_project_metadata_writer.py b/allensdk/test/brain_observatory/behavior/behavior_project_cache/test_behavior_project_metadata_writer.py index 5b56e96b6..eb83baa74 100644 --- a/allensdk/test/brain_observatory/behavior/behavior_project_cache/test_behavior_project_metadata_writer.py +++ b/allensdk/test/brain_observatory/behavior/behavior_project_cache/test_behavior_project_metadata_writer.py @@ -31,9 +31,11 @@ def convert_strings_to_lists(df, is_session=True): def sort_df(df: pd.DataFrame, sort_col: str): """Sorts df for comparison""" - return df.sort_values(sort_col)\ + df = df.sort_values(sort_col)\ .reset_index()\ .drop('index', axis=1) + df = df[df.columns.sort_values()] + return df @pytest.mark.requires_bamboo diff --git a/allensdk/test/brain_observatory/behavior/resources/project_metadata_writer/expected/ophys_experiment_table.pkl b/allensdk/test/brain_observatory/behavior/resources/project_metadata_writer/expected/ophys_experiment_table.pkl index 9801fe8c53a26887005991d03aebcd3f0bcb52ce..f1339a9e75df4c99439145529ab0372e2e5bb9d4 100644 GIT binary patch literal 399298 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

)HR!6%KBVC_jeg}czxK<{psWMcFwPl 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

  • +%Fs8t9g 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%0DG50cvQW zmut4207Dca5e%6@rDW!zB3G6p+#?zRi2Dn3NXTyGbWg9g6adts5Q&j>P(`;wK2@ey zC7F4yM&K&o>Yzg3A`J*tR+7v(R3stCE-0NJT1;EKu*S+Jq+|)yjx7KSQ#%_KJsbPK z8zCpQg(!$-Nktl63o#x{RVlJw14Ztw+(jKMPmAYqyET()pQB_sUjkLJhCO`stmn&D zFO^gefwF!tfp#k*uI*P(d%cv=-UBW6>Rr>X9>q|6Wc1RFPN?+~VLm`spF%}uSUpjo z%)Kjtc5OYcm5g$r#K)(AcFV`tK2Wkurhs;9nXDyH8)ZW*U4aw?n>a|8%fby(GSf0j zfwC#Ie4cLkq1)#umC=Op#$}Xol+_%QuU?h%Gd6@`j-M~1tlEeI+Aa5P`>&q%`sCI3 zyw|fW(2}ES3$(dckJ?R`_-ieEWWkG5yc5G&l9^Ud3@EqoW1z+@e7KPFIm*qv1gL2< zj{-#zy&|vPGj8TFjiUM@()eE2? z>p4(rlq5in8zqFJARRwJ%KG*bm zRO)k(s+n~rNoKk;F`yQ;o?@VuaXrd_+Qm3(8<$ZEl)4>bj@rh1Jp;=24u(kX4^i&g z-ocg@eo#a9ZEZaatTshqje7AFnd#0%fl?Vwp2xV1GN3goqcPB$l+n(Y#<67*AtiR2 zeK}plcSGBEUBtUJYf@B3KpI<{&FmFf^74&BMRi69u?{Ns5;>@bL6km{<;P^^p`s2e z>K^N$dZIWZ&2^0>hlI>HRIdiC*bt!Mswm!FL-i5DRYhhVDheN?6t`xRvq;4jS*U!= zph`0HP`w)RuIlz+`W$h#r6zP%m0(0A&3^sT{il^ktlHAq^yt+1_fHhQ{+ycQ!?{VF?iQpRE*U?6?Mi%MIQoHl9`7JCGTLA z*FnV;3HkgfR7qwYYKU4psEMkB8d*rx6A}#oe?UTJ9;&Ftu1|K3?XZy$0VV*F%sA8t z$z!o`O(y^-(u{gGHd!EtS0W+n2x?sL$%-saS;RCtgNt85S0!wYUC>`MP?oBlVyJ4jvKMTMXIV=Y0YK(y9J%-gG$3m2M$g;b5KVtp0M zRurlvD?wHnRNAc)sAb4%K2(e5>=TRI*uh%D5I_Zh*{dl@mvX-uA_jJw`U1xYWdD@W*>k5i}nhstwuVjG--en|Bfp=bW`d zde-}gtRZK}9rA|!p>SyPg6A(K&ma2GV6hvnlHTwGLmLcjIJD8w#zUJ7Z925s(1f92 zC>q+L{>D>|9T{G}wi#+aS|fPu;bjMpnlfi(Xrwwga*qeYb<-yA7{!@Vu$h=T4nDXJmM}In$?3o-*>~ zk%`rb0~41L?>PfEs|7g0UVuAjDS4BnWxI@QmzC{uvRz)bE68?5*{&qpHD$Y&Y}b}; u41UVIIpEmvvXkb1de+GBiiZr&9XxdQ;EXBwK5533xr5>dyrOvrZ}$J&G#_sO diff --git a/allensdk/test/brain_observatory/behavior/resources/project_metadata_writer/expected/ophys_session_table.pkl b/allensdk/test/brain_observatory/behavior/resources/project_metadata_writer/expected/ophys_session_table.pkl index f1b0178aa86fd883ea5c5a27c8c1d03bc5e02b37..6d7e14fae3eaa32080ec20911d9a0a83b434d184 100644 GIT binary patch literal 91826 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 A5C8xG literal 87134 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 jcp@T^uUwx?kv?!M?%{=7#=nC~=gVHjRU3E Date: Tue, 13 Apr 2021 15:26:03 -0700 Subject: [PATCH 29/86] updates tests and data --- .../test_behavior_project_cache.py | 6 +++++- .../expected/behavior_session_table.pkl | Bin 2034 -> 2034 bytes .../expected/ophys_experiment_table.pkl | Bin 2142 -> 2142 bytes .../expected/ophys_session_table.pkl | Bin 1691 -> 1691 bytes 4 files changed, 5 insertions(+), 1 deletion(-) diff --git a/allensdk/test/brain_observatory/behavior/behavior_project_cache/test_behavior_project_cache.py b/allensdk/test/brain_observatory/behavior/behavior_project_cache/test_behavior_project_cache.py index 242cb5571..0da8f48f9 100644 --- a/allensdk/test/brain_observatory/behavior/behavior_project_cache/test_behavior_project_cache.py +++ b/allensdk/test/brain_observatory/behavior/behavior_project_cache/test_behavior_project_cache.py @@ -14,7 +14,8 @@ def session_table(): return (pd.DataFrame({"behavior_session_id": [3], "ophys_experiment_id": [[5, 6]], - "date_of_acquisition": np.datetime64('2020-02-20') + "date_of_acquisition": np.datetime64('2020-02-20'), + 'session_type': ['OPHYS_1_images_A'], }, index=pd.Index([1], name='ophys_session_id')) ) @@ -55,6 +56,9 @@ def experiments_table(): np.datetime64('2020-02-21'), np.datetime64('2020-02-22') ], + 'session_type': ['TRAINING_1_gratings', + 'TRAINING_1_gratings', + 'OPHYS_1_images_A'], 'imaging_depth': [75, 75, 75], 'targeted_structure': ['VISp', 'VISp', 'VISp'] }) diff --git a/allensdk/test/brain_observatory/behavior/resources/project_metadata/expected/behavior_session_table.pkl b/allensdk/test/brain_observatory/behavior/resources/project_metadata/expected/behavior_session_table.pkl index f8e18e96751aa2c3d83efc151d194426134897e9..1ef4d2cd18f131d9fcb193be10325475a82a5ddf 100644 GIT binary patch delta 208 zcmeyw|A~J?6XWEGjK}zTc#2bti!<}{;!7$EQa6V&2{Ea&yE`ZP2AHKy$w=X@eR&Tb{$+axvlj~SCHlJhBWMtLK(9O`FT)?J2S(#0URXf8l!x+TlXKtO6 zq1Xx3lcAHLmtg=DGhj5DypSzhkQHJs0|%7mnk>uiFUSVv@jz+5$+hg-lI-5R-c0RN Zf~J(#0!><%QI@ej;{Z^MZ}LHQLjVPWHP!$C delta 199 zcmeyw|A~J?6C)2#acXgKW`16LNoB$0dd8ER!tyJGRlH{7WHg!ll`UJ4l>q`cpfuOyOm_Ln6WBcj*`OkPP+DN}OLlEZc5iNPruHd8 aQ%Y;6Xk={4sLI%!am1V7TVS#-hamtipfq^^ delta 209 zcmca7a8F=E6C)2#acXgKW`16LNoB$0dd7E~YnURLCiAh10kMWs54*c_qHlm%+LVmU zjLaUEFwfwEDJaxtQ&u5HZtV=c41)~A45JL=$rISrCkL>zOtxoZ<5kO0&(O%w%+Siv z$uI%R7&7Z-=z~?fX5(Zuo&1$8o0E+J0yrlZvdc5_P3~m(7i5Ksu|sM8$uHTpB{{r# fyqVgk1WhTeouZMkDWfW5bH)*G0dK*{x*Uc8+YCA| diff --git a/allensdk/test/brain_observatory/behavior/resources/project_metadata/expected/ophys_session_table.pkl b/allensdk/test/brain_observatory/behavior/resources/project_metadata/expected/ophys_session_table.pkl index b4957ad48bfa6414a76cfa02418c0cd984632e35..1ec3abff272ae5f8eb0625ae2e50a696ac94a80b 100644 GIT binary patch delta 204 zcmbQuJDYby6XWEGj3@bec#2bti!<}{;!7$EQa493Suv`xyE`ZP2AHKyiJhY1&FamT z+9Ti};1L-dZy2AMo0y(j9Pc<~@^{=PR8TuIplLcATCm&-GV%5$t%rFM=c$r$K zWGHq5^=Ig0=mA9x7>y=7v1UuMGC%+al;(oc+>?*7`U|o_c|1^>Z?YPjwj{eZw>MM! bl%Of4wLtS0Wn^V6&e-D3>&-Vgi_H)KcMdcW delta 202 zcmbQuJDYby6C)2#acXgKW`16LNoB$0dd4%GqnIoiRRsJ4JR*bR4dXL&6Vp?R;~l5; zu)8}a`UaS#O^KbN;mzvJmO6Pm^Fwy+4807448zHStm>1Gu?VqhW$0uWfq1-3ty3}- zJAsNbbTjmUA_k1clbu+zCD|AtfD=k{LusDL$5{OZS)n|3D9t-rjZIsU!<)yOseMY& Zl+s$Dd5bc#G8SiS@#gd9pPa>J2mrepGY|j( From e22179a3bcd463eaef09a28021875e3565d1f019 Mon Sep 17 00:00:00 2001 From: aamster Date: Wed, 14 Apr 2021 16:50:03 -0700 Subject: [PATCH 30/86] Use mtrain only for behavior only; LIMS otherwise --- .../tables/sessions_table.py | 17 ++++++++++++++++- .../expected/behavior_session_table.pkl | Bin 970316 -> 1002113 bytes 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/allensdk/brain_observatory/behavior/behavior_project_cache/tables/sessions_table.py b/allensdk/brain_observatory/behavior/behavior_project_cache/tables/sessions_table.py index d97903156..ac3862514 100644 --- a/allensdk/brain_observatory/behavior/behavior_project_cache/tables/sessions_table.py +++ b/allensdk/brain_observatory/behavior/behavior_project_cache/tables/sessions_table.py @@ -78,7 +78,7 @@ def postprocess_additional(self): # Prioritize behavior session_type self._df['session_type'] = \ - self._df['session_type_behavior'] + self.__get_session_type() self._df = self._df.drop( ['session_type_behavior', 'session_type_ophys'], axis=1) @@ -99,3 +99,18 @@ def parse_session_number(session_type: str): self._df.loc[session_type.index, 'session_number'] = \ session_type.apply(parse_session_number) + + def __get_session_type(self) -> pd.Series: + """Session type is returned by both mtrain for behavior sessions + as well as in LIMS table ophys_sessions. + + This method applies logic to use the mtrain value for behavior-only + sessions and LIMS value otherwise + """ + behavior_only = self._df['ophys_session_id'].isnull() + behavior_only_session = self._df[behavior_only]\ + ['session_type_behavior'] + behavior_ophys_session = self._df[~behavior_only]['session_type_ophys'] + return pd.concat([behavior_only_session, behavior_ophys_session]) + + diff --git a/allensdk/test/brain_observatory/behavior/resources/project_metadata_writer/expected/behavior_session_table.pkl b/allensdk/test/brain_observatory/behavior/resources/project_metadata_writer/expected/behavior_session_table.pkl index 24230d09fa1e693892389b26c788d6142733d468..9dd331cc0477785fafff4b975464d22a3a68685d 100644 GIT binary patch literal 1002113 zcmeFZcc4_&)&9Rx#NK0xijEzzp6=6Ic9>_x#!*^$B$?lJGN;;E#IKA zLkEu@HE#Uay$6r4g-@KW;W%mH^tE+0TuzNTG}UEDtH#4ZO;>f5*P=0DW_8voO0z44QpkEyR>=GrO;jTlvH z#)R5h=WZT4ylI~iqsI;!H+0;%5u-;98Zl(j#2E&U9Xe>_h*7nb&0VW~=%CTV1~m=d zYwr=`MvSldOqw|Jkg+5789El9*@o6kWA+?6YW$#4jrLDJY{bY~k{Z*j!}cCIa?ox= zM~!Y&I&sDkqlSza+%$gl*hv#-uhEUHWvn}oX&OJgmUqw5dyn(Lxkir}K4ILTT3^OD z)e0HgV4K^OL-!jqbnJ*mzIu*1T-$O13ym3DUsKI-^tipp4jngW{OCa=_N=db+|cop zCbniCz2}HV18PlKq?Kr~`!Z%3Gj{auLkEwq)i$~@nwS65v>%?ETHezF&M(tLgv2E+vVy=d--FM=2a9|EHm2CiUskARSuU^Wnc5K3{!+hW}dQGhNN^$VtN&o;c00 z!rZJ~n43_an+-}UOyj35^pA~yNMP9B

    &>ggdxB?1&#|k_WinzyyYY_=905WYE9_ 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


    ;H{wdE>bLzt8oU3Hm?nC%2yA`ak_pv^)&` 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 RUVZ;EeuGEy!c{{h-L%Sr$M From 7fdb7b5ec917463875bacfa05dc62b6e02eba371 Mon Sep 17 00:00:00 2001 From: aamster Date: Wed, 14 Apr 2021 17:15:22 -0700 Subject: [PATCH 31/86] flake8 --- .../behavior_project_cache/tables/sessions_table.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/allensdk/brain_observatory/behavior/behavior_project_cache/tables/sessions_table.py b/allensdk/brain_observatory/behavior/behavior_project_cache/tables/sessions_table.py index ac3862514..eeea39be5 100644 --- a/allensdk/brain_observatory/behavior/behavior_project_cache/tables/sessions_table.py +++ b/allensdk/brain_observatory/behavior/behavior_project_cache/tables/sessions_table.py @@ -108,9 +108,7 @@ def __get_session_type(self) -> pd.Series: sessions and LIMS value otherwise """ behavior_only = self._df['ophys_session_id'].isnull() - behavior_only_session = self._df[behavior_only]\ - ['session_type_behavior'] + behavior_only_session = \ + self._df[behavior_only]['session_type_behavior'] behavior_ophys_session = self._df[~behavior_only]['session_type_ophys'] return pd.concat([behavior_only_session, behavior_ophys_session]) - - From 43125e3d24a630cfab13b406603fda7de4b5448c Mon Sep 17 00:00:00 2001 From: aamster Date: Thu, 15 Apr 2021 14:22:47 -0700 Subject: [PATCH 32/86] Remove comment --- .../behavior/behavior_project_cache/tables/sessions_table.py | 1 - 1 file changed, 1 deletion(-) diff --git a/allensdk/brain_observatory/behavior/behavior_project_cache/tables/sessions_table.py b/allensdk/brain_observatory/behavior/behavior_project_cache/tables/sessions_table.py index eeea39be5..eaeedfed7 100644 --- a/allensdk/brain_observatory/behavior/behavior_project_cache/tables/sessions_table.py +++ b/allensdk/brain_observatory/behavior/behavior_project_cache/tables/sessions_table.py @@ -76,7 +76,6 @@ def postprocess_additional(self): self._df = self._df.drop(['date_of_acquisition_behavior', 'date_of_acquisition_ophys'], axis=1) - # Prioritize behavior session_type self._df['session_type'] = \ self.__get_session_type() self._df = self._df.drop( From c42bcaaf96ea33783ff226685e3a74dd6020ddd4 Mon Sep 17 00:00:00 2001 From: aamster Date: Thu, 22 Apr 2021 08:02:00 -0700 Subject: [PATCH 33/86] Updates metadata test data after update to mtrain --- .../expected/behavior_session_table.pkl | Bin 1002113 -> 1002084 bytes .../expected/ophys_experiment_table.pkl | Bin 399298 -> 399298 bytes .../expected/ophys_session_table.pkl | Bin 91826 -> 91826 bytes 3 files changed, 0 insertions(+), 0 deletions(-) diff --git a/allensdk/test/brain_observatory/behavior/resources/project_metadata_writer/expected/behavior_session_table.pkl b/allensdk/test/brain_observatory/behavior/resources/project_metadata_writer/expected/behavior_session_table.pkl index 9dd331cc0477785fafff4b975464d22a3a68685d..6aea978bb1d1f0b127a4e13aba65d05453656ef5 100644 GIT binary patch delta 22953 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Ýp!6`x0YRxOD$z?_hx}*E+eBcP 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 ZE9=fs)7;@bz|{S%&h-pkJ#YBJ{{_*FCpQ29 diff --git a/allensdk/test/brain_observatory/behavior/resources/project_metadata_writer/expected/ophys_experiment_table.pkl b/allensdk/test/brain_observatory/behavior/resources/project_metadata_writer/expected/ophys_experiment_table.pkl index f1339a9e75df4c99439145529ab0372e2e5bb9d4..aacdde21d5d2108ad838bfad6658ccd92fcbf89a 100644 GIT binary patch delta 634 zcmbV|zfV(96vz9uRW1(lHeX#Vi#{!{@4j~6JsA2VMms39+FuBXWg=aaCPoaJv%!_uS99_a1ii!;b#ty-5Sp zp(E3NG;d zS(~2D3i{zm8aQ2QIxd~M_vx!^Q`Z%Ht25avv|7-~Dr=i38UG}Mq~P`&NPy>Zuz2w? z97K5IIj)mt8N8B(&L8q{#xI|LUucYf=aJ@G4i5JoA;UjwN?OlUUu#pbN|xS%4eBH|x! IUmQ0706jXjWB>pF delta 692 zcma)2KWGzS6whm-eyL(_lA{P-rzEz?-8InMWyuLb5^7V=nA9ZI5@-og(A1@^f(8U@ zTvB+$1DCi-rYHr^I2h7N7h@M~CvhrH-Cguu+RddO{C@BI^LxMd-nY|{cRKRhW*B1; z1IJ-mj;)ow2U&rfLkJA2+_*$L8=jsGDRhc0mir>^E zRx|?-rv$b%ftRL&IG7XogYO3F8GLfb#@?iaYx)a}Wi9+!RB&us!M{b;O^XrJ!e7f5 zuydN~)mU70_tI28G%WK%y^IRt&8h_xQM<;RpLBSk!eXw8Ub_L7mb$ z))fkFhP!hKd~`R@MK5$6jl1x_gqjmtgP@P9kKBne z5PZ{q3|{)Z!UgLO*}SpNWIlz@)L4X|6RN|yMCl^Po82v7j4-?mpVQqs<=^}V*5Jz@ diff --git a/allensdk/test/brain_observatory/behavior/resources/project_metadata_writer/expected/ophys_session_table.pkl b/allensdk/test/brain_observatory/behavior/resources/project_metadata_writer/expected/ophys_session_table.pkl index 6d7e14fae3eaa32080ec20911d9a0a83b434d184..2906af06728de33739e289ee5bd1e8adfc5faece 100644 GIT binary patch delta 381 zcmXX>ze_@46!jU(%?Q7S?0fpsCqf7jJtD-K-eLp|Ij8UWgA9sLa}b?;TTUV!f<{M| z;^7C;Xn%mw*d%Sa1WnF;ly|xJ+S5$K01*Nq&P=2%wY4RvWHu4 zdp#L^@o2!wn4Z5nLQsvNw@FB;I;xyttNJo6t_SGs#PGIFcuEqURRhy?!Zbo+uI_pj z54N<^CFjJ16Az)M1#z!2@u-#jn1?x{2_{kr?;-jXf^HF7HiKRu6fK5&gQ1uwm}SCy zo?~2Qc(JnRUNLaV8QKiE35w&X~hmHQ9dz_82&FE|Q5 zFQj3C!>JS88zu!hW){a!h3<;97Wn_&LyNwG*Rz;7E)7@FZ|j293k@44C)d% bO$UYy$u4EE90{i}^TTcFFk4CCw!PoKz)gIa delta 421 zcmXwzKTE?<6vZ2*^|eL%T;dA`8(S00>A6W1Ut9UDLylN)i*SY-C zigSvO4ZSiSJ0W{b#hso}eEFn%38;BP(Pc58JcREs^lJZpr}XdeUH}B zT%~xw$;cyiPK?fn fOa`9$3kAYn`5$7?VAAcf1@Jn3_(sTnFZcNas~?0+ From 6deeb5d07c53b5571cb6eefc052d652b2d8cfbbd Mon Sep 17 00:00:00 2001 From: danielsf Date: Fri, 23 Apr 2021 15:56:46 -0700 Subject: [PATCH 34/86] remove unused import --- allensdk/api/cloud_cache/manifest.py | 1 - 1 file changed, 1 deletion(-) diff --git a/allensdk/api/cloud_cache/manifest.py b/allensdk/api/cloud_cache/manifest.py index 658c1da5a..1abb66562 100644 --- a/allensdk/api/cloud_cache/manifest.py +++ b/allensdk/api/cloud_cache/manifest.py @@ -1,7 +1,6 @@ from typing import Dict, List, Any import json import pathlib -import copy from typing import Union from allensdk.api.cloud_cache.utils import relative_path_from_url # noqa: E501 from allensdk.api.cloud_cache.file_attributes import CacheFileAttributes # noqa: E501 From 562d5fb07ad6ac22ba9e01d93c393027782e4a7d Mon Sep 17 00:00:00 2001 From: danielsf Date: Thu, 22 Apr 2021 16:43:29 -0700 Subject: [PATCH 35/86] do not download redundant data files If a data file is unchanged from version to version, just construct a symlink to the copy already downloaded --- allensdk/api/cloud_cache/cloud_cache.py | 119 +++++++++- allensdk/test/api/cloud_cache/conftest.py | 35 +++ .../api/cloud_cache/test_smart_download.py | 223 ++++++++++++++++++ allensdk/test/api/cloud_cache/utils.py | 111 +++++++++ 4 files changed, 476 insertions(+), 12 deletions(-) create mode 100644 allensdk/test/api/cloud_cache/conftest.py create mode 100644 allensdk/test/api/cloud_cache/test_smart_download.py create mode 100644 allensdk/test/api/cloud_cache/utils.py diff --git a/allensdk/api/cloud_cache/cloud_cache.py b/allensdk/api/cloud_cache/cloud_cache.py index 93af0ddb5..3008a2a04 100644 --- a/allensdk/api/cloud_cache/cloud_cache.py +++ b/allensdk/api/cloud_cache/cloud_cache.py @@ -7,6 +7,7 @@ import semver import tqdm import re +import json from botocore import UNSIGNED from botocore.client import Config from allensdk.internal.core.lims_utilities import safe_system_path @@ -39,6 +40,14 @@ def __init__(self, cache_dir, project_name): self._manifest = None self._cache_dir = cache_dir + + # self._downloaded_data_path is where we will keep a JSONized + # dict mapping paths to downloaded files to their file_hashes; + # this will be used when determining if a downloaded file + # can instead be a symlink + c_path = pathlib.Path(self._cache_dir) + self._downloaded_data_path = c_path / '_downloaded_data.json' + self._project_name = project_name self._manifest_file_names = self._list_all_manifests() @@ -191,6 +200,88 @@ def load_manifest(self, manifest_name: str): json_input=f ) + def _update_list_of_downloads(self, + file_attributes: CacheFileAttributes + ) -> None: + """ + Update the local file that keeps track of files that have actually + been downloaded to reflect a newly downloaded file. + + Parameters + ---------- + file_attributes: CacheFileAttributes + + Returns + ------- + None + """ + if not file_attributes.local_path.exists(): + # This file does not exist; there is nothing to do + return None + + if self._downloaded_data_path.exists(): + with open(self._downloaded_data_path, 'rb') as in_file: + downloaded_data = json.load(in_file) + else: + downloaded_data = {} + + abs_path = str(file_attributes.local_path.resolve()) + downloaded_data[abs_path] = file_attributes.file_hash + with open(self._downloaded_data_path, 'w') as out_file: + out_file.write(json.dumps(downloaded_data, + indent=2, + sort_keys=True)) + return None + + def _check_for_identical_copy(self, + file_attributes: CacheFileAttributes + ) -> bool: + """ + Check the manifest of files that have been locally downloaded to + see if a file with an identical hash to the requested file has already + been downloaded. If it has, create a symlink to the downloaded file + at the requested file's localpath, update the manifest of downloaded + files, and return True. + + Else return False + + Parameters + ---------- + file_attributes: CacheFileAttributes + The file we are considering downloading + + Returns + ------- + bool + """ + if not self._downloaded_data_path.exists(): + return False + + with open(self._downloaded_data_path, 'rb') as in_file: + available_files = json.load(in_file) + + matched_path = None + for abs_path in available_files: + if available_files[abs_path] == file_attributes.file_hash: + matched_path = pathlib.Path(abs_path) + break + + if matched_path is None: + return False + + # double check that locally downloaded file still has + # the expected hash + candidate_hash = file_hash_from_path(matched_path) + if candidate_hash != file_attributes.file_hash: + return False + + local_parent = file_attributes.local_path.parent.resolve() + if not local_parent.exists(): + os.makedirs(local_parent) + + file_attributes.local_path.symlink_to(matched_path.resolve()) + return True + def _file_exists(self, file_attributes: CacheFileAttributes) -> bool: """ Given a CacheFileAttributes describing a file, assess whether or @@ -213,20 +304,23 @@ def _file_exists(self, file_attributes: CacheFileAttributes) -> bool: If file_attributes.local_path exists but is not a file. It would be unclear how the cache should proceed in this case. """ + file_exists = False - if not file_attributes.local_path.exists(): - return False - if not file_attributes.local_path.is_file(): - raise RuntimeError(f"{file_attributes.local_path}\n" - "exists, but is not a file;\n" - "unsure how to proceed") - - full_path = file_attributes.local_path.resolve() - test_checksum = file_hash_from_path(full_path) - if test_checksum != file_attributes.file_hash: - return False + if file_attributes.local_path.exists(): + if not file_attributes.local_path.is_file(): + raise RuntimeError(f"{file_attributes.local_path}\n" + "exists, but is not a file;\n" + "unsure how to proceed") - return True + full_path = file_attributes.local_path.resolve() + test_checksum = file_hash_from_path(full_path) + if test_checksum == file_attributes.file_hash: + file_exists = True + + if not file_exists: + file_exists = self._check_for_identical_copy(file_attributes) + + return file_exists def data_path(self, file_id) -> dict: """ @@ -288,6 +382,7 @@ def download_data(self, file_id) -> pathlib.Path: super_attributes = self.data_path(file_id) file_attributes = super_attributes['file_attributes'] self._download_file(file_attributes) + self._update_list_of_downloads(file_attributes) return file_attributes.local_path def metadata_path(self, fname: str) -> dict: diff --git a/allensdk/test/api/cloud_cache/conftest.py b/allensdk/test/api/cloud_cache/conftest.py new file mode 100644 index 000000000..24f46547c --- /dev/null +++ b/allensdk/test/api/cloud_cache/conftest.py @@ -0,0 +1,35 @@ +import pytest + + +@pytest.fixture +def example_datasets(): + datasets = {} + data = {} + data['f1.txt'] = {'data': b'1234567', + 'file_id': '1'} + data['f2.txt'] = {'data': b'4567890', + 'file_id': '2'} + data['f3.txt'] = {'data': b'11121314', + 'file_id': '3'} + datasets['1.0'] = data + + data = {} + data['f1.txt'] = {'data': b'abcdefg', + 'file_id': '1'} + data['f2.txt'] = {'data': b'4567890', + 'file_id': '2'} + data['f3.txt'] = {'data': b'11121314', + 'file_id': '3'} + + datasets['2.0'] = data + + data = {} + data['f1.txt'] = {'data': b'1234567', + 'file_id': '1'} + data['f2.txt'] = {'data': b'xyzabcde', + 'file_id': '2'} + data['f3.txt'] = {'data': b'hijklmnop', + 'file_id': '3'} + + datasets['3.0'] = data + return datasets diff --git a/allensdk/test/api/cloud_cache/test_smart_download.py b/allensdk/test/api/cloud_cache/test_smart_download.py new file mode 100644 index 000000000..56b46e4af --- /dev/null +++ b/allensdk/test/api/cloud_cache/test_smart_download.py @@ -0,0 +1,223 @@ +import json +import hashlib +import pathlib +from moto import mock_s3 +from .utils import create_bucket +from allensdk.api.cloud_cache.cloud_cache import S3CloudCache + + +@mock_s3 +def test_smart_file_downloading(tmpdir, example_datasets): + """ + Test that the CloudCache is smart enough to build symlinks + where possible + """ + test_bucket_name = 'smart_download_bucket' + create_bucket(test_bucket_name, + example_datasets) + + cache_dir = pathlib.Path(tmpdir) / 'cache' + cache = S3CloudCache(cache_dir, test_bucket_name, 'project-x') + + # download all data files from all versions, keeping track + # of the paths to the downloaded data files + downloaded = {} + for version in ('1.0', '2.0', '3.0'): + downloaded[version] = {} + cache.load_manifest(f'project-x_manifest_v{version}.json') + for file_id in ('1', '2', '3'): + downloaded[version][file_id] = cache.download_data(file_id) + + # check that the version 1.0 of all files are actual files + for file_id in ('1', '2', '3'): + assert downloaded['1.0'][file_id].is_file() + assert not downloaded['1.0'][file_id].is_symlink() + + # check that v2.0 f1.txt is a new file + assert downloaded['2.0']['1'].is_file() + assert not downloaded['2.0']['1'].is_symlink() + + # check that v2.0 f2.txt and f3.txt are symlinks to + # the correct v1.0 files + for file_id in ('2', '3'): + assert downloaded['2.0'][file_id].is_file() + assert downloaded['2.0'][file_id].is_symlink() + + # check that symlink points to the correct file + test = downloaded['2.0'][file_id].resolve() + control = downloaded['1.0'][file_id].resolve() + if test != control: + test = downloaded['2.0'][file_id].resolve() + control = downloaded['1.0'][file_id].resolve() + raise RuntimeError(f'{test} != {control}\n' + 'even though the first is a symlink') + + # check that the absolute paths of the files are different, + # even though one is a symlink + test = downloaded['2.0'][file_id].absolute() + control = downloaded['1.0'][file_id].absolute() + if test == control: + test = downloaded['2.0'][file_id].absolute() + control = downloaded['1.0'][file_id].absolute() + raise RuntimeError(f'{test} == {control}\n' + 'even though they should be ' + 'different absolute paths') + + # repeat the above tests for v3.0, f1.txt + assert downloaded['3.0']['1'].is_file() + assert downloaded['3.0']['1'].is_symlink() + if downloaded['3.0']['1'].resolve() != downloaded['1.0']['1'].resolve(): + test = downloaded['3.0']['1'].resolve() + control = downloaded['1.0']['1'].resolve() + raise RuntimeError(f'{test} != {control}\n' + 'even though the first is a symlink') + + if downloaded['3.0']['1'].absolute() == downloaded['1.0']['1'].absolute(): + test = downloaded['3.0']['1'].absolute() + control = downloaded['1.0']['1'].absolute() + raise RuntimeError(f'{test} == {control}\n' + 'even though they should be ' + 'different absolute paths') + + # check that v3 v2.txt and f3.txt are not symlinks + assert downloaded['3.0']['2'].is_file() + assert not downloaded['3.0']['2'].is_symlink() + assert downloaded['3.0']['3'].is_file() + assert not downloaded['3.0']['3'].is_symlink() + + +@mock_s3 +def test_on_corrupted_files(tmpdir, example_datasets): + """ + Test that the CloudCache re-downloads files when they have been + corrupted + """ + bucket_name = 'corruption_bucket' + create_bucket(bucket_name, + example_datasets) + + cache_dir = pathlib.Path(tmpdir) / 'cache' + cache = S3CloudCache(cache_dir, bucket_name, 'project-x') + + version_list = ('1.0', '2.0', '3.0') + file_id_list = ('1', '2', '3') + + for version in version_list: + cache.load_manifest(f'project-x_manifest_v{version}.json') + for file_id in file_id_list: + cache.download_data(file_id) + + # make sure that all files exist + for version in version_list: + cache.load_manifest(f'project-x_manifest_v{version}.json') + for file_id in file_id_list: + attr = cache.data_path(file_id) + assert attr['exists'] + + # Check that, when a file on disk gets corrupted, + # all of the symlinks that point back to that file + # get marked as `not exists` + + hasher = hashlib.blake2b() + hasher.update(b'4567890') + true_hash = hasher.hexdigest() + + cache.load_manifest('project-x_manifest_v1.0.json') + attr = cache.data_path('2') + with open(attr['local_path'], 'wb') as out_file: + out_file.write(b'xxxxxx') + + attr = cache.data_path('2') + assert not attr['exists'] + cache.load_manifest('project-x_manifest_v2.0.json') + attr = cache.data_path('2') + assert not attr['exists'] + + # re-download one of the identical files, and verify + # that both datasets are restored + cache.download_data('2') + attr = cache.data_path('2') + assert attr['exists'] + redownloaded_path = attr['local_path'] + cache.load_manifest('project-x_manifest_v1.0.json') + attr = cache.data_path('2') + assert attr['exists'] + other_path = attr['local_path'] + + hasher = hashlib.blake2b() + with open(other_path, 'rb') as in_file: + hasher.update(in_file.read()) + assert hasher.hexdigest() == true_hash + + # The file is downloaded to other_path because that was + # the first path originally downloaded and stored + # in CloudCache._downloaded_data_path + + assert other_path.resolve() == redownloaded_path.resolve() + assert other_path.absolute() != redownloaded_path.absolute() + + +@mock_s3 +def test_corrupted_download_manifest(tmpdir, example_datasets): + """ + Test that CloudCache can handle the case where the + _downloaded_data_path dict gets corrupted + """ + bucket_name = 'manifest_corruption_bucket' + create_bucket(bucket_name, + example_datasets) + + cache_dir = pathlib.Path(tmpdir) / 'cache' + cache = S3CloudCache(cache_dir, bucket_name, 'project-x') + + version_list = ('1.0', '2.0', '3.0') + file_id_list = ('1', '2', '3') + + for version in version_list: + cache.load_manifest(f'project-x_manifest_v{version}.json') + for file_id in file_id_list: + cache.download_data(file_id) + + with open(cache._downloaded_data_path, 'rb') as in_file: + src_data = json.load(in_file) + + # write a corrupted downloaded_data_path + for k in src_data: + src_data[k] = '' + with open(cache._downloaded_data_path, 'w') as out_file: + out_file.write(json.dumps(src_data, indent=2)) + + # now corrupt one of the data files + hasher = hashlib.blake2b() + hasher.update(b'4567890') + true_hash = hasher.hexdigest() + + cache.load_manifest('project-x_manifest_v1.0.json') + attr = cache.data_path('2') + + # assert below will pass; because file exists and is not yet corrupted, + # CloudCache won't consult _downloaded_data_path + assert attr['exists'] + + with open(attr['local_path'], 'wb') as out_file: + out_file.write(b'xxxxx') + + cache.load_manifest('project-x_manifest_v2.0.json') + attr = cache.data_path('2') + assert not attr['exists'] + cache.download_data('2') + attr = cache.data_path('2') + downloaded_path = attr['local_path'] + + assert attr['exists'] + hasher = hashlib.blake2b() + with open(attr['local_path'], 'rb') as in_file: + hasher.update(in_file.read()) + test_hash = hasher.hexdigest() + assert test_hash == true_hash + + cache.load_manifest('project-x_manifest_v1.0.json') + attr = cache.data_path('2') + assert attr['exists'] + assert attr['local_path'].resolve() == downloaded_path.resolve() + assert attr['local_path'].absolute() != downloaded_path.absolute() diff --git a/allensdk/test/api/cloud_cache/utils.py b/allensdk/test/api/cloud_cache/utils.py new file mode 100644 index 000000000..e3c16d358 --- /dev/null +++ b/allensdk/test/api/cloud_cache/utils.py @@ -0,0 +1,111 @@ +import boto3 +import json +import hashlib + + +def load_dataset(data_blobs: dict, + manifest_version: str, + bucket_name: str, + client: boto3.client) -> None: + """ + Load a test dataset into moto's mocked S3 + + Parameters + ---------- + data_blobs: dict + Maps filename to a dict + 'data': the bytes in the data file + 'file_id': the file_id of the data file + + manifest_version: str + The version of the manifest (manifest will be + uploaded to moto3 as manifest_{manifest_version}.json + + bucket_name: str + + client: boto3.client + + Returns + ------- + None + Uploads the provided data, generates the manifest, + and uploads the manifest to moto3 + """ + + project_name = 'project-x' + + for fname in data_blobs: + client.put_object(Bucket=bucket_name, + Key=f'project-x/data/{fname}', + Body=data_blobs[fname]['data']) + + response = client.list_object_versions(Bucket=bucket_name) + fname_to_version = {} + for obj in response['Versions']: + if obj['IsLatest']: + fname = obj['Key'].split('/')[-1] + fname_to_version[fname] = obj['VersionId'] + + manifest = {} + manifest['manifest_version'] = manifest_version + manifest['project_name'] = project_name + manifest['metadata_file_id_column_name'] = 'file_id' + manifest['metadata_files'] = {} + manifest['data_pipeline'] = 'placeholder' + + data_file_dict = {} + url_root = f'http://{bucket_name}.s3.amazonaws.com/{project_name}/data' + for fname in data_blobs: + url = f'{url_root}/{fname}' + hasher = hashlib.blake2b() + hasher.update(data_blobs[fname]['data']) + checksum = hasher.hexdigest() + + data_file = {'url': url, + 'version_id': fname_to_version[fname], + 'file_hash': checksum} + + data_file_dict[data_blobs[fname]['file_id']] = data_file + + manifest['data_files'] = data_file_dict + + manifest_k = f'{project_name}/manifests/' + manifest_k += f'{project_name}_manifest_v{manifest_version}.json' + client.put_object(Bucket=bucket_name, + Key=manifest_k, + Body=bytes(json.dumps(manifest), 'utf-8')) + + return None + + +def create_bucket(test_bucket_name: str, datasets: dict) -> None: + """ + Create a bucket and populate it with example datasets + + Parameters + ---------- + test_bucket_name: str + Name of the bucket + + datasets: dict + Keyed on version names; values are dicts of individual + data files to be loaded to the bucket + """ + + conn = boto3.resource('s3', region_name='us-east-1') + conn.create_bucket(Bucket=test_bucket_name, ACL='public-read') + + # https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/s3.html#bucketversioning + bucket_versioning = conn.BucketVersioning(test_bucket_name) + bucket_versioning.enable() + + client = boto3.client('s3', region_name='us-east-1') + + # upload first dataset + for v in datasets.keys(): + load_dataset(datasets[v], + v, + test_bucket_name, + client) + + return None From d4256f95c35dee59424992adeffec80d058698d1 Mon Sep 17 00:00:00 2001 From: danielsf Date: Fri, 23 Apr 2021 11:08:43 -0700 Subject: [PATCH 36/86] add test to make sure local_cache can access data downloaded with S3CloudCache --- .../test/api/cloud_cache/test_local_cache.py | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 allensdk/test/api/cloud_cache/test_local_cache.py diff --git a/allensdk/test/api/cloud_cache/test_local_cache.py b/allensdk/test/api/cloud_cache/test_local_cache.py new file mode 100644 index 000000000..fa6daa9e5 --- /dev/null +++ b/allensdk/test/api/cloud_cache/test_local_cache.py @@ -0,0 +1,50 @@ +import pathlib +from moto import mock_s3 +from .utils import create_bucket +from allensdk.api.cloud_cache.cloud_cache import S3CloudCache +from allensdk.api.cloud_cache.cloud_cache import LocalCache + + +@mock_s3 +def test_local_cache_file_access(tmpdir, example_datasets): + """ + Create a cache; download some, but not all of the files + with S3CloudCache; verify that we can access the files + with LocalCache + """ + + bucket_name = 'local_cache_bucket' + create_bucket(bucket_name, example_datasets) + cache_dir = pathlib.Path(tmpdir) / 'cache' + cloud_cache = S3CloudCache(cache_dir, bucket_name, 'project-x') + + cloud_cache.load_manifest('project-x_manifest_v1.0.json') + cloud_cache.download_data('1') + cloud_cache.download_data('3') + + cloud_cache.load_manifest('project-x_manifest_v3.0.json') + cloud_cache.download_data('2') + + del cloud_cache + + local_cache = LocalCache(cache_dir, 'project-x') + + manifest_set = set(local_cache.manifest_file_names) + assert manifest_set == {'project-x_manifest_v1.0.json', + 'project-x_manifest_v3.0.json'} + + local_cache.load_manifest('project-x_manifest_v1.0.json') + attr = local_cache.data_path('1') + assert attr['exists'] + attr = local_cache.data_path('2') + assert not attr['exists'] + attr = local_cache.data_path('3') + assert attr['exists'] + + local_cache.load_manifest('project-x_manifest_v3.0.json') + attr = local_cache.data_path('1') + assert attr['exists'] # because file 1 is the same in v1.0 and v3.0 + attr = local_cache.data_path('2') + assert attr['exists'] + attr = local_cache.data_path('3') + assert not attr['exists'] From 0da956d15a7b2e03da855083e072f4320d479854 Mon Sep 17 00:00:00 2001 From: danielsf Date: Fri, 23 Apr 2021 11:10:03 -0700 Subject: [PATCH 37/86] add _list_all_downloaded_manifests to CloudCacheBase --- allensdk/api/cloud_cache/cloud_cache.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/allensdk/api/cloud_cache/cloud_cache.py b/allensdk/api/cloud_cache/cloud_cache.py index 3008a2a04..b2de40069 100644 --- a/allensdk/api/cloud_cache/cloud_cache.py +++ b/allensdk/api/cloud_cache/cloud_cache.py @@ -51,6 +51,14 @@ def __init__(self, cache_dir, project_name): self._project_name = project_name self._manifest_file_names = self._list_all_manifests() + def _list_all_downloaded_manifests(self) -> list: + """ + Return a list of all of the manifest files that have been + downloaded for this dataset + """ + return [x for x in os.listdir(self._cache_dir) + if re.fullmatch(".*_manifest_v.*.json", x)] + @abstractmethod def _list_all_manifests(self) -> list: """ @@ -655,8 +663,7 @@ def __init__(self, cache_dir, project_name): super().__init__(cache_dir=cache_dir, project_name=project_name) def _list_all_manifests(self) -> list: - return [x for x in os.listdir(self._cache_dir) - if re.fullmatch(".*_manifest_v.*.json", x)] + return self._list_all_downloaded_manifests() def _download_manifest(self, manifest_name: str): raise NotImplementedError() From 650088d91786ecc292ae7007bc1a105093e42f24 Mon Sep 17 00:00:00 2001 From: danielsf Date: Fri, 23 Apr 2021 11:55:36 -0700 Subject: [PATCH 38/86] factor out method that actually does the work of loading manifest --- allensdk/api/cloud_cache/cloud_cache.py | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/allensdk/api/cloud_cache/cloud_cache.py b/allensdk/api/cloud_cache/cloud_cache.py index b2de40069..1990703b4 100644 --- a/allensdk/api/cloud_cache/cloud_cache.py +++ b/allensdk/api/cloud_cache/cloud_cache.py @@ -182,15 +182,19 @@ def manifest_file_names(self) -> list: """ return copy.deepcopy(self._manifest_file_names) - def load_manifest(self, manifest_name: str): + def _load_manifest(self, manifest_name: str) -> Manifest: """ - Load a manifest from this dataset. + Load and return a manifest from this dataset. Parameters ---------- manifest_name: str The name of the manifest to load. Must be an element in self.manifest_file_names + + Returns + ------- + Manifest """ if manifest_name not in self.manifest_file_names: raise ValueError(f"manifest: {manifest_name}\n" @@ -203,10 +207,23 @@ def load_manifest(self, manifest_name: str): self._download_manifest(manifest_name) with open(filepath) as f: - self._manifest = Manifest( + local_manifest = Manifest( cache_dir=self._cache_dir, json_input=f ) + return local_manifest + + def load_manifest(self, manifest_name: str): + """ + Load a manifest from this dataset. + + Parameters + ---------- + manifest_name: str + The name of the manifest to load. Must be an element in + self.manifest_file_names + """ + self._manifest = self._load_manifest(manifest_name) def _update_list_of_downloads(self, file_attributes: CacheFileAttributes From 4f4f5d3a0a04242cb854b902dd5c63f0f0cd7f8c Mon Sep 17 00:00:00 2001 From: danielsf Date: Fri, 23 Apr 2021 14:23:17 -0700 Subject: [PATCH 39/86] expose valid file_id_values in manifest --- allensdk/api/cloud_cache/manifest.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/allensdk/api/cloud_cache/manifest.py b/allensdk/api/cloud_cache/manifest.py index 1abb66562..f4d67200e 100644 --- a/allensdk/api/cloud_cache/manifest.py +++ b/allensdk/api/cloud_cache/manifest.py @@ -50,6 +50,10 @@ def __init__(self, ] self._metadata_file_names.sort() + self._file_id_values: List[Any] = [ii for ii in + self._data['data_files'].keys()] + self._file_id_values.sort() + @property def project_name(self): """ @@ -80,6 +84,13 @@ def metadata_file_names(self): """ return self._metadata_file_names + @property + def file_id_values(self): + """ + List of valid file_id values + """ + return self._file_id_values + def _create_file_attributes(self, remote_path: str, version_id: str, From ffe6ad5a4039468cf7ff16243a72f4ff0147ab67 Mon Sep 17 00:00:00 2001 From: danielsf Date: Fri, 23 Apr 2021 14:28:37 -0700 Subject: [PATCH 40/86] patch up unit tests --- allensdk/test/api/cloud_cache/test_cache.py | 4 ++++ allensdk/test/api/cloud_cache/test_manifest.py | 1 + 2 files changed, 5 insertions(+) diff --git a/allensdk/test/api/cloud_cache/test_cache.py b/allensdk/test/api/cloud_cache/test_cache.py index 43acc94bf..0efd7befd 100644 --- a/allensdk/test/api/cloud_cache/test_cache.py +++ b/allensdk/test/api/cloud_cache/test_cache.py @@ -83,6 +83,7 @@ def test_loading_manifest(tmpdir): 'metadata_file_id_column_name': 'file_id', 'data_pipeline': 'placeholder', 'project_name': 'sam-beckett', + 'data_files': {}, 'metadata_files': {'a.csv': {'url': 'http://www.junk.com', 'version_id': '1111', 'file_hash': 'abcde'}, @@ -94,6 +95,7 @@ def test_loading_manifest(tmpdir): 'metadata_file_id_column_name': 'file_id', 'data_pipeline': 'placeholder', 'project_name': 'al', + 'data_files': {}, 'metadata_files': {'c.csv': {'url': 'http://www.absurd.com', 'version_id': '3333', 'file_hash': 'lmnop'}, @@ -511,6 +513,7 @@ def test_download_metadata(tmpdir): 'file_hash': true_checksum} manifest['metadata_files'] = {'metadata_file.csv': metadata_file} + manifest['data_files'] = {} manifest['data_pipeline'] = 'placeholder' client.put_object(Bucket=test_bucket_name, @@ -602,6 +605,7 @@ def test_metadata(tmpdir): 'file_hash': true_checksum} manifest['metadata_files'] = {'metadata_file.csv': metadata_file} + manifest['data_files'] = {} manifest['data_pipeline'] = 'placeholder' client.put_object(Bucket=test_bucket_name, diff --git a/allensdk/test/api/cloud_cache/test_manifest.py b/allensdk/test/api/cloud_cache/test_manifest.py index daa2f523f..b7f44a97d 100644 --- a/allensdk/test/api/cloud_cache/test_manifest.py +++ b/allensdk/test/api/cloud_cache/test_manifest.py @@ -64,6 +64,7 @@ def manifest_for_metadata(tmpdir): 'file_hash': 'fghijk'} manifest['metadata_files'] = metadata_files + manifest['data_files'] = {} manifest['project_name'] = "some-project" manifest['manifest_version'] = '000' manifest['metadata_file_id_column_name'] = 'file_id' From 81cfd7004292d2334f1ca5e2eb0a336ced02a499 Mon Sep 17 00:00:00 2001 From: danielsf Date: Fri, 23 Apr 2021 14:29:21 -0700 Subject: [PATCH 41/86] add method to summarize changes between manifest versions --- allensdk/api/cloud_cache/cloud_cache.py | 132 ++++++++++++++++ allensdk/test/api/cloud_cache/conftest.py | 132 ++++++++++++++++ .../test/api/cloud_cache/test_change_log.py | 148 ++++++++++++++++++ allensdk/test/api/cloud_cache/utils.py | 40 ++++- 4 files changed, 451 insertions(+), 1 deletion(-) create mode 100644 allensdk/test/api/cloud_cache/test_change_log.py diff --git a/allensdk/api/cloud_cache/cloud_cache.py b/allensdk/api/cloud_cache/cloud_cache.py index 1990703b4..c1c9eaf2e 100644 --- a/allensdk/api/cloud_cache/cloud_cache.py +++ b/allensdk/api/cloud_cache/cloud_cache.py @@ -1,3 +1,4 @@ +from typing import List, Tuple, Dict from abc import ABC, abstractmethod import os import copy @@ -494,6 +495,137 @@ def get_metadata(self, fname: str) -> pd.DataFrame: local_path = self.download_metadata(fname) return pd.read_csv(local_path) + def _detect_changes(self, + filename_to_hash: dict) -> List[Tuple[str, str]]: + """ + Assemble list of changes between two manifests + + Parameters + ---------- + filename_to_hash: dict + filename_to_hash[0] is a dict mapping file names to file hashes + for manifest 0 + + filename_to_hash[1] is a dict mapping file names to file hashes + for manifest 1 + + Returns + ------- + List[Tuple[str, str]] + List of changes between manifest 0 and manifest 1. + + Notes + ----- + Changes are tuples of the form + (fname, string describing how fname changed) + + e.g. + + ('data/f1.txt', 'data/f1.txt renamed data/f5.txt') + ('data/f2.txt', 'data/f2.txt deleted') + ('data/f3.txt', 'data/f3.txt created') + ('data/f4.txt', 'data/f4.txt changed') + """ + output = [] + n0 = set(filename_to_hash[0].keys()) + n1 = set(filename_to_hash[1].keys()) + all_file_names = n0.union(n1) + + hash_to_filename = {} + for v in (0, 1): + hash_to_filename[v] = {} + for fname in filename_to_hash[v]: + hash_to_filename[v][filename_to_hash[v][fname]] = fname + + for fname in all_file_names: + delta = None + if fname in filename_to_hash[0] and fname in filename_to_hash[1]: + h0 = filename_to_hash[0][fname] + h1 = filename_to_hash[1][fname] + if h0 != h1: + delta = f'{fname} changed' + elif fname in filename_to_hash[0]: + h0 = filename_to_hash[0][fname] + if h0 in hash_to_filename[1]: + f1 = hash_to_filename[1][h0] + delta = f'{fname} renamed {f1}' + else: + delta = f'{fname} deleted' + elif fname in filename_to_hash[1]: + h1 = filename_to_hash[1][fname] + if h1 not in hash_to_filename[0]: + delta = f'{fname} created' + else: + raise RuntimeError("should never reach this line") + + if delta is not None: + output.append((fname, delta)) + + return output + + def summarize_comparison(self, + manifest_0_name: str, + manifest_1_name: str + ) -> Dict[str, List[Tuple[str, str]]]: + """ + Compare two manifests from this dataset. Return a dict + containing the list of metadata and data files that changed + between them + + Note: this assumes that manifest_0 predates manifest_1 + + Parameters + ---------- + manifest_0_name: str + + manifest_1_name: str + + Returns + ------- + result: Dict[List[Tuple[str, str]]] + result['data_changes'] lists changes to data files + result['metadata_changes'] lists changes to metadata files + + Notes + ----- + Changes are tuples of the form + (fname, string describing how fname changed) + + e.g. + + ('data/f1.txt', 'data/f1.txt renamed data/f5.txt') + ('data/f2.txt', 'data/f2.txt deleted') + ('data/f3.txt', 'data/f3.txt created') + ('data/f4.txt', 'data/f4.txt changed') + """ + man0 = self._load_manifest(manifest_0_name) + man1 = self._load_manifest(manifest_1_name) + + result = {} + for (result_key, + fname_list, + fname_lookup) in zip(('metadata_changes', 'data_changes'), + ((man0.metadata_file_names, + man1.metadata_file_names), + (man0.file_id_values, + man1.file_id_values)), + ((man0.metadata_file_attributes, + man1.metadata_file_attributes), + (man0.data_file_attributes, + man1.data_file_attributes))): + + filename_to_hash = {} + for version in (0, 1): + filename_to_hash[version] = {} + for file_id in fname_list[version]: + obj = fname_lookup[version](file_id) + file_name = relative_path_from_url(obj.url) + file_name = '/'.join(file_name.split('/')[1:]) + filename_to_hash[version][file_name] = obj.file_hash + changes = self._detect_changes(filename_to_hash) + result[result_key] = changes + return result + class S3CloudCache(CloudCacheBase): """ diff --git a/allensdk/test/api/cloud_cache/conftest.py b/allensdk/test/api/cloud_cache/conftest.py index 24f46547c..3e45b1dc8 100644 --- a/allensdk/test/api/cloud_cache/conftest.py +++ b/allensdk/test/api/cloud_cache/conftest.py @@ -1,4 +1,5 @@ import pytest +import copy @pytest.fixture @@ -33,3 +34,134 @@ def example_datasets(): datasets['3.0'] = data return datasets + + +@pytest.fixture +def baseline_data_with_metadata(): + data = {} + data['f1.txt'] = {'file_id': '1', 'data': b'1234'} + data['f2.txt'] = {'file_id': '2', 'data': b'2345'} + data['f3.txt'] = {'file_id': '3', 'data': b'6789'} + + metadata = {} + metadata['metadata_1.csv'] = b'abcdef' + metadata['metadata_2.csv'] = b'ghijklm' + metadata['metadata_3.csv'] = b'nopqrst' + return {'data': data, 'metadata': metadata} + + +@pytest.fixture +def example_datasets_with_metadata(baseline_data_with_metadata): + + example = {} + example['data'] = {} + example['metadata'] = {} + + data = copy.deepcopy(baseline_data_with_metadata) + example['data']['1.0'] = data['data'] + example['metadata']['1.0'] = data['metadata'] + + # delete one data file + data = copy.deepcopy(baseline_data_with_metadata) + data['data'].pop('f2.txt') + example['data']['2.0'] = data['data'] + example['metadata']['2.0'] = data['metadata'] + + # rename one data file + data = copy.deepcopy(baseline_data_with_metadata) + old = data['data'].pop('f2.txt') + data['data']['f4.txt'] = {'file_id': '4', 'data': old['data']} + example['data']['3.0'] = data['data'] + example['metadata']['3.0'] = data['metadata'] + + # change one data file + data = copy.deepcopy(baseline_data_with_metadata) + data['data']['f3.txt'] = {'file_id': '3', 'data': b'44556677'} + example['data']['4.0'] = data['data'] + example['metadata']['4.0'] = data['metadata'] + + # add a data file + data = copy.deepcopy(baseline_data_with_metadata) + data['data']['f4.txt'] = {'file_id': '4', 'data': b'44556677'} + example['data']['5.0'] = data['data'] + example['metadata']['5.0'] = data['metadata'] + + # delete a data file and change another + data = copy.deepcopy(baseline_data_with_metadata) + data['data'].pop('f2.txt') + data['data']['f1.txt'] = {'file_id': '1', 'data': b'xxxxxx'} + example['data']['6.0'] = data['data'] + example['metadata']['6.0'] = data['metadata'] + + # delete a data file and rename another + data = copy.deepcopy(baseline_data_with_metadata) + data['data'].pop('f2.txt') + old = data['data'].pop('f3.txt') + data['data']['f5.txt'] = {'file_id': '5', 'data': old['data']} + example['data']['7.0'] = data['data'] + example['metadata']['7.0'] = data['metadata'] + + # delete a data file and add another + data = copy.deepcopy(baseline_data_with_metadata) + data['data'].pop('f2.txt') + data['data']['f5.txt'] = {'file_id': '5', 'data': b'yyyyy'} + example['data']['8.0'] = data['data'] + example['metadata']['8.0'] = data['metadata'] + + # rename a data file and add another + data = copy.deepcopy(baseline_data_with_metadata) + old = data['data'].pop('f3.txt') + data['data']['f4.txt'] = {'file_id': '4', 'data': old['data']} + data['data']['f5.txt'] = {'file_id': '5', 'data': b'wwwwww'} + example['data']['9.0'] = data['data'] + example['metadata']['9.0'] = data['metadata'] + + # delete a metadata file + data = copy.deepcopy(baseline_data_with_metadata) + data['metadata'].pop('metadata_2.csv') + example['data']['10.0'] = data['data'] + example['metadata']['10.0'] = data['metadata'] + + # rename a metadata file + data = copy.deepcopy(baseline_data_with_metadata) + old = data['metadata'].pop('metadata_2.csv') + data['metadata']['metadata_4.csv'] = old + example['data']['11.0'] = data['data'] + example['metadata']['11.0'] = data['metadata'] + + # change a metadata file + data = copy.deepcopy(baseline_data_with_metadata) + data['metadata']['metadata_3.csv'] = b'12345' + example['data']['12.0'] = data['data'] + example['metadata']['12.0'] = data['metadata'] + + # add a metadata file + data = copy.deepcopy(baseline_data_with_metadata) + data['metadata']['metadata_4.csv'] = b'12345' + example['data']['13.0'] = data['data'] + example['metadata']['13.0'] = data['metadata'] + + # delete a data file and change a metadata file + data = copy.deepcopy(baseline_data_with_metadata) + data['data'].pop('f2.txt') + old = data['metadata'].pop('metadata_3.csv') + data['metadata']['metadata_4.csv'] = old + example['data']['14.0'] = data['data'] + example['metadata']['14.0'] = data['metadata'] + + # rename a data file, add two data files + # rename a metadata file and delete two metadata files + data = copy.deepcopy(baseline_data_with_metadata) + old = data['data'].pop('f1.txt') + data['data']['f4.txt'] = old + data['data']['f5.txt'] = {'file_id': '5', 'data': b'babababa'} + data['data']['f6.txt'] = {'file_id': '6', 'data': b'neighneigh'} + old = data['metadata'].pop('metadata_2.csv') + data['metadata']['metadata_4.csv'] = old + data['metadata'].pop('metadata_1.csv') + data['metadata'].pop('metadata_3.csv') + + example['data']['15.0'] = data['data'] + example['metadata']['15.0'] = data['metadata'] + + return example diff --git a/allensdk/test/api/cloud_cache/test_change_log.py b/allensdk/test/api/cloud_cache/test_change_log.py new file mode 100644 index 000000000..b438eae6c --- /dev/null +++ b/allensdk/test/api/cloud_cache/test_change_log.py @@ -0,0 +1,148 @@ +import pathlib +from moto import mock_s3 +from .utils import create_bucket +from allensdk.api.cloud_cache.cloud_cache import S3CloudCache + + +@mock_s3 +def test_summarize_comparison(tmpdir, example_datasets_with_metadata): + """ + Test that CloudCacheBase.summarize_comparison reports the correct + changes when comparing two manifests + """ + bucket_name = 'summarizing_bucket' + create_bucket(bucket_name, + example_datasets_with_metadata['data'], + metadatasets=example_datasets_with_metadata['metadata']) + + cache_dir = pathlib.Path(tmpdir) / 'cache' + cache = S3CloudCache(cache_dir, bucket_name, 'project-x') + + log = cache.summarize_comparison('project-x_manifest_v1.0.json', + 'project-x_manifest_v2.0.json') + + assert len(log['metadata_changes']) == 0 + assert len(log['data_changes']) == 1 + assert ('data/f2.txt', 'data/f2.txt deleted') in log['data_changes'] + + log = cache.summarize_comparison('project-x_manifest_v1.0.json', + 'project-x_manifest_v3.0.json') + + assert len(log['metadata_changes']) == 0 + assert len(log['data_changes']) == 1 + assert ('data/f2.txt', + 'data/f2.txt renamed data/f4.txt') in log['data_changes'] + + log = cache.summarize_comparison('project-x_manifest_v1.0.json', + 'project-x_manifest_v4.0.json') + + assert len(log['metadata_changes']) == 0 + assert len(log['data_changes']) == 1 + assert ('data/f3.txt', 'data/f3.txt changed') in log['data_changes'] + + log = cache.summarize_comparison('project-x_manifest_v1.0.json', + 'project-x_manifest_v5.0.json') + + assert len(log['metadata_changes']) == 0 + assert len(log['data_changes']) == 1 + assert ('data/f4.txt', 'data/f4.txt created') in log['data_changes'] + + log = cache.summarize_comparison('project-x_manifest_v1.0.json', + 'project-x_manifest_v6.0.json') + + assert len(log['metadata_changes']) == 0 + assert len(log['data_changes']) == 2 + assert ('data/f2.txt', 'data/f2.txt deleted') in log['data_changes'] + assert ('data/f1.txt', 'data/f1.txt changed') in log['data_changes'] + + log = cache.summarize_comparison('project-x_manifest_v1.0.json', + 'project-x_manifest_v7.0.json') + + assert len(log['metadata_changes']) == 0 + assert len(log['data_changes']) == 2 + assert ('data/f2.txt', 'data/f2.txt deleted') in log['data_changes'] + assert ('data/f3.txt', 'data/f3.txt ' + 'renamed data/f5.txt') in log['data_changes'] + + log = cache.summarize_comparison('project-x_manifest_v1.0.json', + 'project-x_manifest_v8.0.json') + + assert len(log['metadata_changes']) == 0 + assert len(log['data_changes']) == 2 + assert ('data/f2.txt', 'data/f2.txt deleted') in log['data_changes'] + assert ('data/f5.txt', 'data/f5.txt created') in log['data_changes'] + + log = cache.summarize_comparison('project-x_manifest_v1.0.json', + 'project-x_manifest_v9.0.json') + + assert len(log['metadata_changes']) == 0 + assert len(log['data_changes']) == 2 + assert ('data/f3.txt', 'data/f3.txt renamed ' + 'data/f4.txt') in log['data_changes'] + assert ('data/f5.txt', 'data/f5.txt created') in log['data_changes'] + + log = cache.summarize_comparison('project-x_manifest_v1.0.json', + 'project-x_manifest_v10.0.json') + + assert len(log['data_changes']) == 0 + assert len(log['metadata_changes']) == 1 + assert ('project_metadata/metadata_2.csv', + 'project_metadata/metadata_2.csv ' + 'deleted') in log['metadata_changes'] + + log = cache.summarize_comparison('project-x_manifest_v1.0.json', + 'project-x_manifest_v11.0.json') + + assert len(log['data_changes']) == 0 + assert len(log['metadata_changes']) == 1 + assert ('project_metadata/metadata_2.csv', + 'project_metadata/metadata_2.csv renamed ' + 'project_metadata/metadata_4.csv') in log['metadata_changes'] + + log = cache.summarize_comparison('project-x_manifest_v1.0.json', + 'project-x_manifest_v12.0.json') + + assert len(log['data_changes']) == 0 + assert len(log['metadata_changes']) == 1 + assert ('project_metadata/metadata_3.csv', + 'project_metadata/metadata_3.csv ' + 'changed') in log['metadata_changes'] + + log = cache.summarize_comparison('project-x_manifest_v1.0.json', + 'project-x_manifest_v13.0.json') + + assert len(log['data_changes']) == 0 + assert len(log['metadata_changes']) == 1 + assert ('project_metadata/metadata_4.csv', + 'project_metadata/metadata_4.csv ' + 'created') in log['metadata_changes'] + + log = cache.summarize_comparison('project-x_manifest_v1.0.json', + 'project-x_manifest_v14.0.json') + assert len(log['data_changes']) == 1 + assert len(log['metadata_changes']) == 1 + assert ('data/f2.txt', 'data/f2.txt deleted') in log['data_changes'] + assert ('project_metadata/metadata_3.csv', + 'project_metadata/metadata_3.csv renamed ' + 'project_metadata/metadata_4.csv') in log['metadata_changes'] + + log = cache.summarize_comparison('project-x_manifest_v1.0.json', + 'project-x_manifest_v15.0.json') + assert len(log['data_changes']) == 3 + assert len(log['metadata_changes']) == 3 + + ans1 = ('data/f1.txt', 'data/f1.txt renamed data/f4.txt') + ans2 = ('data/f5.txt', 'data/f5.txt created') + ans3 = ('data/f6.txt', 'data/f6.txt created') + + assert set(log['data_changes']) == {ans1, ans2, ans3} + + ans1 = ('project_metadata/metadata_2.csv', + 'project_metadata/metadata_2.csv renamed ' + 'project_metadata/metadata_4.csv') + ans2 = ('project_metadata/metadata_1.csv', + 'project_metadata/metadata_1.csv deleted') + ans3 = ('project_metadata/metadata_3.csv', + 'project_metadata/metadata_3.csv deleted') + + assert set(log['metadata_changes']) == {ans1, ans2, ans3} diff --git a/allensdk/test/api/cloud_cache/utils.py b/allensdk/test/api/cloud_cache/utils.py index e3c16d358..4b7c2e315 100644 --- a/allensdk/test/api/cloud_cache/utils.py +++ b/allensdk/test/api/cloud_cache/utils.py @@ -1,9 +1,11 @@ +from typing import Union, Optional import boto3 import json import hashlib def load_dataset(data_blobs: dict, + metadata_blobs: Union[dict, None], manifest_version: str, bucket_name: str, client: boto3.client) -> None: @@ -17,6 +19,9 @@ def load_dataset(data_blobs: dict, 'data': the bytes in the data file 'file_id': the file_id of the data file + metadata_blobs: Union[dict, None] + A dict mapping metadata filename to bytes in the file + manifest_version: str The version of the manifest (manifest will be uploaded to moto3 as manifest_{manifest_version}.json @@ -39,6 +44,13 @@ def load_dataset(data_blobs: dict, Key=f'project-x/data/{fname}', Body=data_blobs[fname]['data']) + if metadata_blobs is not None: + for fname in metadata_blobs: + client.put_object(Bucket=bucket_name, + Key=f'project-x/project_metadata/{fname}', + Body=metadata_blobs[fname]) + + response = client.list_object_versions(Bucket=bucket_name) fname_to_version = {} for obj in response['Versions']: @@ -69,6 +81,21 @@ def load_dataset(data_blobs: dict, manifest['data_files'] = data_file_dict + if metadata_blobs is not None: + url_root = f'http://{bucket_name}.s3.amazonaws.com/{project_name}/' + url_root += 'project_metadata' + + metadata_dict = {} + for fname in metadata_blobs: + url = f'{url_root}/{fname}' + hasher = hashlib.blake2b() + hasher.update(metadata_blobs[fname]) + metadata_dict[fname] = {'url': url, + 'file_hash': hasher.hexdigest(), + 'version_id': fname_to_version[fname]} + + manifest['metadata_files'] = metadata_dict + manifest_k = f'{project_name}/manifests/' manifest_k += f'{project_name}_manifest_v{manifest_version}.json' client.put_object(Bucket=bucket_name, @@ -78,7 +105,9 @@ def load_dataset(data_blobs: dict, return None -def create_bucket(test_bucket_name: str, datasets: dict) -> None: +def create_bucket(test_bucket_name: str, + datasets: dict, + metadatasets: Optional[dict]=None) -> None: """ Create a bucket and populate it with example datasets @@ -90,6 +119,10 @@ def create_bucket(test_bucket_name: str, datasets: dict) -> None: datasets: dict Keyed on version names; values are dicts of individual data files to be loaded to the bucket + + metadatasets: Optional[dict] + Keyed on version names; values are dicts of individual + metadata files to be loaded to the bucket (default: None) """ conn = boto3.resource('s3', region_name='us-east-1') @@ -103,7 +136,12 @@ def create_bucket(test_bucket_name: str, datasets: dict) -> None: # upload first dataset for v in datasets.keys(): + if metadatasets is not None: + m = metadatasets[v] + else: + m = None load_dataset(datasets[v], + m, v, test_bucket_name, client) From 6e4496e599ea2f9befe5cafbb464ab208daab1ea Mon Sep 17 00:00:00 2001 From: danielsf Date: Fri, 23 Apr 2021 16:15:01 -0700 Subject: [PATCH 42/86] pep8 changes to test/api/cloud_cache/utils.py --- allensdk/test/api/cloud_cache/utils.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/allensdk/test/api/cloud_cache/utils.py b/allensdk/test/api/cloud_cache/utils.py index 4b7c2e315..ec758c2a4 100644 --- a/allensdk/test/api/cloud_cache/utils.py +++ b/allensdk/test/api/cloud_cache/utils.py @@ -50,7 +50,6 @@ def load_dataset(data_blobs: dict, Key=f'project-x/project_metadata/{fname}', Body=metadata_blobs[fname]) - response = client.list_object_versions(Bucket=bucket_name) fname_to_version = {} for obj in response['Versions']: @@ -107,7 +106,7 @@ def load_dataset(data_blobs: dict, def create_bucket(test_bucket_name: str, datasets: dict, - metadatasets: Optional[dict]=None) -> None: + metadatasets: Optional[dict] = None) -> None: """ Create a bucket and populate it with example datasets From cf00d1aa05c0a886750807257fd6208d92a1ddd8 Mon Sep 17 00:00:00 2001 From: danielsf Date: Fri, 23 Apr 2021 16:30:25 -0700 Subject: [PATCH 43/86] use real semver strings in tests --- allensdk/test/api/cloud_cache/conftest.py | 66 +++++++-------- allensdk/test/api/cloud_cache/test_cache.py | 29 +++---- .../test/api/cloud_cache/test_change_log.py | 56 ++++++------- .../test/api/cloud_cache/test_full_process.py | 8 +- .../test/api/cloud_cache/test_local_cache.py | 12 +-- .../api/cloud_cache/test_smart_download.py | 80 +++++++++---------- 6 files changed, 126 insertions(+), 125 deletions(-) diff --git a/allensdk/test/api/cloud_cache/conftest.py b/allensdk/test/api/cloud_cache/conftest.py index 3e45b1dc8..d3580f025 100644 --- a/allensdk/test/api/cloud_cache/conftest.py +++ b/allensdk/test/api/cloud_cache/conftest.py @@ -12,7 +12,7 @@ def example_datasets(): 'file_id': '2'} data['f3.txt'] = {'data': b'11121314', 'file_id': '3'} - datasets['1.0'] = data + datasets['1.0.0'] = data data = {} data['f1.txt'] = {'data': b'abcdefg', @@ -22,7 +22,7 @@ def example_datasets(): data['f3.txt'] = {'data': b'11121314', 'file_id': '3'} - datasets['2.0'] = data + datasets['2.0.0'] = data data = {} data['f1.txt'] = {'data': b'1234567', @@ -32,7 +32,7 @@ def example_datasets(): data['f3.txt'] = {'data': b'hijklmnop', 'file_id': '3'} - datasets['3.0'] = data + datasets['3.0.0'] = data return datasets @@ -58,96 +58,96 @@ def example_datasets_with_metadata(baseline_data_with_metadata): example['metadata'] = {} data = copy.deepcopy(baseline_data_with_metadata) - example['data']['1.0'] = data['data'] - example['metadata']['1.0'] = data['metadata'] + example['data']['1.0.0'] = data['data'] + example['metadata']['1.0.0'] = data['metadata'] # delete one data file data = copy.deepcopy(baseline_data_with_metadata) data['data'].pop('f2.txt') - example['data']['2.0'] = data['data'] - example['metadata']['2.0'] = data['metadata'] + example['data']['2.0.0'] = data['data'] + example['metadata']['2.0.0'] = data['metadata'] # rename one data file data = copy.deepcopy(baseline_data_with_metadata) old = data['data'].pop('f2.txt') data['data']['f4.txt'] = {'file_id': '4', 'data': old['data']} - example['data']['3.0'] = data['data'] - example['metadata']['3.0'] = data['metadata'] + example['data']['3.0.0'] = data['data'] + example['metadata']['3.0.0'] = data['metadata'] # change one data file data = copy.deepcopy(baseline_data_with_metadata) data['data']['f3.txt'] = {'file_id': '3', 'data': b'44556677'} - example['data']['4.0'] = data['data'] - example['metadata']['4.0'] = data['metadata'] + example['data']['4.0.0'] = data['data'] + example['metadata']['4.0.0'] = data['metadata'] # add a data file data = copy.deepcopy(baseline_data_with_metadata) data['data']['f4.txt'] = {'file_id': '4', 'data': b'44556677'} - example['data']['5.0'] = data['data'] - example['metadata']['5.0'] = data['metadata'] + example['data']['5.0.0'] = data['data'] + example['metadata']['5.0.0'] = data['metadata'] # delete a data file and change another data = copy.deepcopy(baseline_data_with_metadata) data['data'].pop('f2.txt') data['data']['f1.txt'] = {'file_id': '1', 'data': b'xxxxxx'} - example['data']['6.0'] = data['data'] - example['metadata']['6.0'] = data['metadata'] + example['data']['6.0.0'] = data['data'] + example['metadata']['6.0.0'] = data['metadata'] # delete a data file and rename another data = copy.deepcopy(baseline_data_with_metadata) data['data'].pop('f2.txt') old = data['data'].pop('f3.txt') data['data']['f5.txt'] = {'file_id': '5', 'data': old['data']} - example['data']['7.0'] = data['data'] - example['metadata']['7.0'] = data['metadata'] + example['data']['7.0.0'] = data['data'] + example['metadata']['7.0.0'] = data['metadata'] # delete a data file and add another data = copy.deepcopy(baseline_data_with_metadata) data['data'].pop('f2.txt') data['data']['f5.txt'] = {'file_id': '5', 'data': b'yyyyy'} - example['data']['8.0'] = data['data'] - example['metadata']['8.0'] = data['metadata'] + example['data']['8.0.0'] = data['data'] + example['metadata']['8.0.0'] = data['metadata'] # rename a data file and add another data = copy.deepcopy(baseline_data_with_metadata) old = data['data'].pop('f3.txt') data['data']['f4.txt'] = {'file_id': '4', 'data': old['data']} data['data']['f5.txt'] = {'file_id': '5', 'data': b'wwwwww'} - example['data']['9.0'] = data['data'] - example['metadata']['9.0'] = data['metadata'] + example['data']['9.0.0'] = data['data'] + example['metadata']['9.0.0'] = data['metadata'] # delete a metadata file data = copy.deepcopy(baseline_data_with_metadata) data['metadata'].pop('metadata_2.csv') - example['data']['10.0'] = data['data'] - example['metadata']['10.0'] = data['metadata'] + example['data']['10.0.0'] = data['data'] + example['metadata']['10.0.0'] = data['metadata'] # rename a metadata file data = copy.deepcopy(baseline_data_with_metadata) old = data['metadata'].pop('metadata_2.csv') data['metadata']['metadata_4.csv'] = old - example['data']['11.0'] = data['data'] - example['metadata']['11.0'] = data['metadata'] + example['data']['11.0.0'] = data['data'] + example['metadata']['11.0.0'] = data['metadata'] # change a metadata file data = copy.deepcopy(baseline_data_with_metadata) data['metadata']['metadata_3.csv'] = b'12345' - example['data']['12.0'] = data['data'] - example['metadata']['12.0'] = data['metadata'] + example['data']['12.0.0'] = data['data'] + example['metadata']['12.0.0'] = data['metadata'] # add a metadata file data = copy.deepcopy(baseline_data_with_metadata) data['metadata']['metadata_4.csv'] = b'12345' - example['data']['13.0'] = data['data'] - example['metadata']['13.0'] = data['metadata'] + example['data']['13.0.0'] = data['data'] + example['metadata']['13.0.0'] = data['metadata'] # delete a data file and change a metadata file data = copy.deepcopy(baseline_data_with_metadata) data['data'].pop('f2.txt') old = data['metadata'].pop('metadata_3.csv') data['metadata']['metadata_4.csv'] = old - example['data']['14.0'] = data['data'] - example['metadata']['14.0'] = data['metadata'] + example['data']['14.0.0'] = data['data'] + example['metadata']['14.0.0'] = data['metadata'] # rename a data file, add two data files # rename a metadata file and delete two metadata files @@ -161,7 +161,7 @@ def example_datasets_with_metadata(baseline_data_with_metadata): data['metadata'].pop('metadata_1.csv') data['metadata'].pop('metadata_3.csv') - example['data']['15.0'] = data['data'] - example['metadata']['15.0'] = data['metadata'] + example['data']['15.0.0'] = data['data'] + example['metadata']['15.0.0'] = data['metadata'] return example diff --git a/allensdk/test/api/cloud_cache/test_cache.py b/allensdk/test/api/cloud_cache/test_cache.py index 0efd7befd..0e7c9062d 100644 --- a/allensdk/test/api/cloud_cache/test_cache.py +++ b/allensdk/test/api/cloud_cache/test_cache.py @@ -23,10 +23,10 @@ def test_list_all_manifests(tmpdir): client = boto3.client('s3', region_name='us-east-1') client.put_object(Bucket=test_bucket_name, - Key='proj/manifests/manifest_1.json', + Key='proj/manifests/manifest_v1.0.0.json', Body=b'123456') client.put_object(Bucket=test_bucket_name, - Key='proj/manifests/manifest_2.json', + Key='proj/manifests/manifest_v2.0.0.json', Body=b'123456') client.put_object(Bucket=test_bucket_name, Key='junk.txt', @@ -34,7 +34,8 @@ def test_list_all_manifests(tmpdir): cache = S3CloudCache(tmpdir, test_bucket_name, 'proj') - assert cache.manifest_file_names == ['manifest_1.json', 'manifest_2.json'] + assert cache.manifest_file_names == ['manifest_v1.0.0.json', + 'manifest_v2.0.0.json'] @mock_s3 @@ -104,28 +105,28 @@ def test_loading_manifest(tmpdir): 'file_hash': 'qrstuv'}}} client.put_object(Bucket=test_bucket_name, - Key='proj/manifests/manifest_1.csv', + Key='proj/manifests/manifest_v1.0.0.json', Body=bytes(json.dumps(manifest_1), 'utf-8')) client.put_object(Bucket=test_bucket_name, - Key='proj/manifests/manifest_2.csv', + Key='proj/manifests/manifest_v2.0.0.json', Body=bytes(json.dumps(manifest_2), 'utf-8')) cache = S3CloudCache(pathlib.Path(tmpdir), test_bucket_name, 'proj') - cache.load_manifest('manifest_1.csv') + cache.load_manifest('manifest_v1.0.0.json') assert cache._manifest._data == manifest_1 assert cache.version == '1' assert cache.file_id_column == 'file_id' assert cache.metadata_file_names == ['a.csv', 'b.csv'] - cache.load_manifest('manifest_2.csv') + cache.load_manifest('manifest_v2.0.0.json') assert cache._manifest._data == manifest_2 assert cache.version == '2' assert cache.file_id_column == 'file_id' assert cache.metadata_file_names == ['c.csv', 'd.csv'] with pytest.raises(ValueError) as context: - cache.load_manifest('manifest_3.csv') + cache.load_manifest('manifest_v3.0.0.json') msg = 'is not one of the valid manifest names' assert msg in context.value.args[0] @@ -441,13 +442,13 @@ def test_download_data(tmpdir): manifest['data_pipeline'] = 'placeholder' client.put_object(Bucket=test_bucket_name, - Key='proj/manifests/manifest_1.json', + Key='proj/manifests/manifest_v1.0.0.json', Body=bytes(json.dumps(manifest), 'utf-8')) cache_dir = pathlib.Path(tmpdir) / "data/path/cache" cache = S3CloudCache(cache_dir, test_bucket_name, 'proj') - cache.load_manifest('manifest_1.json') + cache.load_manifest('manifest_v1.0.0.json') expected_path = cache_dir / 'project-z-1' / 'data/data_file.txt' assert not expected_path.exists() @@ -517,13 +518,13 @@ def test_download_metadata(tmpdir): manifest['data_pipeline'] = 'placeholder' client.put_object(Bucket=test_bucket_name, - Key='proj/manifests/manifest_1.json', + Key='proj/manifests/manifest_v1.0.0.json', Body=bytes(json.dumps(manifest), 'utf-8')) cache_dir = pathlib.Path(tmpdir) / "metadata/path/cache" cache = S3CloudCache(cache_dir, test_bucket_name, 'proj') - cache.load_manifest('manifest_1.json') + cache.load_manifest('manifest_v1.0.0.json') expected_path = cache_dir / "project4-1" / 'metadata_file.csv' assert not expected_path.exists() @@ -609,12 +610,12 @@ def test_metadata(tmpdir): manifest['data_pipeline'] = 'placeholder' client.put_object(Bucket=test_bucket_name, - Key='proj/manifests/manifest_1.json', + Key='proj/manifests/manifest_v1.0.0.json', Body=bytes(json.dumps(manifest), 'utf-8')) cache_dir = pathlib.Path(tmpdir) / "metadata/cache" cache = S3CloudCache(cache_dir, test_bucket_name, 'proj') - cache.load_manifest('manifest_1.json') + cache.load_manifest('manifest_v1.0.0.json') metadata_df = cache.get_metadata('metadata_file.csv') assert true_df.equals(metadata_df) diff --git a/allensdk/test/api/cloud_cache/test_change_log.py b/allensdk/test/api/cloud_cache/test_change_log.py index b438eae6c..b3de4d165 100644 --- a/allensdk/test/api/cloud_cache/test_change_log.py +++ b/allensdk/test/api/cloud_cache/test_change_log.py @@ -18,45 +18,45 @@ def test_summarize_comparison(tmpdir, example_datasets_with_metadata): cache_dir = pathlib.Path(tmpdir) / 'cache' cache = S3CloudCache(cache_dir, bucket_name, 'project-x') - log = cache.summarize_comparison('project-x_manifest_v1.0.json', - 'project-x_manifest_v2.0.json') + log = cache.summarize_comparison('project-x_manifest_v1.0.0.json', + 'project-x_manifest_v2.0.0.json') assert len(log['metadata_changes']) == 0 assert len(log['data_changes']) == 1 assert ('data/f2.txt', 'data/f2.txt deleted') in log['data_changes'] - log = cache.summarize_comparison('project-x_manifest_v1.0.json', - 'project-x_manifest_v3.0.json') + log = cache.summarize_comparison('project-x_manifest_v1.0.0.json', + 'project-x_manifest_v3.0.0.json') assert len(log['metadata_changes']) == 0 assert len(log['data_changes']) == 1 assert ('data/f2.txt', 'data/f2.txt renamed data/f4.txt') in log['data_changes'] - log = cache.summarize_comparison('project-x_manifest_v1.0.json', - 'project-x_manifest_v4.0.json') + log = cache.summarize_comparison('project-x_manifest_v1.0.0.json', + 'project-x_manifest_v4.0.0.json') assert len(log['metadata_changes']) == 0 assert len(log['data_changes']) == 1 assert ('data/f3.txt', 'data/f3.txt changed') in log['data_changes'] - log = cache.summarize_comparison('project-x_manifest_v1.0.json', - 'project-x_manifest_v5.0.json') + log = cache.summarize_comparison('project-x_manifest_v1.0.0.json', + 'project-x_manifest_v5.0.0.json') assert len(log['metadata_changes']) == 0 assert len(log['data_changes']) == 1 assert ('data/f4.txt', 'data/f4.txt created') in log['data_changes'] - log = cache.summarize_comparison('project-x_manifest_v1.0.json', - 'project-x_manifest_v6.0.json') + log = cache.summarize_comparison('project-x_manifest_v1.0.0.json', + 'project-x_manifest_v6.0.0.json') assert len(log['metadata_changes']) == 0 assert len(log['data_changes']) == 2 assert ('data/f2.txt', 'data/f2.txt deleted') in log['data_changes'] assert ('data/f1.txt', 'data/f1.txt changed') in log['data_changes'] - log = cache.summarize_comparison('project-x_manifest_v1.0.json', - 'project-x_manifest_v7.0.json') + log = cache.summarize_comparison('project-x_manifest_v1.0.0.json', + 'project-x_manifest_v7.0.0.json') assert len(log['metadata_changes']) == 0 assert len(log['data_changes']) == 2 @@ -64,16 +64,16 @@ def test_summarize_comparison(tmpdir, example_datasets_with_metadata): assert ('data/f3.txt', 'data/f3.txt ' 'renamed data/f5.txt') in log['data_changes'] - log = cache.summarize_comparison('project-x_manifest_v1.0.json', - 'project-x_manifest_v8.0.json') + log = cache.summarize_comparison('project-x_manifest_v1.0.0.json', + 'project-x_manifest_v8.0.0.json') assert len(log['metadata_changes']) == 0 assert len(log['data_changes']) == 2 assert ('data/f2.txt', 'data/f2.txt deleted') in log['data_changes'] assert ('data/f5.txt', 'data/f5.txt created') in log['data_changes'] - log = cache.summarize_comparison('project-x_manifest_v1.0.json', - 'project-x_manifest_v9.0.json') + log = cache.summarize_comparison('project-x_manifest_v1.0.0.json', + 'project-x_manifest_v9.0.0.json') assert len(log['metadata_changes']) == 0 assert len(log['data_changes']) == 2 @@ -81,8 +81,8 @@ def test_summarize_comparison(tmpdir, example_datasets_with_metadata): 'data/f4.txt') in log['data_changes'] assert ('data/f5.txt', 'data/f5.txt created') in log['data_changes'] - log = cache.summarize_comparison('project-x_manifest_v1.0.json', - 'project-x_manifest_v10.0.json') + log = cache.summarize_comparison('project-x_manifest_v1.0.0.json', + 'project-x_manifest_v10.0.0.json') assert len(log['data_changes']) == 0 assert len(log['metadata_changes']) == 1 @@ -90,8 +90,8 @@ def test_summarize_comparison(tmpdir, example_datasets_with_metadata): 'project_metadata/metadata_2.csv ' 'deleted') in log['metadata_changes'] - log = cache.summarize_comparison('project-x_manifest_v1.0.json', - 'project-x_manifest_v11.0.json') + log = cache.summarize_comparison('project-x_manifest_v1.0.0.json', + 'project-x_manifest_v11.0.0.json') assert len(log['data_changes']) == 0 assert len(log['metadata_changes']) == 1 @@ -99,8 +99,8 @@ def test_summarize_comparison(tmpdir, example_datasets_with_metadata): 'project_metadata/metadata_2.csv renamed ' 'project_metadata/metadata_4.csv') in log['metadata_changes'] - log = cache.summarize_comparison('project-x_manifest_v1.0.json', - 'project-x_manifest_v12.0.json') + log = cache.summarize_comparison('project-x_manifest_v1.0.0.json', + 'project-x_manifest_v12.0.0.json') assert len(log['data_changes']) == 0 assert len(log['metadata_changes']) == 1 @@ -108,8 +108,8 @@ def test_summarize_comparison(tmpdir, example_datasets_with_metadata): 'project_metadata/metadata_3.csv ' 'changed') in log['metadata_changes'] - log = cache.summarize_comparison('project-x_manifest_v1.0.json', - 'project-x_manifest_v13.0.json') + log = cache.summarize_comparison('project-x_manifest_v1.0.0.json', + 'project-x_manifest_v13.0.0.json') assert len(log['data_changes']) == 0 assert len(log['metadata_changes']) == 1 @@ -117,8 +117,8 @@ def test_summarize_comparison(tmpdir, example_datasets_with_metadata): 'project_metadata/metadata_4.csv ' 'created') in log['metadata_changes'] - log = cache.summarize_comparison('project-x_manifest_v1.0.json', - 'project-x_manifest_v14.0.json') + log = cache.summarize_comparison('project-x_manifest_v1.0.0.json', + 'project-x_manifest_v14.0.0.json') assert len(log['data_changes']) == 1 assert len(log['metadata_changes']) == 1 assert ('data/f2.txt', 'data/f2.txt deleted') in log['data_changes'] @@ -126,8 +126,8 @@ def test_summarize_comparison(tmpdir, example_datasets_with_metadata): 'project_metadata/metadata_3.csv renamed ' 'project_metadata/metadata_4.csv') in log['metadata_changes'] - log = cache.summarize_comparison('project-x_manifest_v1.0.json', - 'project-x_manifest_v15.0.json') + log = cache.summarize_comparison('project-x_manifest_v1.0.0.json', + 'project-x_manifest_v15.0.0.json') assert len(log['data_changes']) == 3 assert len(log['metadata_changes']) == 3 diff --git a/allensdk/test/api/cloud_cache/test_full_process.py b/allensdk/test/api/cloud_cache/test_full_process.py index 8caff5dd1..8f8be62f2 100644 --- a/allensdk/test/api/cloud_cache/test_full_process.py +++ b/allensdk/test/api/cloud_cache/test_full_process.py @@ -184,11 +184,11 @@ def test_full_cache_system(tmpdir): manifest_2['metadata_files'] = metadata_files_2 s3_client.put_object(Bucket=test_bucket_name, - Key='proj/manifests/manifest_1.json', + Key='proj/manifests/manifest_v1.0.0.json', Body=bytes(json.dumps(manifest_1), 'utf-8')) s3_client.put_object(Bucket=test_bucket_name, - Key='proj/manifests/manifest_2.json', + Key='proj/manifests/manifest_v2.0.0.json', Body=bytes(json.dumps(manifest_2), 'utf-8')) # Use S3CloudCache to interact with dataset @@ -197,7 +197,7 @@ def test_full_cache_system(tmpdir): # load the first version of the dataset - cache.load_manifest('manifest_1.json') + cache.load_manifest('manifest_v1.0.0.json') assert cache.version == 'A' # check that metadata dataframes have expected contents @@ -224,7 +224,7 @@ def test_full_cache_system(tmpdir): # now load the second version of the dataset - cache.load_manifest('manifest_2.json') + cache.load_manifest('manifest_v2.0.0.json') assert cache.version == 'B' # metadata2.csv should not exist in this version of the dataset diff --git a/allensdk/test/api/cloud_cache/test_local_cache.py b/allensdk/test/api/cloud_cache/test_local_cache.py index fa6daa9e5..211c5c464 100644 --- a/allensdk/test/api/cloud_cache/test_local_cache.py +++ b/allensdk/test/api/cloud_cache/test_local_cache.py @@ -18,11 +18,11 @@ def test_local_cache_file_access(tmpdir, example_datasets): cache_dir = pathlib.Path(tmpdir) / 'cache' cloud_cache = S3CloudCache(cache_dir, bucket_name, 'project-x') - cloud_cache.load_manifest('project-x_manifest_v1.0.json') + cloud_cache.load_manifest('project-x_manifest_v1.0.0.json') cloud_cache.download_data('1') cloud_cache.download_data('3') - cloud_cache.load_manifest('project-x_manifest_v3.0.json') + cloud_cache.load_manifest('project-x_manifest_v3.0.0.json') cloud_cache.download_data('2') del cloud_cache @@ -30,10 +30,10 @@ def test_local_cache_file_access(tmpdir, example_datasets): local_cache = LocalCache(cache_dir, 'project-x') manifest_set = set(local_cache.manifest_file_names) - assert manifest_set == {'project-x_manifest_v1.0.json', - 'project-x_manifest_v3.0.json'} + assert manifest_set == {'project-x_manifest_v1.0.0.json', + 'project-x_manifest_v3.0.0.json'} - local_cache.load_manifest('project-x_manifest_v1.0.json') + local_cache.load_manifest('project-x_manifest_v1.0.0.json') attr = local_cache.data_path('1') assert attr['exists'] attr = local_cache.data_path('2') @@ -41,7 +41,7 @@ def test_local_cache_file_access(tmpdir, example_datasets): attr = local_cache.data_path('3') assert attr['exists'] - local_cache.load_manifest('project-x_manifest_v3.0.json') + local_cache.load_manifest('project-x_manifest_v3.0.0.json') attr = local_cache.data_path('1') assert attr['exists'] # because file 1 is the same in v1.0 and v3.0 attr = local_cache.data_path('2') diff --git a/allensdk/test/api/cloud_cache/test_smart_download.py b/allensdk/test/api/cloud_cache/test_smart_download.py index 56b46e4af..22764dc69 100644 --- a/allensdk/test/api/cloud_cache/test_smart_download.py +++ b/allensdk/test/api/cloud_cache/test_smart_download.py @@ -22,68 +22,68 @@ def test_smart_file_downloading(tmpdir, example_datasets): # download all data files from all versions, keeping track # of the paths to the downloaded data files downloaded = {} - for version in ('1.0', '2.0', '3.0'): + for version in ('1.0.0', '2.0.0', '3.0.0'): downloaded[version] = {} cache.load_manifest(f'project-x_manifest_v{version}.json') for file_id in ('1', '2', '3'): downloaded[version][file_id] = cache.download_data(file_id) - # check that the version 1.0 of all files are actual files + # check that the version 1.0.0 of all files are actual files for file_id in ('1', '2', '3'): - assert downloaded['1.0'][file_id].is_file() - assert not downloaded['1.0'][file_id].is_symlink() + assert downloaded['1.0.0'][file_id].is_file() + assert not downloaded['1.0.0'][file_id].is_symlink() - # check that v2.0 f1.txt is a new file - assert downloaded['2.0']['1'].is_file() - assert not downloaded['2.0']['1'].is_symlink() + # check that v2.0.0 f1.txt is a new file + assert downloaded['2.0.0']['1'].is_file() + assert not downloaded['2.0.0']['1'].is_symlink() - # check that v2.0 f2.txt and f3.txt are symlinks to - # the correct v1.0 files + # check that v2.0.0 f2.txt and f3.txt are symlinks to + # the correct v1.0.0 files for file_id in ('2', '3'): - assert downloaded['2.0'][file_id].is_file() - assert downloaded['2.0'][file_id].is_symlink() + assert downloaded['2.0.0'][file_id].is_file() + assert downloaded['2.0.0'][file_id].is_symlink() # check that symlink points to the correct file - test = downloaded['2.0'][file_id].resolve() - control = downloaded['1.0'][file_id].resolve() + test = downloaded['2.0.0'][file_id].resolve() + control = downloaded['1.0.0'][file_id].resolve() if test != control: - test = downloaded['2.0'][file_id].resolve() - control = downloaded['1.0'][file_id].resolve() + test = downloaded['2.0.0'][file_id].resolve() + control = downloaded['1.0.0'][file_id].resolve() raise RuntimeError(f'{test} != {control}\n' 'even though the first is a symlink') # check that the absolute paths of the files are different, # even though one is a symlink - test = downloaded['2.0'][file_id].absolute() - control = downloaded['1.0'][file_id].absolute() + test = downloaded['2.0.0'][file_id].absolute() + control = downloaded['1.0.0'][file_id].absolute() if test == control: - test = downloaded['2.0'][file_id].absolute() - control = downloaded['1.0'][file_id].absolute() + test = downloaded['2.0.0'][file_id].absolute() + control = downloaded['1.0.0'][file_id].absolute() raise RuntimeError(f'{test} == {control}\n' 'even though they should be ' 'different absolute paths') - # repeat the above tests for v3.0, f1.txt - assert downloaded['3.0']['1'].is_file() - assert downloaded['3.0']['1'].is_symlink() - if downloaded['3.0']['1'].resolve() != downloaded['1.0']['1'].resolve(): - test = downloaded['3.0']['1'].resolve() - control = downloaded['1.0']['1'].resolve() + # repeat the above tests for v3.0.0, f1.txt + assert downloaded['3.0.0']['1'].is_file() + assert downloaded['3.0.0']['1'].is_symlink() + if downloaded['3.0.0']['1'].resolve() != downloaded['1.0.0']['1'].resolve(): + test = downloaded['3.0.0']['1'].resolve() + control = downloaded['1.0.0']['1'].resolve() raise RuntimeError(f'{test} != {control}\n' 'even though the first is a symlink') - if downloaded['3.0']['1'].absolute() == downloaded['1.0']['1'].absolute(): - test = downloaded['3.0']['1'].absolute() - control = downloaded['1.0']['1'].absolute() + if downloaded['3.0.0']['1'].absolute() == downloaded['1.0.0']['1'].absolute(): + test = downloaded['3.0.0']['1'].absolute() + control = downloaded['1.0.0']['1'].absolute() raise RuntimeError(f'{test} == {control}\n' 'even though they should be ' 'different absolute paths') # check that v3 v2.txt and f3.txt are not symlinks - assert downloaded['3.0']['2'].is_file() - assert not downloaded['3.0']['2'].is_symlink() - assert downloaded['3.0']['3'].is_file() - assert not downloaded['3.0']['3'].is_symlink() + assert downloaded['3.0.0']['2'].is_file() + assert not downloaded['3.0.0']['2'].is_symlink() + assert downloaded['3.0.0']['3'].is_file() + assert not downloaded['3.0.0']['3'].is_symlink() @mock_s3 @@ -99,7 +99,7 @@ def test_on_corrupted_files(tmpdir, example_datasets): cache_dir = pathlib.Path(tmpdir) / 'cache' cache = S3CloudCache(cache_dir, bucket_name, 'project-x') - version_list = ('1.0', '2.0', '3.0') + version_list = ('1.0.0', '2.0.0', '3.0.0') file_id_list = ('1', '2', '3') for version in version_list: @@ -122,14 +122,14 @@ def test_on_corrupted_files(tmpdir, example_datasets): hasher.update(b'4567890') true_hash = hasher.hexdigest() - cache.load_manifest('project-x_manifest_v1.0.json') + cache.load_manifest('project-x_manifest_v1.0.0.json') attr = cache.data_path('2') with open(attr['local_path'], 'wb') as out_file: out_file.write(b'xxxxxx') attr = cache.data_path('2') assert not attr['exists'] - cache.load_manifest('project-x_manifest_v2.0.json') + cache.load_manifest('project-x_manifest_v2.0.0.json') attr = cache.data_path('2') assert not attr['exists'] @@ -139,7 +139,7 @@ def test_on_corrupted_files(tmpdir, example_datasets): attr = cache.data_path('2') assert attr['exists'] redownloaded_path = attr['local_path'] - cache.load_manifest('project-x_manifest_v1.0.json') + cache.load_manifest('project-x_manifest_v1.0.0.json') attr = cache.data_path('2') assert attr['exists'] other_path = attr['local_path'] @@ -170,7 +170,7 @@ def test_corrupted_download_manifest(tmpdir, example_datasets): cache_dir = pathlib.Path(tmpdir) / 'cache' cache = S3CloudCache(cache_dir, bucket_name, 'project-x') - version_list = ('1.0', '2.0', '3.0') + version_list = ('1.0.0', '2.0.0', '3.0.0') file_id_list = ('1', '2', '3') for version in version_list: @@ -192,7 +192,7 @@ def test_corrupted_download_manifest(tmpdir, example_datasets): hasher.update(b'4567890') true_hash = hasher.hexdigest() - cache.load_manifest('project-x_manifest_v1.0.json') + cache.load_manifest('project-x_manifest_v1.0.0.json') attr = cache.data_path('2') # assert below will pass; because file exists and is not yet corrupted, @@ -202,7 +202,7 @@ def test_corrupted_download_manifest(tmpdir, example_datasets): with open(attr['local_path'], 'wb') as out_file: out_file.write(b'xxxxx') - cache.load_manifest('project-x_manifest_v2.0.json') + cache.load_manifest('project-x_manifest_v2.0.0.json') attr = cache.data_path('2') assert not attr['exists'] cache.download_data('2') @@ -216,7 +216,7 @@ def test_corrupted_download_manifest(tmpdir, example_datasets): test_hash = hasher.hexdigest() assert test_hash == true_hash - cache.load_manifest('project-x_manifest_v1.0.json') + cache.load_manifest('project-x_manifest_v1.0.0.json') attr = cache.data_path('2') assert attr['exists'] assert attr['local_path'].resolve() == downloaded_path.resolve() From 2cf548926e8b1f4ad2b18b03ce685c6760cedb55 Mon Sep 17 00:00:00 2001 From: danielsf Date: Fri, 23 Apr 2021 16:33:05 -0700 Subject: [PATCH 44/86] add method to CloudCacheBase to get latest downloaded manifest --- allensdk/api/cloud_cache/cloud_cache.py | 36 +++++++++++++++++---- allensdk/test/api/cloud_cache/test_cache.py | 25 ++++++++++++++ 2 files changed, 55 insertions(+), 6 deletions(-) diff --git a/allensdk/api/cloud_cache/cloud_cache.py b/allensdk/api/cloud_cache/cloud_cache.py index c1c9eaf2e..3064829cf 100644 --- a/allensdk/api/cloud_cache/cloud_cache.py +++ b/allensdk/api/cloud_cache/cloud_cache.py @@ -68,9 +68,23 @@ def _list_all_manifests(self) -> list: """ raise NotImplementedError() + def _find_latest_file(self, file_name_list: List[str]): + """ + Take a list of files named like + + {blob}_v{version}.json + + and return the one with the latest version + """ + vstrs = [s.split(".json")[0].split("_v")[-1] + for s in file_name_list] + versions = [semver.VersionInfo.parse(v) for v in vstrs] + imax = versions.index(max(versions)) + return file_name_list[imax] + @property def latest_manifest_file(self) -> str: - """parses available manifest files for semver string + """parses on-line available manifest files for semver string and returns the latest one self.manifest_file_names are assumed to be of the form '_v.json' @@ -80,11 +94,21 @@ def latest_manifest_file(self) -> str: str the filename whose semver string is the latest one """ - vstrs = [s.split(".json")[0].split("_v")[-1] - for s in self.manifest_file_names] - versions = [semver.VersionInfo.parse(v) for v in vstrs] - imax = versions.index(max(versions)) - return self.manifest_file_names[imax] + return self._find_latest_file(self.manifest_file_names) + + @property + def latest_downloaded_manifest_file(self) -> str: + """parses downloaded available manifest files for semver string + and returns the latest one + self.manifest_file_names are assumed to be of the form + '_v.json' + + Returns + ------- + str + the filename whose semver string is the latest one + """ + return self._find_latest_file(self._list_all_downloaded_manifests()) def load_latest_manifest(self): self.load_manifest(self.latest_manifest_file) diff --git a/allensdk/test/api/cloud_cache/test_cache.py b/allensdk/test/api/cloud_cache/test_cache.py index 0e7c9062d..c784a33aa 100644 --- a/allensdk/test/api/cloud_cache/test_cache.py +++ b/allensdk/test/api/cloud_cache/test_cache.py @@ -6,6 +6,7 @@ import io import boto3 from moto import mock_s3 +from .utils import create_bucket from allensdk.api.cloud_cache.cloud_cache import S3CloudCache # noqa: E501 from allensdk.api.cloud_cache.file_attributes import CacheFileAttributes # noqa: E501 @@ -619,3 +620,27 @@ def test_metadata(tmpdir): metadata_df = cache.get_metadata('metadata_file.csv') assert true_df.equals(metadata_df) + + +@mock_s3 +def test_latest_manifest(tmpdir, example_datasets_with_metadata): + """ + Test that the methods which return the latest and latest downloaded + manifest file names work correctly + """ + bucket_name = 'latest_manifest_bucket' + create_bucket(bucket_name, + example_datasets_with_metadata['data'], + metadatasets=example_datasets_with_metadata['metadata']) + + cache_dir = pathlib.Path(tmpdir) / 'cache' + cache = S3CloudCache(cache_dir, bucket_name, 'project-x') + + cache.load_manifest('project-x_manifest_v7.0.0.json') + cache.load_manifest('project-x_manifest_v3.0.0.json') + cache.load_manifest('project-x_manifest_v2.0.0.json') + + assert cache.latest_manifest_file == 'project-x_manifest_v15.0.0.json' + + expected = 'project-x_manifest_v7.0.0.json' + assert cache.latest_downloaded_manifest_file == expected From 6dec97c1ac2af12babc04cc6c273fc2f033d8b98 Mon Sep 17 00:00:00 2001 From: danielsf Date: Fri, 23 Apr 2021 17:02:33 -0700 Subject: [PATCH 45/86] latest_downloaded_manifest_file safe for zero downloads --- allensdk/api/cloud_cache/cloud_cache.py | 9 ++++++--- allensdk/test/api/cloud_cache/test_cache.py | 2 ++ 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/allensdk/api/cloud_cache/cloud_cache.py b/allensdk/api/cloud_cache/cloud_cache.py index 3064829cf..5525bbbcf 100644 --- a/allensdk/api/cloud_cache/cloud_cache.py +++ b/allensdk/api/cloud_cache/cloud_cache.py @@ -52,7 +52,7 @@ def __init__(self, cache_dir, project_name): self._project_name = project_name self._manifest_file_names = self._list_all_manifests() - def _list_all_downloaded_manifests(self) -> list: + def list_all_downloaded_manifests(self) -> list: """ Return a list of all of the manifest files that have been downloaded for this dataset @@ -108,7 +108,10 @@ def latest_downloaded_manifest_file(self) -> str: str the filename whose semver string is the latest one """ - return self._find_latest_file(self._list_all_downloaded_manifests()) + file_list = self.list_all_downloaded_manifests() + if len(file_list) == 0: + return '' + return self._find_latest_file(self.list_all_downloaded_manifests()) def load_latest_manifest(self): self.load_manifest(self.latest_manifest_file) @@ -836,7 +839,7 @@ def __init__(self, cache_dir, project_name): super().__init__(cache_dir=cache_dir, project_name=project_name) def _list_all_manifests(self) -> list: - return self._list_all_downloaded_manifests() + return self.list_all_downloaded_manifests() def _download_manifest(self, manifest_name: str): raise NotImplementedError() diff --git a/allensdk/test/api/cloud_cache/test_cache.py b/allensdk/test/api/cloud_cache/test_cache.py index c784a33aa..6bdfd91ba 100644 --- a/allensdk/test/api/cloud_cache/test_cache.py +++ b/allensdk/test/api/cloud_cache/test_cache.py @@ -636,6 +636,8 @@ def test_latest_manifest(tmpdir, example_datasets_with_metadata): cache_dir = pathlib.Path(tmpdir) / 'cache' cache = S3CloudCache(cache_dir, bucket_name, 'project-x') + assert cache.latest_downloaded_manifest_file == '' + cache.load_manifest('project-x_manifest_v7.0.0.json') cache.load_manifest('project-x_manifest_v3.0.0.json') cache.load_manifest('project-x_manifest_v2.0.0.json') From 96ab1ec837e26c4ec34b5586080353ef2ce72eb3 Mon Sep 17 00:00:00 2001 From: danielsf Date: Fri, 23 Apr 2021 17:33:34 -0700 Subject: [PATCH 46/86] emit warning when users try to load manifest that is not the latest available --- allensdk/api/cloud_cache/cloud_cache.py | 38 +++++++++++++++++++++ allensdk/test/api/cloud_cache/test_cache.py | 38 ++++++++++++++++++++- 2 files changed, 75 insertions(+), 1 deletion(-) diff --git a/allensdk/api/cloud_cache/cloud_cache.py b/allensdk/api/cloud_cache/cloud_cache.py index 5525bbbcf..031779267 100644 --- a/allensdk/api/cloud_cache/cloud_cache.py +++ b/allensdk/api/cloud_cache/cloud_cache.py @@ -9,6 +9,7 @@ import tqdm import re import json +import warnings from botocore import UNSIGNED from botocore.client import Config from allensdk.internal.core.lims_utilities import safe_system_path @@ -19,6 +20,10 @@ from allensdk.api.cloud_cache.utils import relative_path_from_url # noqa: E501 +class OutdatedManifestWarning(UserWarning): + pass + + class CloudCacheBase(ABC): """ A class to handle the downloading and accessing of data served from a cloud @@ -52,6 +57,36 @@ def __init__(self, cache_dir, project_name): self._project_name = project_name self._manifest_file_names = self._list_all_manifests() + # what latest_manifest was the last time an OutdatedManifestWarning + # was emitted + self._manifest_last_warned_on = None + + def _warn_of_outdated_manifest(self, manifest_name: str) -> None: + """ + Warn that manifest_name is not the latest manifest available + """ + if self._manifest_last_warned_on is not None: + if self.latest_manifest_file == self._manifest_last_warned_on: + return None + + self._manifest_last_warned_on = self.latest_manifest_file + + msg = '\n' + msg += 'The manifest file you are loading is not the ' + msg += 'most up to date manifest file available for ' + msg += 'this dataset. The most up to data manifest file ' + msg += 'available for this dataset is \n' + msg += f'{self.latest_manifest_file}\n' + msg += 'To see the differences between these manifests' + msg += 'run\n' + msg += f"self.compare_manifests('{manifest_name}', " + msg += f"'{self.latest_manifest_file}')\n" + msg += "To see all of the manifest files currently downloaded " + msg += "onto your local system, run\n" + msg += "self.list_all_downloaded_manifests()\n" + warnings.warn(msg, OutdatedManifestWarning) + return None + def list_all_downloaded_manifests(self) -> list: """ Return a list of all of the manifest files that have been @@ -251,6 +286,9 @@ def load_manifest(self, manifest_name: str): The name of the manifest to load. Must be an element in self.manifest_file_names """ + if manifest_name != self.latest_manifest_file: + self._warn_of_outdated_manifest(manifest_name) + self._manifest = self._load_manifest(manifest_name) def _update_list_of_downloads(self, diff --git a/allensdk/test/api/cloud_cache/test_cache.py b/allensdk/test/api/cloud_cache/test_cache.py index 6bdfd91ba..09ee52198 100644 --- a/allensdk/test/api/cloud_cache/test_cache.py +++ b/allensdk/test/api/cloud_cache/test_cache.py @@ -6,7 +6,8 @@ import io import boto3 from moto import mock_s3 -from .utils import create_bucket +from .utils import create_bucket, load_dataset +from allensdk.api.cloud_cache.cloud_cache import OutdatedManifestWarning from allensdk.api.cloud_cache.cloud_cache import S3CloudCache # noqa: E501 from allensdk.api.cloud_cache.file_attributes import CacheFileAttributes # noqa: E501 @@ -646,3 +647,38 @@ def test_latest_manifest(tmpdir, example_datasets_with_metadata): expected = 'project-x_manifest_v7.0.0.json' assert cache.latest_downloaded_manifest_file == expected + + +@mock_s3 +def test_outdated_manifest_warning(tmpdir, example_datasets_with_metadata): + """ + Test that a warning is raised the first time you try to load an outdated + manifest + """ + + bucket_name = 'outdated_manifest_bucket' + client = create_bucket(bucket_name, + example_datasets_with_metadata['data'], + metadatasets=example_datasets_with_metadata['metadata']) + + cache_dir = pathlib.Path(tmpdir) / 'cache' + cache = S3CloudCache(cache_dir, bucket_name, 'project-x') + + m_warn_type = 'OutdatedManifestWarning' + + with pytest.warns(OutdatedManifestWarning) as warnings: + cache.load_manifest('project-x_manifest_v7.0.0.json') + ct = 0 + for w in warnings.list: + if w._category_name == m_warn_type: + ct += 1 + assert ct > 0 + + # assert no warning is raised the second time by catching + # any warnings that are emitted and making sure they are + # not OutdatedManifestWarnings + with pytest.warns(None) as warnings: + cache.load_manifest('project-x_manifest_v11.0.0.json') + if len(warnings) > 0: + for w in warnings.list: + assert w._category_name != 'OutdatedManifestWarning' From a9947530205218ce2d30ca2ae2c6df2fa6a9079a Mon Sep 17 00:00:00 2001 From: danielsf Date: Fri, 23 Apr 2021 17:45:57 -0700 Subject: [PATCH 47/86] clean up warning message --- allensdk/api/cloud_cache/cloud_cache.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/allensdk/api/cloud_cache/cloud_cache.py b/allensdk/api/cloud_cache/cloud_cache.py index 031779267..0ba238cae 100644 --- a/allensdk/api/cloud_cache/cloud_cache.py +++ b/allensdk/api/cloud_cache/cloud_cache.py @@ -71,19 +71,19 @@ def _warn_of_outdated_manifest(self, manifest_name: str) -> None: self._manifest_last_warned_on = self.latest_manifest_file - msg = '\n' + msg = '\n\n' msg += 'The manifest file you are loading is not the ' msg += 'most up to date manifest file available for ' msg += 'this dataset. The most up to data manifest file ' - msg += 'available for this dataset is \n' - msg += f'{self.latest_manifest_file}\n' - msg += 'To see the differences between these manifests' - msg += 'run\n' + msg += 'available for this dataset is \n\n' + msg += f'{self.latest_manifest_file}\n\n' + msg += 'To see the differences between these manifests,' + msg += 'run\n\n' msg += f"self.compare_manifests('{manifest_name}', " - msg += f"'{self.latest_manifest_file}')\n" + msg += f"'{self.latest_manifest_file}')\n\n" msg += "To see all of the manifest files currently downloaded " - msg += "onto your local system, run\n" - msg += "self.list_all_downloaded_manifests()\n" + msg += "onto your local system, run\n\n" + msg += "self.list_all_downloaded_manifests()\n\n" warnings.warn(msg, OutdatedManifestWarning) return None From d967de70e1b1ca11d103687ac05ae995aea50f6e Mon Sep 17 00:00:00 2001 From: danielsf Date: Fri, 23 Apr 2021 17:58:17 -0700 Subject: [PATCH 48/86] add method to return string summarizing changes between manifests --- allensdk/api/cloud_cache/cloud_cache.py | 52 +++++++++++++++++++ .../test/api/cloud_cache/test_change_log.py | 34 ++++++++++++ 2 files changed, 86 insertions(+) diff --git a/allensdk/api/cloud_cache/cloud_cache.py b/allensdk/api/cloud_cache/cloud_cache.py index 0ba238cae..2f4fb3992 100644 --- a/allensdk/api/cloud_cache/cloud_cache.py +++ b/allensdk/api/cloud_cache/cloud_cache.py @@ -691,6 +691,58 @@ def summarize_comparison(self, result[result_key] = changes return result + def compare_manifests(self, + manifest_0_name: str, + manifest_1_name: str + ) -> str: + """ + Compare two manifests from this dataset. Return a dict + containing the list of metadata and data files that changed + between them + + Note: this assumes that manifest_0 predates manifest_1 + + Parameters + ---------- + manifest_0_name: str + + manifest_1_name: str + + Returns + ------- + str + A string summarizing all of the changes going from + manifest_0 to manifest_1 + """ + + changes = self.summarize_comparison(manifest_0_name, + manifest_1_name) + if len(changes['data_changes']) == 0: + if len(changes['metadata_changes']) == 0: + return "The two manifests are equivalent" + + data_change_dict = {} + for delta in changes['data_changes']: + data_change_dict[delta[0]] = delta[1] + metadata_change_dict = {} + for delta in changes['metadata_changes']: + metadata_change_dict[delta[0]] = delta[1] + + msg = 'Changes going from\n' + msg += f'{manifest_0_name}\n' + msg += 'to\n' + msg += f'{manifest_1_name}\n\n' + + m_keys = list(metadata_change_dict.keys()) + m_keys.sort() + for m in m_keys: + msg += f'{metadata_change_dict[m]}\n' + d_keys = list(data_change_dict.keys()) + d_keys.sort() + for d in d_keys: + msg += f'{data_change_dict[d]}\n' + return msg + class S3CloudCache(CloudCacheBase): """ diff --git a/allensdk/test/api/cloud_cache/test_change_log.py b/allensdk/test/api/cloud_cache/test_change_log.py index b3de4d165..7b96e4cef 100644 --- a/allensdk/test/api/cloud_cache/test_change_log.py +++ b/allensdk/test/api/cloud_cache/test_change_log.py @@ -146,3 +146,37 @@ def test_summarize_comparison(tmpdir, example_datasets_with_metadata): 'project_metadata/metadata_3.csv deleted') assert set(log['metadata_changes']) == {ans1, ans2, ans3} + + +@mock_s3 +@mock_s3 +def test_compare_manifesst_string(tmpdir, example_datasets_with_metadata): + """ + Test that CloudCacheBase.compare_manifests reports the correct + changes when comparing two manifests + """ + bucket_name = 'compare_manifest_bucket' + create_bucket(bucket_name, + example_datasets_with_metadata['data'], + metadatasets=example_datasets_with_metadata['metadata']) + + cache_dir = pathlib.Path(tmpdir) / 'cache' + cache = S3CloudCache(cache_dir, bucket_name, 'project-x') + + msg = cache.compare_manifests('project-x_manifest_v1.0.0.json', + 'project-x_manifest_v15.0.0.json') + + + expected = 'Changes going from\n' + expected += 'project-x_manifest_v1.0.0.json\n' + expected += 'to\n' + expected += 'project-x_manifest_v15.0.0.json\n\n' + expected += 'project_metadata/metadata_1.csv deleted\n' + expected += 'project_metadata/metadata_2.csv renamed ' + expected += 'project_metadata/metadata_4.csv\n' + expected += 'project_metadata/metadata_3.csv deleted\n' + expected += 'data/f1.txt renamed data/f4.txt\n' + expected += 'data/f5.txt created\n' + expected += 'data/f6.txt created\n' + + assert msg == expected From 5e0b251d321cdb934cd4ab09589dfb9407f07a29 Mon Sep 17 00:00:00 2001 From: danielsf Date: Mon, 26 Apr 2021 14:54:34 -0700 Subject: [PATCH 49/86] pep8 changes --- allensdk/test/api/cloud_cache/test_cache.py | 9 +++++---- allensdk/test/api/cloud_cache/test_change_log.py | 1 - allensdk/test/api/cloud_cache/test_smart_download.py | 9 +++++++-- 3 files changed, 12 insertions(+), 7 deletions(-) diff --git a/allensdk/test/api/cloud_cache/test_cache.py b/allensdk/test/api/cloud_cache/test_cache.py index 09ee52198..b83da7176 100644 --- a/allensdk/test/api/cloud_cache/test_cache.py +++ b/allensdk/test/api/cloud_cache/test_cache.py @@ -6,7 +6,7 @@ import io import boto3 from moto import mock_s3 -from .utils import create_bucket, load_dataset +from .utils import create_bucket from allensdk.api.cloud_cache.cloud_cache import OutdatedManifestWarning from allensdk.api.cloud_cache.cloud_cache import S3CloudCache # noqa: E501 from allensdk.api.cloud_cache.file_attributes import CacheFileAttributes # noqa: E501 @@ -657,9 +657,10 @@ def test_outdated_manifest_warning(tmpdir, example_datasets_with_metadata): """ bucket_name = 'outdated_manifest_bucket' - client = create_bucket(bucket_name, - example_datasets_with_metadata['data'], - metadatasets=example_datasets_with_metadata['metadata']) + metadatasets = example_datasets_with_metadata['metadata'] + _ = create_bucket(bucket_name, + example_datasets_with_metadata['data'], + metadatasets=metadatasets) cache_dir = pathlib.Path(tmpdir) / 'cache' cache = S3CloudCache(cache_dir, bucket_name, 'project-x') diff --git a/allensdk/test/api/cloud_cache/test_change_log.py b/allensdk/test/api/cloud_cache/test_change_log.py index 7b96e4cef..91adf1ce4 100644 --- a/allensdk/test/api/cloud_cache/test_change_log.py +++ b/allensdk/test/api/cloud_cache/test_change_log.py @@ -166,7 +166,6 @@ def test_compare_manifesst_string(tmpdir, example_datasets_with_metadata): msg = cache.compare_manifests('project-x_manifest_v1.0.0.json', 'project-x_manifest_v15.0.0.json') - expected = 'Changes going from\n' expected += 'project-x_manifest_v1.0.0.json\n' expected += 'to\n' diff --git a/allensdk/test/api/cloud_cache/test_smart_download.py b/allensdk/test/api/cloud_cache/test_smart_download.py index 22764dc69..bc6f4f8f2 100644 --- a/allensdk/test/api/cloud_cache/test_smart_download.py +++ b/allensdk/test/api/cloud_cache/test_smart_download.py @@ -66,13 +66,18 @@ def test_smart_file_downloading(tmpdir, example_datasets): # repeat the above tests for v3.0.0, f1.txt assert downloaded['3.0.0']['1'].is_file() assert downloaded['3.0.0']['1'].is_symlink() - if downloaded['3.0.0']['1'].resolve() != downloaded['1.0.0']['1'].resolve(): + + res3 = downloaded['3.0.0']['1'].resolve() + res1 = downloaded['1.0.0']['1'].resolve() + if res3 != res1: test = downloaded['3.0.0']['1'].resolve() control = downloaded['1.0.0']['1'].resolve() raise RuntimeError(f'{test} != {control}\n' 'even though the first is a symlink') - if downloaded['3.0.0']['1'].absolute() == downloaded['1.0.0']['1'].absolute(): + abs3 = downloaded['3.0.0']['1'].absolute() + abs1 = downloaded['1.0.0']['1'].absolute() + if abs3 == abs1: test = downloaded['3.0.0']['1'].absolute() control = downloaded['1.0.0']['1'].absolute() raise RuntimeError(f'{test} == {control}\n' From 24111ae1ceab7676ed8c1a3ff279296d7fbfa123 Mon Sep 17 00:00:00 2001 From: danielsf Date: Mon, 26 Apr 2021 14:58:55 -0700 Subject: [PATCH 50/86] add docstrings --- allensdk/test/api/cloud_cache/conftest.py | 18 ++++++++++++++++++ allensdk/test/api/cloud_cache/utils.py | 2 +- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/allensdk/test/api/cloud_cache/conftest.py b/allensdk/test/api/cloud_cache/conftest.py index d3580f025..dbdd900e7 100644 --- a/allensdk/test/api/cloud_cache/conftest.py +++ b/allensdk/test/api/cloud_cache/conftest.py @@ -4,6 +4,15 @@ @pytest.fixture def example_datasets(): + """ + A dict representing an example dataset that can + be used for testing the CloudCache api. + + The key of the dict is the name of each file. + The values of the dict are dicts in which + 'file_id' -> maps to the file_id used to describe the file + 'data' -> a bytestring representing the contents of the file + """ datasets = {} data = {} data['f1.txt'] = {'data': b'1234567', @@ -38,6 +47,10 @@ def example_datasets(): @pytest.fixture def baseline_data_with_metadata(): + """ + Example dataset with example metadata for use in testing + CloudCache API + """ data = {} data['f1.txt'] = {'file_id': '1', 'data': b'1234'} data['f2.txt'] = {'file_id': '2', 'data': b'2345'} @@ -52,6 +65,11 @@ def baseline_data_with_metadata(): @pytest.fixture def example_datasets_with_metadata(baseline_data_with_metadata): + """ + Multiple versions of an example dataset that goes through + all possible mutations (adding/deleting files; renaming files; + changing existing files) for use in testing the CloudCache API + """ example = {} example['data'] = {} diff --git a/allensdk/test/api/cloud_cache/utils.py b/allensdk/test/api/cloud_cache/utils.py index ec758c2a4..f58524bba 100644 --- a/allensdk/test/api/cloud_cache/utils.py +++ b/allensdk/test/api/cloud_cache/utils.py @@ -24,7 +24,7 @@ def load_dataset(data_blobs: dict, manifest_version: str The version of the manifest (manifest will be - uploaded to moto3 as manifest_{manifest_version}.json + uploaded to moto3 as manifest_{manifest_version}.json) bucket_name: str From 983593fa989240c37acce3a37a10789bdc1001d0 Mon Sep 17 00:00:00 2001 From: danielsf Date: Mon, 26 Apr 2021 14:59:44 -0700 Subject: [PATCH 51/86] fix call to create_bucket in unit test --- allensdk/test/api/cloud_cache/test_cache.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/allensdk/test/api/cloud_cache/test_cache.py b/allensdk/test/api/cloud_cache/test_cache.py index b83da7176..4f2e4e86c 100644 --- a/allensdk/test/api/cloud_cache/test_cache.py +++ b/allensdk/test/api/cloud_cache/test_cache.py @@ -658,9 +658,9 @@ def test_outdated_manifest_warning(tmpdir, example_datasets_with_metadata): bucket_name = 'outdated_manifest_bucket' metadatasets = example_datasets_with_metadata['metadata'] - _ = create_bucket(bucket_name, - example_datasets_with_metadata['data'], - metadatasets=metadatasets) + create_bucket(bucket_name, + example_datasets_with_metadata['data'], + metadatasets=metadatasets) cache_dir = pathlib.Path(tmpdir) / 'cache' cache = S3CloudCache(cache_dir, bucket_name, 'project-x') From 099049c769e24e362261bcdba89df052d1f52e5d Mon Sep 17 00:00:00 2001 From: danielsf Date: Mon, 26 Apr 2021 15:03:50 -0700 Subject: [PATCH 52/86] clarify parameter names in summarize_comparison --- allensdk/api/cloud_cache/cloud_cache.py | 27 +++++++++++++------------ 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/allensdk/api/cloud_cache/cloud_cache.py b/allensdk/api/cloud_cache/cloud_cache.py index 2f4fb3992..6929adf72 100644 --- a/allensdk/api/cloud_cache/cloud_cache.py +++ b/allensdk/api/cloud_cache/cloud_cache.py @@ -637,7 +637,8 @@ def summarize_comparison(self, containing the list of metadata and data files that changed between them - Note: this assumes that manifest_0 predates manifest_1 + Note: this assumes that manifest_0 predates manifest_1 (i.e. + changes are listed relative to manifest_0) Parameters ---------- @@ -668,22 +669,22 @@ def summarize_comparison(self, result = {} for (result_key, - fname_list, - fname_lookup) in zip(('metadata_changes', 'data_changes'), - ((man0.metadata_file_names, - man1.metadata_file_names), - (man0.file_id_values, - man1.file_id_values)), - ((man0.metadata_file_attributes, - man1.metadata_file_attributes), - (man0.data_file_attributes, - man1.data_file_attributes))): + file_id_list, + attr_lookup) in zip(('metadata_changes', 'data_changes'), + ((man0.metadata_file_names, + man1.metadata_file_names), + (man0.file_id_values, + man1.file_id_values)), + ((man0.metadata_file_attributes, + man1.metadata_file_attributes), + (man0.data_file_attributes, + man1.data_file_attributes))): filename_to_hash = {} for version in (0, 1): filename_to_hash[version] = {} - for file_id in fname_list[version]: - obj = fname_lookup[version](file_id) + for file_id in file_id_list[version]: + obj = attr_lookup[version](file_id) file_name = relative_path_from_url(obj.url) file_name = '/'.join(file_name.split('/')[1:]) filename_to_hash[version][file_name] = obj.file_hash From 5c773560d5fdbd1ef4700741917cfdad4d6beef8 Mon Sep 17 00:00:00 2001 From: danielsf Date: Mon, 26 Apr 2021 15:28:47 -0700 Subject: [PATCH 53/86] add unit test of list_all_downloaded_manifests --- allensdk/test/api/cloud_cache/test_cache.py | 28 +++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/allensdk/test/api/cloud_cache/test_cache.py b/allensdk/test/api/cloud_cache/test_cache.py index 4f2e4e86c..5ff398821 100644 --- a/allensdk/test/api/cloud_cache/test_cache.py +++ b/allensdk/test/api/cloud_cache/test_cache.py @@ -683,3 +683,31 @@ def test_outdated_manifest_warning(tmpdir, example_datasets_with_metadata): if len(warnings) > 0: for w in warnings.list: assert w._category_name != 'OutdatedManifestWarning' + + +@mock_s3 +def test_list_all_downloaded(tmpdir, example_datasets_with_metadata): + """ + Test that list_all_downloaded_manifests works + """ + + bucket_name = 'outdated_manifest_bucket' + metadatasets = example_datasets_with_metadata['metadata'] + create_bucket(bucket_name, + example_datasets_with_metadata['data'], + metadatasets=metadatasets) + + cache_dir = pathlib.Path(tmpdir) / 'cache' + cache = S3CloudCache(cache_dir, bucket_name, 'project-x') + + assert cache.list_all_downloaded_manifests() == [] + + cache.load_manifest('project-x_manifest_v5.0.0.json') + cache.load_manifest('project-x_manifest_v2.0.0.json') + cache.load_manifest('project-x_manifest_v2.0.0.json') + + expected = {'project-x_manifest_v5.0.0.json', + 'project-x_manifest_v2.0.0.json', + 'project-x_manifest_v2.0.0.json'} + downloaded = set(cache.list_all_downloaded_manifests()) + assert downloaded == expected From 76a2feac7220e99a5fe023f6f6bf9a6208f15155 Mon Sep 17 00:00:00 2001 From: danielsf Date: Mon, 26 Apr 2021 15:40:20 -0700 Subject: [PATCH 54/86] flesh out comments in unit test --- .../api/cloud_cache/test_smart_download.py | 24 +++++++++++++++---- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/allensdk/test/api/cloud_cache/test_smart_download.py b/allensdk/test/api/cloud_cache/test_smart_download.py index bc6f4f8f2..dd241ae1f 100644 --- a/allensdk/test/api/cloud_cache/test_smart_download.py +++ b/allensdk/test/api/cloud_cache/test_smart_download.py @@ -119,14 +119,14 @@ def test_on_corrupted_files(tmpdir, example_datasets): attr = cache.data_path(file_id) assert attr['exists'] - # Check that, when a file on disk gets corrupted, - # all of the symlinks that point back to that file - # get marked as `not exists` - hasher = hashlib.blake2b() hasher.update(b'4567890') true_hash = hasher.hexdigest() + # Check that, when a file on disk gets corrupted, + # all of the symlinks that point back to that file + # get marked as `not exists` + cache.load_manifest('project-x_manifest_v1.0.0.json') attr = cache.data_path('2') with open(attr['local_path'], 'wb') as out_file: @@ -134,6 +134,9 @@ def test_on_corrupted_files(tmpdir, example_datasets): attr = cache.data_path('2') assert not attr['exists'] + + # note that v0.2.0/f2.txt is identical to v0.1.0/f2.txt + # in the example data set cache.load_manifest('project-x_manifest_v2.0.0.json') attr = cache.data_path('2') assert not attr['exists'] @@ -144,6 +147,7 @@ def test_on_corrupted_files(tmpdir, example_datasets): attr = cache.data_path('2') assert attr['exists'] redownloaded_path = attr['local_path'] + cache.load_manifest('project-x_manifest_v1.0.0.json') attr = cache.data_path('2') assert attr['exists'] @@ -192,7 +196,6 @@ def test_corrupted_download_manifest(tmpdir, example_datasets): with open(cache._downloaded_data_path, 'w') as out_file: out_file.write(json.dumps(src_data, indent=2)) - # now corrupt one of the data files hasher = hashlib.blake2b() hasher.update(b'4567890') true_hash = hasher.hexdigest() @@ -204,12 +207,20 @@ def test_corrupted_download_manifest(tmpdir, example_datasets): # CloudCache won't consult _downloaded_data_path assert attr['exists'] + # now corrupt one of the data files with open(attr['local_path'], 'wb') as out_file: out_file.write(b'xxxxx') + # now that the file is corrupted, 'exists' is False + attr = cache.data_path('2') + assert not attr['exists'] + + # note that v0.2.0/f2.txt is identical to v0.1.0/f2.txt cache.load_manifest('project-x_manifest_v2.0.0.json') attr = cache.data_path('2') assert not attr['exists'] + + # re download the file cache.download_data('2') attr = cache.data_path('2') downloaded_path = attr['local_path'] @@ -221,6 +232,9 @@ def test_corrupted_download_manifest(tmpdir, example_datasets): test_hash = hasher.hexdigest() assert test_hash == true_hash + # check that the v0.1.0 version of the file, which should be + # identical to the v0.2.0 version of the file, is also + # fixed cache.load_manifest('project-x_manifest_v1.0.0.json') attr = cache.data_path('2') assert attr['exists'] From e279edb28e10ff5668bd918752458f221e3e5733 Mon Sep 17 00:00:00 2001 From: danielsf Date: Mon, 26 Apr 2021 16:43:52 -0700 Subject: [PATCH 55/86] add ability to reconstruct local path-to-hash manifest for users who first used CloudCache before the symlink functionality was added --- allensdk/api/cloud_cache/cloud_cache.py | 47 +++++++++-- .../api/cloud_cache/test_smart_download.py | 80 +++++++++++++++++++ 2 files changed, 121 insertions(+), 6 deletions(-) diff --git a/allensdk/api/cloud_cache/cloud_cache.py b/allensdk/api/cloud_cache/cloud_cache.py index 6929adf72..009ef4a2b 100644 --- a/allensdk/api/cloud_cache/cloud_cache.py +++ b/allensdk/api/cloud_cache/cloud_cache.py @@ -47,6 +47,13 @@ def __init__(self, cache_dir, project_name): self._manifest = None self._cache_dir = cache_dir + self._project_name = project_name + self._manifest_file_names = self._list_all_manifests() + + # what latest_manifest was the last time an OutdatedManifestWarning + # was emitted + self._manifest_last_warned_on = None + # self._downloaded_data_path is where we will keep a JSONized # dict mapping paths to downloaded files to their file_hashes; # this will be used when determining if a downloaded file @@ -54,12 +61,40 @@ def __init__(self, cache_dir, project_name): c_path = pathlib.Path(self._cache_dir) self._downloaded_data_path = c_path / '_downloaded_data.json' - self._project_name = project_name - self._manifest_file_names = self._list_all_manifests() + if not self._downloaded_data_path.exists(): + self._construct_file_version_lookup() - # what latest_manifest was the last time an OutdatedManifestWarning - # was emitted - self._manifest_last_warned_on = None + def _construct_file_version_lookup(self) -> None: + """ + Construct the dict that maps between file_hash and + absolute local path. Save it to self._downloaded_data_path + """ + lookup = {} + for mfest_name in self.list_all_downloaded_manifests(): + + # Call the private method so that we don't accidentally + # raise the "a more up to date version of the manifest + # exists" warning. That is not the point here. + self._manifest = self._load_manifest(mfest_name) + + for file_id in self._manifest.file_id_values: + attr = self.data_path(file_id) + if attr['exists']: + local_path = str(attr['local_path'].resolve()) + hsh = attr['file_attributes'].file_hash + lookup[local_path] = hsh + + for metadata_name in self._manifest.metadata_file_names: + attr = self.metadata_path(metadata_name) + if attr['exists']: + local_path = str(attr['local_path'].resolve()) + hsh = attr['file_attributes'].file_hash + lookup[local_path] = hsh + + with open(self._downloaded_data_path, 'w') as out_file: + out_file.write(json.dumps(lookup, indent=2, sort_keys=True)) + + self._manifest = None def _warn_of_outdated_manifest(self, manifest_name: str) -> None: """ @@ -427,7 +462,7 @@ def data_path(self, file_id) -> dict: ------- dict - 'path' will be a pathlib.Path pointing to the file's location + 'local_path' will be a pathlib.Path pointing to the file's location 'exists' will be a boolean indicating if the file exists in a valid state diff --git a/allensdk/test/api/cloud_cache/test_smart_download.py b/allensdk/test/api/cloud_cache/test_smart_download.py index dd241ae1f..5ad02bd66 100644 --- a/allensdk/test/api/cloud_cache/test_smart_download.py +++ b/allensdk/test/api/cloud_cache/test_smart_download.py @@ -1,9 +1,11 @@ +import pytest import json import hashlib import pathlib from moto import mock_s3 from .utils import create_bucket from allensdk.api.cloud_cache.cloud_cache import S3CloudCache +from allensdk.api.cloud_cache.file_attributes import CacheFileAttributes # noqa: E501 @mock_s3 @@ -240,3 +242,81 @@ def test_corrupted_download_manifest(tmpdir, example_datasets): assert attr['exists'] assert attr['local_path'].resolve() == downloaded_path.resolve() assert attr['local_path'].absolute() != downloaded_path.absolute() + + +@mock_s3 +def test_reconstruction_of_local_manifest(tmpdir): + """ + Test that, if _downloaded_data.json gets lost, it can be reconstructed + so that the CloudCache does not automatically download new copies of files + """ + + # define a cache class that cannot download from S3 + class DummyCache(S3CloudCache): + def _download_file(self, file_attributes: CacheFileAttributes): + if not self._file_exists(file_attributes): + raise RuntimeError("Cannot download files") + return True + + # first two versions of dataset are identical; + # third differs + example_data = {} + example_data['1.0.0'] = {} + example_data['1.0.0']['f1.txt'] = {'file_id': '1', 'data': b'abc'} + example_data['1.0.0']['f2.txt'] = {'file_id': '2', 'data': b'def'} + + example_data['2.0.0'] = {} + example_data['2.0.0']['f1.txt'] = {'file_id': '1', 'data': b'abc'} + example_data['2.0.0']['f2.txt'] = {'file_id': '2', 'data': b'def'} + + example_data['3.0.0'] = {} + example_data['3.0.0']['f1.txt'] = {'file_id': '1', 'data': b'tuv'} + example_data['3.0.0']['f2.txt'] = {'file_id': '2', 'data': b'wxy'} + + test_bucket_name = 'cache_from_scratch_bucket' + create_bucket(test_bucket_name, + example_data) + + cache_dir = pathlib.Path(tmpdir) / 'cache' + + # read in v1.0.0 data files using normal S3 cache class + cache = S3CloudCache(cache_dir, test_bucket_name, 'project-x') + expected_hash = {} + cache.load_manifest(f'project-x_manifest_v1.0.0.json') + for file_id in ('1', '2'): + local_path = cache.download_data(file_id) + hasher = hashlib.blake2b() + with open(local_path, 'rb') as in_file: + hasher.update(in_file.read()) + expected_hash[file_id] = hasher.hexdigest() + + # load the other manifests, so DummyCache can get it + cache.load_manifest(f'project-x_manifest_v2.0.0.json') + cache.load_manifest(f'project-x_manifest_v3.0.0.json') + + # delete the JSON file that maps local path to file hash + lookup_path = cache._downloaded_data_path + assert lookup_path.exists() + lookup_path.unlink() + assert not lookup_path.exists() + + del cache + + # Reload the data using the cache class that cannot download + # files. Verify that paths to files with the correct hashes + # are returned. This will mean that the local manifest mapping + # filename to file hash was correctly reconstructed. + dummy = DummyCache(cache_dir, test_bucket_name, 'project-x') + dummy.load_manifest(f'project-x_manifest_v2.0.0.json') + for file_id in ('1', '2'): + local_path = dummy.download_data(file_id) + hasher = hashlib.blake2b() + with open(local_path, 'rb') as in_file: + hasher.update(in_file.read()) + assert hasher.hexdigest() == expected_hash[file_id] + + # make sure that dummy really is unable to download by trying + # (and failing) to get data from v3.0.0 + dummy.load_manifest(f'project-x_manifest_v3.0.0.json') + with pytest.raises(RuntimeError) as error: + dummy.download_data('1') From 8edd602abcc602e11099fa9c58f79b91442b9bb1 Mon Sep 17 00:00:00 2001 From: danielsf Date: Mon, 26 Apr 2021 18:53:45 -0700 Subject: [PATCH 56/86] pep8 changes --- .../api/cloud_cache/test_smart_download.py | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/allensdk/test/api/cloud_cache/test_smart_download.py b/allensdk/test/api/cloud_cache/test_smart_download.py index 5ad02bd66..4513297f4 100644 --- a/allensdk/test/api/cloud_cache/test_smart_download.py +++ b/allensdk/test/api/cloud_cache/test_smart_download.py @@ -282,7 +282,7 @@ def _download_file(self, file_attributes: CacheFileAttributes): # read in v1.0.0 data files using normal S3 cache class cache = S3CloudCache(cache_dir, test_bucket_name, 'project-x') expected_hash = {} - cache.load_manifest(f'project-x_manifest_v1.0.0.json') + cache.load_manifest('project-x_manifest_v1.0.0.json') for file_id in ('1', '2'): local_path = cache.download_data(file_id) hasher = hashlib.blake2b() @@ -291,8 +291,8 @@ def _download_file(self, file_attributes: CacheFileAttributes): expected_hash[file_id] = hasher.hexdigest() # load the other manifests, so DummyCache can get it - cache.load_manifest(f'project-x_manifest_v2.0.0.json') - cache.load_manifest(f'project-x_manifest_v3.0.0.json') + cache.load_manifest('project-x_manifest_v2.0.0.json') + cache.load_manifest('project-x_manifest_v3.0.0.json') # delete the JSON file that maps local path to file hash lookup_path = cache._downloaded_data_path @@ -307,16 +307,16 @@ def _download_file(self, file_attributes: CacheFileAttributes): # are returned. This will mean that the local manifest mapping # filename to file hash was correctly reconstructed. dummy = DummyCache(cache_dir, test_bucket_name, 'project-x') - dummy.load_manifest(f'project-x_manifest_v2.0.0.json') + dummy.load_manifest('project-x_manifest_v2.0.0.json') for file_id in ('1', '2'): - local_path = dummy.download_data(file_id) - hasher = hashlib.blake2b() - with open(local_path, 'rb') as in_file: - hasher.update(in_file.read()) - assert hasher.hexdigest() == expected_hash[file_id] + local_path = dummy.download_data(file_id) + hasher = hashlib.blake2b() + with open(local_path, 'rb') as in_file: + hasher.update(in_file.read()) + assert hasher.hexdigest() == expected_hash[file_id] # make sure that dummy really is unable to download by trying # (and failing) to get data from v3.0.0 - dummy.load_manifest(f'project-x_manifest_v3.0.0.json') - with pytest.raises(RuntimeError) as error: + dummy.load_manifest('project-x_manifest_v3.0.0.json') + with pytest.raises(RuntimeError): dummy.download_data('1') From 40123efbcca926bc4b1ba3afe00f014cb8e3176d Mon Sep 17 00:00:00 2001 From: Dan Kapner Date: Mon, 29 Mar 2021 09:31:25 -0700 Subject: [PATCH 57/86] extends CI test matrix to 3.8 --- .github/workflows/github-actions-ci.yml | 3 ++- docker/anaconda3/Dockerfile | 2 +- requirements.txt | 4 ++-- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/.github/workflows/github-actions-ci.yml b/.github/workflows/github-actions-ci.yml index f8703bde1..afe5bf4b8 100644 --- a/.github/workflows/github-actions-ci.yml +++ b/.github/workflows/github-actions-ci.yml @@ -44,7 +44,8 @@ jobs: strategy: matrix: os: ["macos-latest", "windows-latest", "ubuntu-latest"] - python-version: ["3.6", "3.7"] + python-version: ["3.6", "3.7", "3.8"] + fail-fast: false defaults: run: shell: bash -l {0} diff --git a/docker/anaconda3/Dockerfile b/docker/anaconda3/Dockerfile index 85d0a1cb8..1c65f18ff 100755 --- a/docker/anaconda3/Dockerfile +++ b/docker/anaconda3/Dockerfile @@ -22,7 +22,7 @@ RUN conda create -y --name py36 python=3.6 ipykernel \ RUN conda create -y --name py37 python=3.7 ipykernel \ && conda clean --index-cache --tarballs -RUN conda create -y --name py38 python=3.8 ipykernel numpy h5py \ +RUN conda create -y --name py38 python=3.8 ipykernel numpy\ && conda clean --index-cache --tarballs RUN conda create -y --name py39 python=3.9 ipykernel \ diff --git a/requirements.txt b/requirements.txt index 4c729645a..edd37d1dd 100644 --- a/requirements.txt +++ b/requirements.txt @@ -14,14 +14,14 @@ requests-toolbelt<1.0.0 simplejson>=3.10.0,<4.0.0 scikit-image>=0.14.0,<0.17.0 scikit-build<1.0.0 -statsmodels==0.9.0 +statsmodels<=0.13.0 simpleitk>=2.0.2,<3.0.0 argschema<3.0.0 marshmallow==3.0.0rc6 glymur==0.8.19 xarray<0.16.0 pynwb>=1.3.2,<2.0.0 -tables==3.5.1 # pinning this because updates tend to not include wheels immediately +tables>=3.6.0,<4.0.0 seaborn<1.0.0 aiohttp==3.7.4 nest_asyncio==1.2.0 From 4c183424fb97c9f6c08361f2e5da07e3e2f98d23 Mon Sep 17 00:00:00 2001 From: Dan Date: Wed, 28 Apr 2021 08:14:44 -0700 Subject: [PATCH 58/86] updates docs to reflect change --- CHANGELOG.md | 3 +++ doc_template/index.rst | 2 +- setup.py | 1 + 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 28108c026..b8e20c53e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,9 @@ # Change Log All notable changes to this project will be documented in this file. +## [2.11.0] = TBD +- python 3.8 compatibility + ## [2.10.3] = 2021-04-23 - Adds restriction to require hdmf version to be strictly less than 2.5.0 which accidentally introduced a major version breaking change diff --git a/doc_template/index.rst b/doc_template/index.rst index 03e5bdd1e..9d98a5441 100644 --- a/doc_template/index.rst +++ b/doc_template/index.rst @@ -121,7 +121,7 @@ See the `mouse connectivity section `_ for more details. What's New - 2.11.0 ----------------------------------------------------------------------- -- list updates here +- python 3.8 compatibility What's New - 2.10.3 diff --git a/setup.py b/setup.py index a399fe919..0edd3984c 100644 --- a/setup.py +++ b/setup.py @@ -57,5 +57,6 @@ def prepend_find_packages(*roots): 'Operating System :: OS Independent', 'Programming Language :: Python :: 3.6', 'Programming Language :: Python :: 3.7', + 'Programming Language :: Python :: 3.8', 'Topic :: Scientific/Engineering :: Bio-Informatics' ]) From d55c72e7de97c489054e9e07c7e84be961254179 Mon Sep 17 00:00:00 2001 From: danielsf Date: Thu, 29 Apr 2021 13:24:56 -0700 Subject: [PATCH 59/86] make construct_local_manifest() optional --- allensdk/api/cloud_cache/cloud_cache.py | 38 +++++++++++++++++-- .../api/cloud_cache/test_smart_download.py | 19 +++++++++- 2 files changed, 52 insertions(+), 5 deletions(-) diff --git a/allensdk/api/cloud_cache/cloud_cache.py b/allensdk/api/cloud_cache/cloud_cache.py index 009ef4a2b..d74b2325e 100644 --- a/allensdk/api/cloud_cache/cloud_cache.py +++ b/allensdk/api/cloud_cache/cloud_cache.py @@ -24,6 +24,10 @@ class OutdatedManifestWarning(UserWarning): pass +class MissingLocalManifestWarning(UserWarning): + pass + + class CloudCacheBase(ABC): """ A class to handle the downloading and accessing of data served from a cloud @@ -61,10 +65,38 @@ def __init__(self, cache_dir, project_name): c_path = pathlib.Path(self._cache_dir) self._downloaded_data_path = c_path / '_downloaded_data.json' + # if the local manifest is missing but there are + # data files in cache_dir, emit a warning + # suggesting that the user run + # self.construct_local_manifest if not self._downloaded_data_path.exists(): - self._construct_file_version_lookup() - - def _construct_file_version_lookup(self) -> None: + file_list = c_path.glob('**/*') + has_files = False + for fname in file_list: + if fname.is_file(): + if 'json' not in fname.name: + has_files = True + break + if has_files: + msg = 'This cache directory appears to ' + msg += 'contain data files, but it has no ' + msg += 'record of what those files are. ' + msg += 'You might want to consider running\n\n' + msg += 'self.construct_local_manifest()\n\n' + msg += 'to avoid needlessly downloading duplicates ' + msg += 'of data files that did not change between ' + msg += 'data releases. NOTE: running this method ' + msg += 'will require hashing every data file you ' + msg += 'have currently downloaded and could be ' + msg += 'very time consuming.\n\n' + msg += 'To avoid this warning in the future, make ' + msg += 'sure that\n\n' + msg += f'{str(self._downloaded_data_path.resolve())}\n\n' + msg += 'is not deleted between instantiations of this ' + msg += 'cache' + warnings.warn(msg, MissingLocalManifestWarning) + + def construct_local_manifest(self) -> None: """ Construct the dict that maps between file_hash and absolute local path. Save it to self._downloaded_data_path diff --git a/allensdk/test/api/cloud_cache/test_smart_download.py b/allensdk/test/api/cloud_cache/test_smart_download.py index 4513297f4..37d50ebb7 100644 --- a/allensdk/test/api/cloud_cache/test_smart_download.py +++ b/allensdk/test/api/cloud_cache/test_smart_download.py @@ -4,6 +4,7 @@ import pathlib from moto import mock_s3 from .utils import create_bucket +from allensdk.api.cloud_cache.cloud_cache import MissingLocalManifestWarning from allensdk.api.cloud_cache.cloud_cache import S3CloudCache from allensdk.api.cloud_cache.file_attributes import CacheFileAttributes # noqa: E501 @@ -280,7 +281,17 @@ def _download_file(self, file_attributes: CacheFileAttributes): cache_dir = pathlib.Path(tmpdir) / 'cache' # read in v1.0.0 data files using normal S3 cache class - cache = S3CloudCache(cache_dir, test_bucket_name, 'project-x') + with pytest.warns(None) as warnings: + cache = S3CloudCache(cache_dir, test_bucket_name, 'project-x') + + # make sure no MissingLocalManifestWarnings were raised + w_type = 'MissingLocalManifestWarning' + for w in warnings.list: + if w._category_name == w_type: + msg = 'Raised MissingLocalManifestWarning on empty ' + msg += 'cache dir' + assert False, msg + expected_hash = {} cache.load_manifest('project-x_manifest_v1.0.0.json') for file_id in ('1', '2'): @@ -306,7 +317,11 @@ def _download_file(self, file_attributes: CacheFileAttributes): # files. Verify that paths to files with the correct hashes # are returned. This will mean that the local manifest mapping # filename to file hash was correctly reconstructed. - dummy = DummyCache(cache_dir, test_bucket_name, 'project-x') + with pytest.warns(MissingLocalManifestWarning) as warnings: + dummy = DummyCache(cache_dir, test_bucket_name, 'project-x') + + dummy.construct_local_manifest() + dummy.load_manifest('project-x_manifest_v2.0.0.json') for file_id in ('1', '2'): local_path = dummy.download_data(file_id) From 4f1aea0cb318b40815bfbb774e2dd9aa5de6fdf6 Mon Sep 17 00:00:00 2001 From: danielsf Date: Thu, 29 Apr 2021 13:43:45 -0700 Subject: [PATCH 60/86] construct_local_manifest hashes all files that exist in cache_dir as opposed to checking the manifest to see if those files exist (which requires a hash already). This should make it possible for us to add a status bar, since we will know in advance how many files need to be hashed --- allensdk/api/cloud_cache/cloud_cache.py | 33 +++++++++---------------- 1 file changed, 11 insertions(+), 22 deletions(-) diff --git a/allensdk/api/cloud_cache/cloud_cache.py b/allensdk/api/cloud_cache/cloud_cache.py index d74b2325e..cc6b264e0 100644 --- a/allensdk/api/cloud_cache/cloud_cache.py +++ b/allensdk/api/cloud_cache/cloud_cache.py @@ -102,32 +102,21 @@ def construct_local_manifest(self) -> None: absolute local path. Save it to self._downloaded_data_path """ lookup = {} - for mfest_name in self.list_all_downloaded_manifests(): - - # Call the private method so that we don't accidentally - # raise the "a more up to date version of the manifest - # exists" warning. That is not the point here. - self._manifest = self._load_manifest(mfest_name) - - for file_id in self._manifest.file_id_values: - attr = self.data_path(file_id) - if attr['exists']: - local_path = str(attr['local_path'].resolve()) - hsh = attr['file_attributes'].file_hash - lookup[local_path] = hsh - - for metadata_name in self._manifest.metadata_file_names: - attr = self.metadata_path(metadata_name) - if attr['exists']: - local_path = str(attr['local_path'].resolve()) - hsh = attr['file_attributes'].file_hash - lookup[local_path] = hsh + files_to_hash = set() + c_dir = pathlib.Path(self._cache_dir) + file_iterator = c_dir.glob('**/*') + for file_name in file_iterator: + if file_name.is_file(): + if 'json' not in file_name.name: + files_to_hash.add(file_name.resolve()) + + for local_path in files_to_hash: + hsh = file_hash_from_path(local_path) + lookup[str(local_path.absolute())] = hsh with open(self._downloaded_data_path, 'w') as out_file: out_file.write(json.dumps(lookup, indent=2, sort_keys=True)) - self._manifest = None - def _warn_of_outdated_manifest(self, manifest_name: str) -> None: """ Warn that manifest_name is not the latest manifest available From 1964d89f8563bd046fc3ecdf7186f9b4484a5d8a Mon Sep 17 00:00:00 2001 From: danielsf Date: Thu, 29 Apr 2021 14:11:19 -0700 Subject: [PATCH 61/86] add status bar for construct_local_manifest --- allensdk/api/cloud_cache/cloud_cache.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/allensdk/api/cloud_cache/cloud_cache.py b/allensdk/api/cloud_cache/cloud_cache.py index cc6b264e0..d82511c88 100644 --- a/allensdk/api/cloud_cache/cloud_cache.py +++ b/allensdk/api/cloud_cache/cloud_cache.py @@ -110,7 +110,11 @@ def construct_local_manifest(self) -> None: if 'json' not in file_name.name: files_to_hash.add(file_name.resolve()) - for local_path in files_to_hash: + pbar = tqdm.tqdm(files_to_hash, + total=len(files_to_hash), + unit='(files hashed)') + + for local_path in pbar: hsh = file_hash_from_path(local_path) lookup[str(local_path.absolute())] = hsh From 634985f1e7af7b575b1efd897973fef70b3e7f72 Mon Sep 17 00:00:00 2001 From: danielsf Date: Thu, 29 Apr 2021 14:18:40 -0700 Subject: [PATCH 62/86] OutdatedManifestWarning points user towards load_latest_manifest() --- allensdk/api/cloud_cache/cloud_cache.py | 2 ++ allensdk/test/api/cloud_cache/test_cache.py | 4 ++++ 2 files changed, 6 insertions(+) diff --git a/allensdk/api/cloud_cache/cloud_cache.py b/allensdk/api/cloud_cache/cloud_cache.py index d82511c88..7e7494f83 100644 --- a/allensdk/api/cloud_cache/cloud_cache.py +++ b/allensdk/api/cloud_cache/cloud_cache.py @@ -144,6 +144,8 @@ def _warn_of_outdated_manifest(self, manifest_name: str) -> None: msg += "To see all of the manifest files currently downloaded " msg += "onto your local system, run\n\n" msg += "self.list_all_downloaded_manifests()\n\n" + msg += "If you just want to load the latest manifest, run\n\n" + msg += "self.load_latest_manifest()\n\n" warnings.warn(msg, OutdatedManifestWarning) return None diff --git a/allensdk/test/api/cloud_cache/test_cache.py b/allensdk/test/api/cloud_cache/test_cache.py index 5ff398821..8f2466dec 100644 --- a/allensdk/test/api/cloud_cache/test_cache.py +++ b/allensdk/test/api/cloud_cache/test_cache.py @@ -672,6 +672,10 @@ def test_outdated_manifest_warning(tmpdir, example_datasets_with_metadata): ct = 0 for w in warnings.list: if w._category_name == m_warn_type: + msg = str(w.message) + assert 'is not the most up to date' in msg + assert 'self.compare_manifests' in msg + assert 'load_latest_manifest' in msg ct += 1 assert ct > 0 From 9b3045f7e73053df45d4fc383cf5af826a2d3d40 Mon Sep 17 00:00:00 2001 From: danielsf Date: Thu, 29 Apr 2021 14:55:07 -0700 Subject: [PATCH 63/86] warn users when latest_manifest is not latest local manifest --- allensdk/api/cloud_cache/cloud_cache.py | 15 +++++++++++ allensdk/test/api/cloud_cache/test_cache.py | 28 +++++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/allensdk/api/cloud_cache/cloud_cache.py b/allensdk/api/cloud_cache/cloud_cache.py index 7e7494f83..46bf1850f 100644 --- a/allensdk/api/cloud_cache/cloud_cache.py +++ b/allensdk/api/cloud_cache/cloud_cache.py @@ -211,6 +211,21 @@ def latest_downloaded_manifest_file(self) -> str: return self._find_latest_file(self.list_all_downloaded_manifests()) def load_latest_manifest(self): + latest_downloaded = self.latest_downloaded_manifest_file + latest = self.latest_manifest_file + if latest != latest_downloaded: + if latest_downloaded != '': + msg = f'You are loading\n{self.latest_manifest_file}\n' + msg += 'which is newer than the most recent manifest ' + msg += 'file you have previously been working with\n' + msg += f'{latest_downloaded}\n' + msg += 'It is possible that some data files have changed ' + msg += 'between these two data releases, which will ' + msg += 'force you to re-download those data files ' + msg += '(currently downloaded files will not be overwritten).' + msg += f' To continue using {latest_downloaded}, run\n' + msg += f"self.load_manifest('{latest_downloaded}')" + warnings.warn(msg, OutdatedManifestWarning) self.load_manifest(self.latest_manifest_file) @abstractmethod diff --git a/allensdk/test/api/cloud_cache/test_cache.py b/allensdk/test/api/cloud_cache/test_cache.py index 8f2466dec..9344bfbe0 100644 --- a/allensdk/test/api/cloud_cache/test_cache.py +++ b/allensdk/test/api/cloud_cache/test_cache.py @@ -715,3 +715,31 @@ def test_list_all_downloaded(tmpdir, example_datasets_with_metadata): 'project-x_manifest_v2.0.0.json'} downloaded = set(cache.list_all_downloaded_manifests()) assert downloaded == expected + + +@mock_s3 +def test_latest_manifest_warning(tmpdir, example_datasets_with_metadata): + """ + Test that the correct warning is emitted when the user tries + to load_latest_manifest but that has not been downloaded yet + """ + + bucket_name = 'outdated_manifest_bucket' + metadatasets = example_datasets_with_metadata['metadata'] + create_bucket(bucket_name, + example_datasets_with_metadata['data'], + metadatasets=metadatasets) + + cache_dir = pathlib.Path(tmpdir) / 'cache' + cache = S3CloudCache(cache_dir, bucket_name, 'project-x') + + cache.load_manifest('project-x_manifest_v4.0.0.json') + + with pytest.warns(OutdatedManifestWarning) as warnings: + cache.load_latest_manifest() + assert len(warnings) == 1 + msg = str(warnings[0].message) + assert 'project-x_manifest_v4.0.0.json' in msg + assert 'project-x_manifest_v15.0.0.json' in msg + assert 'It is possible that some data files' in msg + assert "self.load_manifest('project-x_manifest_v4.0.0.json')" in msg From a6c8825bfbd405a791e74abd38ba122d8dc48e74 Mon Sep 17 00:00:00 2001 From: Dan Date: Mon, 3 May 2021 09:51:57 -0700 Subject: [PATCH 64/86] adds explicit no ROIs exception. --- allensdk/internal/pipeline_modules/run_roi_filter.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/allensdk/internal/pipeline_modules/run_roi_filter.py b/allensdk/internal/pipeline_modules/run_roi_filter.py index 1c7198f01..d56aa1dd4 100644 --- a/allensdk/internal/pipeline_modules/run_roi_filter.py +++ b/allensdk/internal/pipeline_modules/run_roi_filter.py @@ -209,6 +209,8 @@ def load_all_input(data): border = roi_filter_utils.calculate_max_border(motion_data, MAX_SHIFT) rois = roi_filter_utils.get_rois(segmentation_stack, border) + if len(rois) == 0: + raise ValueError(f"no ROIs were found from {maxint_file}") rois = roi_filter_utils.order_rois_by_object_list(object_data, rois) result = {"model_id": model_id, From 68e57524f3c2a20e973c51ff0aad7775f9ee8e7d Mon Sep 17 00:00:00 2001 From: Dan Date: Mon, 3 May 2021 09:53:36 -0700 Subject: [PATCH 65/86] adds tests for nearest neighbor tree search --- .../brain_observatory/roi_filter_utils.py | 2 + .../test_roi_filter_utils.py | 42 +++++++++++++++++++ 2 files changed, 44 insertions(+) create mode 100644 allensdk/test/internal/brain_observatory/test_roi_filter_utils.py diff --git a/allensdk/internal/brain_observatory/roi_filter_utils.py b/allensdk/internal/brain_observatory/roi_filter_utils.py index ad4a2d1af..781bcd532 100644 --- a/allensdk/internal/brain_observatory/roi_filter_utils.py +++ b/allensdk/internal/brain_observatory/roi_filter_utils.py @@ -286,6 +286,8 @@ def get_indices_by_distance(object_list_points, mask_points): Require a distance of 0 (perfect match) and a unique match between masks and object_list entries. ''' + if np.array(mask_points).ndim != 2: + raise ValueError("number of dimensions is incorrect.") tree = cKDTree(mask_points) distance, indices = tree.query(object_list_points) if distance.max() > 0: diff --git a/allensdk/test/internal/brain_observatory/test_roi_filter_utils.py b/allensdk/test/internal/brain_observatory/test_roi_filter_utils.py new file mode 100644 index 000000000..87e31cfea --- /dev/null +++ b/allensdk/test/internal/brain_observatory/test_roi_filter_utils.py @@ -0,0 +1,42 @@ +import pytest + +from allensdk.internal.brain_observatory.roi_filter_utils import ( + get_indices_by_distance) + + +@pytest.mark.parametrize( + "tree_points, query_points, expected, exception", + [ + ( + [[0, 0], [0, 1], [0, 2], [1, 2], [2, 2]], + [[0, 0], [2, 2]], + [0, 4], + None + ), + ( + [[0, 0], [0, 1], [0, 2], [1, 2], [2, 2]], + [[0, 0.4], [0.1, 0.6]], + [0, 1], + pytest.raises(AssertionError, + match="Max match distance greater than 0") + ), + ( + [], + [], + [], + pytest.raises(ValueError, + match=("number of dimensions is incorrect. " + "perhaps there are no ROIs.")) + ) + ]) +def test_get_indices_by_distance(tree_points, query_points, expected, exception): + """tests exceptions with simple 2D vectors. Actual code has 5D vectors + for a basic cell-matching to [minx, miny, maxx, maxy, area] + """ + if exception is None: + indices = get_indices_by_distance(query_points, tree_points) + assert all([e == i for e, i in zip(expected, indices)]) + else: + with exception: + indices = get_indices_by_distance(query_points, tree_points) + assert all([e == i for e, i in zip(expected, indices)]) From 15945cd2115a6238629d16476c9c235f33e13190 Mon Sep 17 00:00:00 2001 From: Dan Date: Mon, 3 May 2021 09:58:10 -0700 Subject: [PATCH 66/86] pep8 --- .../brain_observatory/roi_filter_utils.py | 11 ++++++--- .../pipeline_modules/run_roi_filter.py | 23 +++++++++++-------- .../test_roi_filter_utils.py | 3 ++- 3 files changed, 23 insertions(+), 14 deletions(-) diff --git a/allensdk/internal/brain_observatory/roi_filter_utils.py b/allensdk/internal/brain_observatory/roi_filter_utils.py index 781bcd532..1266a591f 100644 --- a/allensdk/internal/brain_observatory/roi_filter_utils.py +++ b/allensdk/internal/brain_observatory/roi_filter_utils.py @@ -12,6 +12,7 @@ "roi_filter_training_criteria.json") _CRITERIA = None + def CRITERIA(): global _CRITERIA if _CRITERIA is None: @@ -181,9 +182,9 @@ def calculate_max_border(motion_df, max_shift): [right_shift, left_shift, down_shift, up_shift] ''' # strip outliers - x_no_outliers = motion_df["x"][(motion_df["x"] >= -max_shift) & \ + x_no_outliers = motion_df["x"][(motion_df["x"] >= -max_shift) & (motion_df["x"] <= max_shift)] - y_no_outliers = motion_df["y"][(motion_df["y"] >= -max_shift) & \ + y_no_outliers = motion_df["y"][(motion_df["y"] >= -max_shift) & (motion_df["y"] <= max_shift)] right_shift = np.max(-1*x_no_outliers.min(), 0) @@ -214,7 +215,11 @@ def order_rois_by_object_list(object_data, rois): list The list of rois reordered to index the same as object_data. ''' - object_points = object_data[["minx", "miny", "maxx", "maxy", "area"]].copy() + object_points = object_data[["minx", + "miny", + "maxx", + "maxy", + "area"]].copy() object_points["maxx"] += 1 object_points["maxy"] += 1 roi_points = [] diff --git a/allensdk/internal/pipeline_modules/run_roi_filter.py b/allensdk/internal/pipeline_modules/run_roi_filter.py index d56aa1dd4..e83f2f38a 100644 --- a/allensdk/internal/pipeline_modules/run_roi_filter.py +++ b/allensdk/internal/pipeline_modules/run_roi_filter.py @@ -1,22 +1,21 @@ import logging -from six.moves import cPickle import allensdk.internal.core.lims_utilities as lu -from allensdk.internal.core.lims_pipeline_module import PipelineModule, run_module +from allensdk.internal.core.lims_pipeline_module import ( + PipelineModule, run_module) from allensdk.internal.brain_observatory import roi_filter, roi_filter_utils from allensdk.brain_observatory.roi_masks import (RIGHT_SHIFT, LEFT_SHIFT, DOWN_SHIFT, UP_SHIFT) import pandas as pd import os -import numpy import h5py DEPRECATED_MOTION_HEADER = ["index", "x", "y", "a", "b", "c", "d", "e", "f"] MAX_SHIFT = 30 OVERLAP_THRESHOLD = 0.9 -DEBUG_SDK_PATH="/data/informatics/CAM/roi_filter/allensdk/" -DEBUG_SCRIPT=os.path.join(DEBUG_SDK_PATH, "allensdk", "internal", - "pipeline_modules", "run_roi_filter.py") -DEBUG_OUTPUT_DIRECTORY="/data/informatics/CAM/roi_filter/" +DEBUG_SDK_PATH = "/data/informatics/CAM/roi_filter/allensdk/" +DEBUG_SCRIPT = os.path.join(DEBUG_SDK_PATH, "allensdk", "internal", + "pipeline_modules", "run_roi_filter.py") +DEBUG_OUTPUT_DIRECTORY = "/data/informatics/CAM/roi_filter/" def get_motion_filepath(experiment_id): @@ -143,10 +142,12 @@ def load_all_input(data): raise try: - rigid_motion_transform_file = data["log_0"] # TODO: update name in LIMS and here + # TODO: update name in LIMS and here + rigid_motion_transform_file = data["log_0"] motion_data = load_rigid_motion_transform(rigid_motion_transform_file) except KeyError: - logging.error("Input json missing log_0") # TODO: update name in LIMS and here + # TODO: update name in LIMS and here + logging.error("Input json missing log_0") raise except IOError: logging.error("Could not read rigid motion transform file %s", @@ -285,4 +286,6 @@ def main(): mod.write_output_data(output_data) -if __name__ == "__main__": main() + +if __name__ == "__main__": + main() diff --git a/allensdk/test/internal/brain_observatory/test_roi_filter_utils.py b/allensdk/test/internal/brain_observatory/test_roi_filter_utils.py index 87e31cfea..4a66dd207 100644 --- a/allensdk/test/internal/brain_observatory/test_roi_filter_utils.py +++ b/allensdk/test/internal/brain_observatory/test_roi_filter_utils.py @@ -29,7 +29,8 @@ "perhaps there are no ROIs.")) ) ]) -def test_get_indices_by_distance(tree_points, query_points, expected, exception): +def test_get_indices_by_distance(tree_points, query_points, + expected, exception): """tests exceptions with simple 2D vectors. Actual code has 5D vectors for a basic cell-matching to [minx, miny, maxx, maxy, area] """ From 34bb88cf1d0fe62498c2cc735fcdf5bfb180c6ae Mon Sep 17 00:00:00 2001 From: Dan Date: Mon, 3 May 2021 10:04:57 -0700 Subject: [PATCH 67/86] fix exception wording match. --- .../test/internal/brain_observatory/test_roi_filter_utils.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/allensdk/test/internal/brain_observatory/test_roi_filter_utils.py b/allensdk/test/internal/brain_observatory/test_roi_filter_utils.py index 4a66dd207..52dca23dd 100644 --- a/allensdk/test/internal/brain_observatory/test_roi_filter_utils.py +++ b/allensdk/test/internal/brain_observatory/test_roi_filter_utils.py @@ -25,8 +25,7 @@ [], [], pytest.raises(ValueError, - match=("number of dimensions is incorrect. " - "perhaps there are no ROIs.")) + match=("number of dimensions is incorrect.")) ) ]) def test_get_indices_by_distance(tree_points, query_points, From 834995302a4b8fe99acca942bb6c93f724d058e2 Mon Sep 17 00:00:00 2001 From: Dan Date: Mon, 3 May 2021 10:20:12 -0700 Subject: [PATCH 68/86] more informative exception --- allensdk/internal/brain_observatory/roi_filter_utils.py | 3 ++- .../test/internal/brain_observatory/test_roi_filter_utils.py | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/allensdk/internal/brain_observatory/roi_filter_utils.py b/allensdk/internal/brain_observatory/roi_filter_utils.py index 1266a591f..4954f08da 100644 --- a/allensdk/internal/brain_observatory/roi_filter_utils.py +++ b/allensdk/internal/brain_observatory/roi_filter_utils.py @@ -292,7 +292,8 @@ def get_indices_by_distance(object_list_points, mask_points): masks and object_list entries. ''' if np.array(mask_points).ndim != 2: - raise ValueError("number of dimensions is incorrect.") + raise ValueError("number of dimensions is incorrect. Expected 2 " + f"got {np.array(mask_points).ndim}") tree = cKDTree(mask_points) distance, indices = tree.query(object_list_points) if distance.max() > 0: diff --git a/allensdk/test/internal/brain_observatory/test_roi_filter_utils.py b/allensdk/test/internal/brain_observatory/test_roi_filter_utils.py index 52dca23dd..835dc0f9f 100644 --- a/allensdk/test/internal/brain_observatory/test_roi_filter_utils.py +++ b/allensdk/test/internal/brain_observatory/test_roi_filter_utils.py @@ -25,7 +25,8 @@ [], [], pytest.raises(ValueError, - match=("number of dimensions is incorrect.")) + match=("number of dimensions is incorrect. " + "Expected 2 got 1")) ) ]) def test_get_indices_by_distance(tree_points, query_points, From 18344413b3892acad4696d74675243e9a1b23342 Mon Sep 17 00:00:00 2001 From: Dan Date: Mon, 3 May 2021 10:24:32 -0700 Subject: [PATCH 69/86] non pep8 requested format --- allensdk/internal/brain_observatory/roi_filter_utils.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/allensdk/internal/brain_observatory/roi_filter_utils.py b/allensdk/internal/brain_observatory/roi_filter_utils.py index 4954f08da..0cb0fb5de 100644 --- a/allensdk/internal/brain_observatory/roi_filter_utils.py +++ b/allensdk/internal/brain_observatory/roi_filter_utils.py @@ -182,10 +182,10 @@ def calculate_max_border(motion_df, max_shift): [right_shift, left_shift, down_shift, up_shift] ''' # strip outliers - x_no_outliers = motion_df["x"][(motion_df["x"] >= -max_shift) & - (motion_df["x"] <= max_shift)] - y_no_outliers = motion_df["y"][(motion_df["y"] >= -max_shift) & - (motion_df["y"] <= max_shift)] + x_no_outliers = motion_df["x"][(motion_df["x"] >= -max_shift) + & (motion_df["x"] <= max_shift)] + y_no_outliers = motion_df["y"][(motion_df["y"] >= -max_shift) + & (motion_df["y"] <= max_shift)] right_shift = np.max(-1*x_no_outliers.min(), 0) left_shift = np.max(x_no_outliers.max(), 0) From 8edb01278b1389548c9addf529004f2e37a737ab Mon Sep 17 00:00:00 2001 From: danielsf Date: Tue, 4 May 2021 14:07:25 -0700 Subject: [PATCH 70/86] add manifest maniuplation code to VisualBehaviorOphysProjectCache One seemingly unrelated change is that now CloudCache tracks metadata files in the local manifest. This prevents the CloudCache from warning users about missing local manifests when only metadata has been downloaded to the cache. This was necessary because VisualBehaviorOphysProjectCache automatically downloads metadata files on instantiation. --- allensdk/api/cloud_cache/cloud_cache.py | 54 +++- allensdk/api/warehouse_cache/cache.py | 5 + .../behavior_project_cache.py | 166 +++++++++- .../data_io/behavior_project_cloud_api.py | 54 +++- allensdk/test/api/cloud_cache/test_cache.py | 7 +- .../behavior_project_cache/__init__.py | 1 + .../behavior_project_cache/conftest.py | 193 ++++++++++++ .../test_behavior_project_cache.py | 44 +++ .../test_behavior_project_cloud_api.py | 3 + .../behavior_project_cache/test_from_s3.py | 290 ++++++++++++++++++ .../behavior/behavior_project_cache/utils.py | 154 ++++++++++ 11 files changed, 932 insertions(+), 39 deletions(-) create mode 100644 allensdk/test/brain_observatory/behavior/behavior_project_cache/__init__.py create mode 100644 allensdk/test/brain_observatory/behavior/behavior_project_cache/conftest.py create mode 100644 allensdk/test/brain_observatory/behavior/behavior_project_cache/test_from_s3.py create mode 100644 allensdk/test/brain_observatory/behavior/behavior_project_cache/utils.py diff --git a/allensdk/api/cloud_cache/cloud_cache.py b/allensdk/api/cloud_cache/cloud_cache.py index 46bf1850f..fd3699bd8 100644 --- a/allensdk/api/cloud_cache/cloud_cache.py +++ b/allensdk/api/cloud_cache/cloud_cache.py @@ -1,4 +1,4 @@ -from typing import List, Tuple, Dict +from typing import List, Tuple, Dict, Union from abc import ABC, abstractmethod import os import copy @@ -41,14 +41,26 @@ class CloudCacheBase(ABC): project_name: str the name of the project this cache is supposed to access. This will be the root directory for all files stored in the bucket. + + ui_class_name: Optional[str] + Name of the class users are actually using to maniuplate this + functionality (used to populate helpful error messages) """ _bucket_name = None - def __init__(self, cache_dir, project_name): + def __init__(self, cache_dir, project_name, ui_class_name=None): os.makedirs(cache_dir, exist_ok=True) + # the class users are actually interacting with + # (for warning message purposes) + if ui_class_name is None: + self._user_interface_class = type(self).__name__ + else: + self._user_interface_class = ui_class_name + self._manifest = None + self._manifest_name = None self._cache_dir = cache_dir self._project_name = project_name @@ -82,7 +94,7 @@ def __init__(self, cache_dir, project_name): msg += 'contain data files, but it has no ' msg += 'record of what those files are. ' msg += 'You might want to consider running\n\n' - msg += 'self.construct_local_manifest()\n\n' + msg += f'{self.ui}.construct_local_manifest()\n\n' msg += 'to avoid needlessly downloading duplicates ' msg += 'of data files that did not change between ' msg += 'data releases. NOTE: running this method ' @@ -96,6 +108,10 @@ def __init__(self, cache_dir, project_name): msg += 'cache' warnings.warn(msg, MissingLocalManifestWarning) + @property + def ui(self): + return self._user_interface_class + def construct_local_manifest(self) -> None: """ Construct the dict that maps between file_hash and @@ -139,7 +155,7 @@ def _warn_of_outdated_manifest(self, manifest_name: str) -> None: msg += f'{self.latest_manifest_file}\n\n' msg += 'To see the differences between these manifests,' msg += 'run\n\n' - msg += f"self.compare_manifests('{manifest_name}', " + msg += f"{self.ui}.compare_manifests('{manifest_name}', " msg += f"'{self.latest_manifest_file}')\n\n" msg += "To see all of the manifest files currently downloaded " msg += "onto your local system, run\n\n" @@ -224,7 +240,7 @@ def load_latest_manifest(self): msg += 'force you to re-download those data files ' msg += '(currently downloaded files will not be overwritten).' msg += f' To continue using {latest_downloaded}, run\n' - msg += f"self.load_manifest('{latest_downloaded}')" + msg += f"{self.ui}.load_manifest('{latest_downloaded}')" warnings.warn(msg, OutdatedManifestWarning) self.load_manifest(self.latest_manifest_file) @@ -278,6 +294,13 @@ def _download_file(self, file_attributes: CacheFileAttributes) -> bool: """ raise NotImplementedError() + @property + def current_manifest(self) -> Union[None, str]: + """ + The name of the currently loaded manifest + """ + return self._manifest_name + @property def project_name(self) -> str: """ @@ -367,6 +390,7 @@ def load_manifest(self, manifest_name: str): self._warn_of_outdated_manifest(manifest_name) self._manifest = self._load_manifest(manifest_name) + self._manifest_name = manifest_name def _update_list_of_downloads(self, file_attributes: CacheFileAttributes @@ -613,6 +637,7 @@ def download_metadata(self, fname: str) -> pathlib.Path: super_attributes = self.metadata_path(fname) file_attributes = super_attributes['file_attributes'] self._download_file(file_attributes) + self._update_list_of_downloads(file_attributes) return file_attributes.local_path def get_metadata(self, fname: str) -> pd.DataFrame: @@ -839,13 +864,19 @@ class S3CloudCache(CloudCacheBase): project_name: str the name of the project this cache is supposed to access. This will be the root directory for all files stored in the bucket. + + ui_class_name: Optional[str] + Name of the class users are actually using to maniuplate this + functionality (used to populate helpful error messages) """ - def __init__(self, cache_dir, bucket_name, project_name): + def __init__(self, cache_dir, bucket_name, project_name, + ui_class_name=None): self._manifest = None self._bucket_name = bucket_name - super().__init__(cache_dir=cache_dir, project_name=project_name) + super().__init__(cache_dir=cache_dir, project_name=project_name, + ui_class_name=ui_class_name) _s3_client = None @@ -1002,9 +1033,14 @@ class LocalCache(CloudCacheBase): project_name: str the name of the project this cache is supposed to access. This will be the root directory for all files stored in the bucket. + + ui_class_name: Optional[str] + Name of the class users are actually using to maniuplate this + functionality (used to populate helpful error messages) """ - def __init__(self, cache_dir, project_name): - super().__init__(cache_dir=cache_dir, project_name=project_name) + def __init__(self, cache_dir, project_name, ui_class_name=None): + super().__init__(cache_dir=cache_dir, project_name=project_name, + ui_class_name=ui_class_name) def _list_all_manifests(self) -> list: return self.list_all_downloaded_manifests() diff --git a/allensdk/api/warehouse_cache/cache.py b/allensdk/api/warehouse_cache/cache.py index 55fd9581c..5f317b0f0 100755 --- a/allensdk/api/warehouse_cache/cache.py +++ b/allensdk/api/warehouse_cache/cache.py @@ -97,6 +97,8 @@ def __init__(self, version=None, **kwargs): self.cache = cache + if version is None and hasattr(self, 'MANIFEST_VERSION'): + version = self.MANIFEST_VERSION self.load_manifest(manifest, version) def get_cache_path(self, file_name, manifest_key, *args): @@ -193,6 +195,9 @@ def add_manifest_paths(self, manifest_builder): should call super. ''' manifest_builder.add_path('BASEDIR', '.') + if hasattr(self, 'MANIFEST_CONFIG'): + for key, config in self.MANIFEST_CONFIG.items(): + manifest_builder.add_path(key, **config) return manifest_builder diff --git a/allensdk/brain_observatory/behavior/behavior_project_cache/behavior_project_cache.py b/allensdk/brain_observatory/behavior/behavior_project_cache/behavior_project_cache.py index 9e7b4f0de..64acefbda 100644 --- a/allensdk/brain_observatory/behavior/behavior_project_cache/behavior_project_cache.py +++ b/allensdk/brain_observatory/behavior/behavior_project_cache/behavior_project_cache.py @@ -21,7 +21,12 @@ from allensdk.core.authentication import DbCredentials -class VisualBehaviorOphysProjectCache(Cache): +class VBOLimsCache(Cache): + """ + A class that ineherits from the warehouse Cache and provides + that functionality to VisualBehaviorOphysProjectCache + """ + MANIFEST_VERSION = "0.0.1-alpha.3" OPHYS_SESSIONS_KEY = "ophys_sessions" BEHAVIOR_SESSIONS_KEY = "behavior_sessions" @@ -45,6 +50,9 @@ class VisualBehaviorOphysProjectCache(Cache): } } + +class VisualBehaviorOphysProjectCache(object): + def __init__( self, fetch_api: Optional[Union[BehaviorProjectLimsApi, @@ -100,13 +108,28 @@ def __init__( manifest_ = manifest or "behavior_project_manifest.json" else: manifest_ = None - version_ = version or self.MANIFEST_VERSION - super().__init__(manifest=manifest_, version=version_, cache=cache) self.fetch_api = fetch_api + self.cache = None + + if not isinstance(self.fetch_api, BehaviorProjectCloudApi): + if cache: + self.cache = VBOLimsCache(manifest=manifest_, + version=version, + cache=cache) self.fetch_tries = fetch_tries self.logger = logging.getLogger(self.__class__.__name__) + @property + def manifest(self): + if self.cache is None: + api_name = type(self.fetch_api).__name__ + raise NotImplementedError(f"A {type(self).__name__} " + f"based on {api_name} " + "does not have an accessible manifest " + "property") + return self.cache.manifest + @classmethod def from_s3_cache(cls, cache_dir: Union[str, Path], bucket_name: str = "visual-behavior-ophys-data", @@ -135,7 +158,8 @@ def from_s3_cache(cls, cache_dir: Union[str, Path], """ fetch_api = BehaviorProjectCloudApi.from_s3_cache( - cache_dir, bucket_name, project_name) + cache_dir, bucket_name, project_name, + ui_class_name=cls.__name__) return cls(fetch_api=fetch_api) @classmethod @@ -160,7 +184,8 @@ def from_local_cache(cls, cache_dir: Union[str, Path], """ fetch_api = BehaviorProjectCloudApi.from_local_cache( - cache_dir, project_name) + cache_dir, project_name, + ui_class_name=cls.__name__) return cls(fetch_api=fetch_api) @classmethod @@ -227,6 +252,116 @@ def from_lims(cls, manifest: Optional[Union[str, Path]] = None, return cls(fetch_api=fetch_api, manifest=manifest, version=version, cache=cache, fetch_tries=fetch_tries) + def _cache_not_implemented(self, method_name: str) -> None: + """ + Raise a NotImplementedError explaining that method_name + does not exist for VisualBehaviorOphysProjectCache + that does not have a fetch_api based on LIMS + """ + msg = f"Method {method_name} does not exist for this " + msg += f"{type(self).__name__}, which is based on " + msg += f"{type(self.fetch_api).__name__}" + raise NotImplementedError(msg) + + def construct_local_manifest(self) -> None: + """ + Construct the local file used to determine if two files are + duplicates of each other or not. Save it into the expected + place in the cache. (You will see a warning if the cache + thinks that you need to run this method). + """ + if not isinstance(self.fetch_api, BehaviorProjectCloudApi): + self._cache_not_implemented('construct_local_manifest') + self.fetch_api.cache.construct_local_manifest() + + def compare_manifests(self, + manifest_0_name: str, + manifest_1_name: str + ) -> str: + """ + Compare two manifests from this dataset. Return a dict + containing the list of metadata and data files that changed + between them + + Note: this assumes that manifest_0 predates manifest_1 + + Parameters + ---------- + manifest_0_name: str + + manifest_1_name: str + + Returns + ------- + str + A string summarizing all of the changes going from + manifest_0 to manifest_1 + """ + if not isinstance(self.fetch_api, BehaviorProjectCloudApi): + self._cache_not_implemented('compare_manifests') + return self.fetch_api.cache.compare_manifests(manifest_0_name, + manifest_1_name) + + def load_latest_manifest(self) -> None: + """ + Load the manifest corresponding to the most up to date + version of the dataset. + """ + if not isinstance(self.fetch_api, BehaviorProjectCloudApi): + self._cache_not_implemented('load_latest_manifest') + self.fetch_api.cache.load_latest_manifest() + + def latest_downloaded_manifest_file(self) -> str: + """ + Return the name of the most up to date data manifest + available on your local system. + """ + if not isinstance(self.fetch_api, BehaviorProjectCloudApi): + self._cache_not_implemented('latest_downloaded_manifest_file') + return self.fetch_api.cache.latest_downloaded_manifest_file + + def latest_manifest_file(self) -> str: + """ + Return the name of the most up to date data manifest + corresponding to this dataset, checking in the cloud + if this is a cloud-backed cache. + """ + if not isinstance(self.fetch_api, BehaviorProjectCloudApi): + self._cache_not_implemented('latest_manifest_file') + return self.fetch_api.cache.latest_manifest_file + + def load_manifest(self, manifest_name: str): + """ + Load a specific versioned manifest for this dataset. + + Parameters + ---------- + manifest_name: str + The name of the manifest to load. Must be an element in + self.manifest_file_names + """ + if not isinstance(self.fetch_api, BehaviorProjectCloudApi): + self._cache_not_implemented('load_manifest') + self.fetch_api.load_manifest(manifest_name) + + def list_manifest_file_names(self) -> list: + """ + Return a sorted list of the names of the manifest files + associated with this dataset. + """ + if not isinstance(self.fetch_api, BehaviorProjectCloudApi): + self._cache_not_implemented('list_manifest_file_names') + return self.fetch_api.cache.manifest_file_names + + def current_manifest(self) -> Union[None, str]: + """ + Return the name of the dataset manifest currently being + used by this cache. + """ + if not isinstance(self.fetch_api, BehaviorProjectCloudApi): + self._cache_not_implemented('current_manifest') + return self.fetch_api.cache.current_manifest + def get_ophys_session_table( self, suppress: Optional[List[str]] = None, @@ -252,8 +387,9 @@ def get_ophys_session_table( """ if isinstance(self.fetch_api, BehaviorProjectCloudApi): return self.fetch_api.get_ophys_session_table() - if self.cache: - path = self.get_cache_path(None, self.OPHYS_SESSIONS_KEY) + if self.cache is not None: + path = self.cache.get_cache_path(None, + self.cache.OPHYS_SESSIONS_KEY) ophys_sessions = one_file_call_caching( path, self.fetch_api.get_ophys_session_table, @@ -278,12 +414,6 @@ def get_ophys_session_table( return sessions.table if as_df else sessions - def add_manifest_paths(self, manifest_builder): - manifest_builder = super().add_manifest_paths(manifest_builder) - for key, config in self.MANIFEST_CONFIG.items(): - manifest_builder.add_path(key, **config) - return manifest_builder - def get_ophys_experiment_table( self, suppress: Optional[List[str]] = None, @@ -298,8 +428,9 @@ def get_ophys_experiment_table( """ if isinstance(self.fetch_api, BehaviorProjectCloudApi): return self.fetch_api.get_ophys_experiment_table() - if self.cache: - path = self.get_cache_path(None, self.OPHYS_EXPERIMENTS_KEY) + if self.cache is not None: + path = self.cache.get_cache_path(None, + self.cache.OPHYS_EXPERIMENTS_KEY) experiments = one_file_call_caching( path, self.fetch_api.get_ophys_experiment_table, @@ -336,8 +467,9 @@ def get_behavior_session_table( """ if isinstance(self.fetch_api, BehaviorProjectCloudApi): return self.fetch_api.get_behavior_session_table() - if self.cache: - path = self.get_cache_path(None, self.BEHAVIOR_SESSIONS_KEY) + if self.cache is not None: + path = self.cache.get_cache_path(None, + self.cache.BEHAVIOR_SESSIONS_KEY) sessions = one_file_call_caching( path, self.fetch_api.get_behavior_session_table, diff --git a/allensdk/brain_observatory/behavior/behavior_project_cache/project_apis/data_io/behavior_project_cloud_api.py b/allensdk/brain_observatory/behavior/behavior_project_cache/project_apis/data_io/behavior_project_cloud_api.py index 098fd3ec3..843f8e16b 100644 --- a/allensdk/brain_observatory/behavior/behavior_project_cache/project_apis/data_io/behavior_project_cloud_api.py +++ b/allensdk/brain_observatory/behavior/behavior_project_cache/project_apis/data_io/behavior_project_cloud_api.py @@ -73,31 +73,50 @@ class BehaviorProjectCloudApi(BehaviorProjectBase): def __init__(self, cache: Union[S3CloudCache, LocalCache], skip_version_check: bool = False, local: bool = False): + + self.cache = cache + self.skip_version_check = skip_version_check + self._local = local + self.load_manifest() + + def load_manifest(self, manifest_name: Optional[str] = None): + """ + Load the specified manifest file into the CloudCache + + Parameters + ---------- + manifest_name: Optional[str] + Name of manifest file to load. If None, load latest + (default: None) + """ + if manifest_name is None: + self.cache.load_latest_manifest() + else: + self.cache.load_manifest(manifest_name) + expected_metadata = set(["behavior_session_table", "ophys_session_table", "ophys_experiment_table"]) - self.cache = cache - if cache._manifest.metadata_file_names is None: + if self.cache._manifest.metadata_file_names is None: raise RuntimeError("S3CloudCache object has no metadata " "file names. BehaviorProjectCloudApi " "expects a S3CloudCache passed which " "has already run load_manifest()") - cache_metadata = set(cache._manifest.metadata_file_names) + cache_metadata = set(self.cache._manifest.metadata_file_names) if cache_metadata != expected_metadata: raise RuntimeError("expected S3CloudCache object to have " f"metadata file names: {expected_metadata} " f"but it has {cache_metadata}") - if not skip_version_check: - data_sdk_version = [i for i in cache._manifest._data_pipeline + if not self.skip_version_check: + data_sdk_version = [i for i in self.cache._manifest._data_pipeline if i['name'] == "AllenSDK"][0]["version"] - version_check(cache._manifest.version, data_sdk_version) + version_check(self.cache._manifest.version, data_sdk_version) # version_check(self.cache._manifest._data_pipeline) self.logger = logging.getLogger("BehaviorProjectCloudApi") - self._local = local self._get_ophys_session_table() self._get_behavior_session_table() self._get_ophys_experiment_table() @@ -105,7 +124,8 @@ def __init__(self, cache: Union[S3CloudCache, LocalCache], @staticmethod def from_s3_cache(cache_dir: Union[str, Path], bucket_name: str, - project_name: str) -> "BehaviorProjectCloudApi": + project_name: str, + ui_class_name: str) -> "BehaviorProjectCloudApi": """instantiates this object with a connection to an s3 bucket and/or a local cache related to that bucket. @@ -123,18 +143,25 @@ def from_s3_cache(cache_dir: Union[str, Path], project name is the first part of the prefix of the release data objects. I.e. s3://// + ui_class_name: str + Name of user interface class (used to populate error messages) + Returns ------- BehaviorProjectCloudApi instance """ - cache = S3CloudCache(cache_dir, bucket_name, project_name) + cache = S3CloudCache(cache_dir, + bucket_name, + project_name, + ui_class_name=ui_class_name) cache.load_latest_manifest() return BehaviorProjectCloudApi(cache) @staticmethod def from_local_cache(cache_dir: Union[str, Path], - project_name: str) -> "BehaviorProjectCloudApi": + project_name: str, + ui_class_name: str) -> "BehaviorProjectCloudApi": """instantiates this object with a local cache. Parameters @@ -147,12 +174,17 @@ def from_local_cache(cache_dir: Union[str, Path], project name is the first part of the prefix of the release data objects. I.e. s3://// + ui_class_name: str + Name of user interface class (used to populate error messages) + Returns ------- BehaviorProjectCloudApi instance """ - cache = LocalCache(cache_dir, project_name) + cache = LocalCache(cache_dir, + project_name, + ui_class_name=ui_class_name) cache.load_latest_manifest() return BehaviorProjectCloudApi(cache, local=True) diff --git a/allensdk/test/api/cloud_cache/test_cache.py b/allensdk/test/api/cloud_cache/test_cache.py index 9344bfbe0..af1839152 100644 --- a/allensdk/test/api/cloud_cache/test_cache.py +++ b/allensdk/test/api/cloud_cache/test_cache.py @@ -115,11 +115,13 @@ def test_loading_manifest(tmpdir): Body=bytes(json.dumps(manifest_2), 'utf-8')) cache = S3CloudCache(pathlib.Path(tmpdir), test_bucket_name, 'proj') + assert cache.current_manifest is None cache.load_manifest('manifest_v1.0.0.json') assert cache._manifest._data == manifest_1 assert cache.version == '1' assert cache.file_id_column == 'file_id' assert cache.metadata_file_names == ['a.csv', 'b.csv'] + assert cache.current_manifest == 'manifest_v1.0.0.json' cache.load_manifest('manifest_v2.0.0.json') assert cache._manifest._data == manifest_2 @@ -674,7 +676,7 @@ def test_outdated_manifest_warning(tmpdir, example_datasets_with_metadata): if w._category_name == m_warn_type: msg = str(w.message) assert 'is not the most up to date' in msg - assert 'self.compare_manifests' in msg + assert 'S3CloudCache.compare_manifests' in msg assert 'load_latest_manifest' in msg ct += 1 assert ct > 0 @@ -742,4 +744,5 @@ def test_latest_manifest_warning(tmpdir, example_datasets_with_metadata): assert 'project-x_manifest_v4.0.0.json' in msg assert 'project-x_manifest_v15.0.0.json' in msg assert 'It is possible that some data files' in msg - assert "self.load_manifest('project-x_manifest_v4.0.0.json')" in msg + cmd = "S3CloudCache.load_manifest('project-x_manifest_v4.0.0.json')" + assert cmd in msg diff --git a/allensdk/test/brain_observatory/behavior/behavior_project_cache/__init__.py b/allensdk/test/brain_observatory/behavior/behavior_project_cache/__init__.py new file mode 100644 index 000000000..ea30561d8 --- /dev/null +++ b/allensdk/test/brain_observatory/behavior/behavior_project_cache/__init__.py @@ -0,0 +1 @@ +#empty diff --git a/allensdk/test/brain_observatory/behavior/behavior_project_cache/conftest.py b/allensdk/test/brain_observatory/behavior/behavior_project_cache/conftest.py new file mode 100644 index 000000000..5feae8df5 --- /dev/null +++ b/allensdk/test/brain_observatory/behavior/behavior_project_cache/conftest.py @@ -0,0 +1,193 @@ +import pytest +import pandas as pd +import io + + +@pytest.fixture +def s3_cloud_cache_data(): + + all_versions = {} + all_versions['data'] = {} + all_versions['metadata'] = {} + + version = '0.1.0' + data = {} + metadata = {} + + data['ophys_file_1.nwb'] = {'file_id': 1, + 'data': b'abcde'} + + data['ophys_file_2.nwb'] = {'file_id': 2, + 'data': b'fghijk'} + + data['behavior_file_3.nwb'] = {'file_id': 3, + 'data': b'12345'} + + data['behavior_file_4.nwb'] = {'file_id': 4, + 'data': b'67890'} + + o_session = [{'ophys_session_id': 111, + 'file_id': 1}, + {'ophys_session_id': 222, + 'file_id': 2}] + + o_session = pd.DataFrame(o_session) + buff = io.StringIO() + o_session.to_csv(buff, index=False) + buff.seek(0) + + metadata['ophys_session_table'] = bytes(buff.read(), 'utf-8') + + b_session = [{'behavior_session_id': 333, + 'file_id': 3, + 'species': 'mouse'}, + {'behavior_session_id': 444, + 'file_id': 4, + 'species': 'mouse'}] + b_session = pd.DataFrame(b_session) + buff = io.StringIO() + b_session.to_csv(buff, index=False) + buff.seek(0) + + metadata['behavior_session_table'] = bytes(buff.read(), 'utf-8') + + o_session = [{'ophys_experiment_id': 5111, + 'file_id': 1}, + {'ophys_experiment_id': 5222, + 'file_id': 2}] + + o_session = pd.DataFrame(o_session) + buff = io.StringIO() + o_session.to_csv(buff, index=False) + buff.seek(0) + + metadata['ophys_experiment_table'] = bytes(buff.read(), 'utf-8') + + all_versions['data'][version] = data + all_versions['metadata'][version] = metadata + + version = '0.2.0' + data = {} + metadata = {} + + data['ophys_file_1.nwb'] = {'file_id': 1, + 'data': b'lmnopqrs'} + + data['ophys_file_2.nwb'] = {'file_id': 2, + 'data': b'fghijk'} + + data['behavior_file_3.nwb'] = {'file_id': 3, + 'data': b'12345'} + + data['behavior_file_4.nwb'] = {'file_id': 4, + 'data': b'67890'} + + data['ophys_file_5.nwb'] = {'file_id': 5, + 'data': b'98765'} + + + o_session = [{'ophys_session_id': 222, + 'file_id': 1}, + {'ophys_session_id': 333, + 'file_id': 2}] + + o_session = pd.DataFrame(o_session) + buff = io.StringIO() + o_session.to_csv(buff, index=False) + buff.seek(0) + + metadata['ophys_session_table'] = bytes(buff.read(), 'utf-8') + + b_session = [{'behavior_session_id': 777, + 'file_id': 3, + 'species': 'mouse'}, + {'behavior_session_id': 888, + 'file_id': 4, + 'species': 'mouse'}] + b_session = pd.DataFrame(b_session) + buff = io.StringIO() + b_session.to_csv(buff, index=False) + buff.seek(0) + + metadata['behavior_session_table'] = bytes(buff.read(), 'utf-8') + + o_session = [{'ophys_experiment_id': 5444, + 'file_id': 1}, + {'ophys_experiment_id': 5666, + 'file_id': 2}, + {'ophys_experiment_id': 5777, + 'file_id': 5}] + + o_session = pd.DataFrame(o_session) + buff = io.StringIO() + o_session.to_csv(buff, index=False) + buff.seek(0) + + metadata['ophys_experiment_table'] = bytes(buff.read(), 'utf-8') + + all_versions['data'][version] = data + all_versions['metadata'][version] = metadata + + return all_versions + + +@pytest.fixture +def data_update(): + data = {} + metadata = {} + + data['ophys_file_1.nwb'] = {'file_id': 1, + 'data': b'11235'} + + data['ophys_file_2.nwb'] = {'file_id': 2, + 'data': b'8132134'} + + data['behavior_file_3.nwb'] = {'file_id': 3, + 'data': b'04916'} + + data['behavior_file_4.nwb'] = {'file_id': 4, + 'data': b'253649'} + + data['ophys_file_5.nwb'] = {'file_id': 5, + 'data': b'98765'} + + o_session = [{'ophys_session_id': 1110, + 'file_id': 1}, + {'ophys_session_id': 2220, + 'file_id': 2}] + + o_session = pd.DataFrame(o_session) + buff = io.StringIO() + o_session.to_csv(buff, index=False) + buff.seek(0) + + metadata['ophys_session_table'] = bytes(buff.read(), 'utf-8') + + b_session = [{'behavior_session_id': 3330, + 'file_id': 3, + 'species': 'mouse'}, + {'behavior_session_id': 4440, + 'file_id': 4, + 'species': 'mouse'}] + b_session = pd.DataFrame(b_session) + buff = io.StringIO() + b_session.to_csv(buff, index=False) + buff.seek(0) + + metadata['behavior_session_table'] = bytes(buff.read(), 'utf-8') + + o_session = [{'ophys_experiment_id': 6111, + 'file_id': 1}, + {'ophys_experiment_id': 6222, + 'file_id': 2}, + {'ophys_experiment_id': 63456, + 'file_id': 5}] + + o_session = pd.DataFrame(o_session) + buff = io.StringIO() + o_session.to_csv(buff, index=False) + buff.seek(0) + + metadata['ophys_experiment_table'] = bytes(buff.read(), 'utf-8') + + return {'data': data, 'metadata': metadata} diff --git a/allensdk/test/brain_observatory/behavior/behavior_project_cache/test_behavior_project_cache.py b/allensdk/test/brain_observatory/behavior/behavior_project_cache/test_behavior_project_cache.py index 242cb5571..91e530c4b 100644 --- a/allensdk/test/brain_observatory/behavior/behavior_project_cache/test_behavior_project_cache.py +++ b/allensdk/test/brain_observatory/behavior/behavior_project_cache/test_behavior_project_cache.py @@ -195,3 +195,47 @@ def test_get_ophys_session_table_by_experiment(TempdirBehaviorCache): index_column="ophys_experiment_id")[ ["ophys_session_id"]] pd.testing.assert_frame_equal(expected, actual) + + +@pytest.mark.parametrize("TempdirBehaviorCache", [True], indirect=True) +def test_cloud_manifest_errors(TempdirBehaviorCache): + """ + Test that methods which should not exist for BehaviorProjectCaches + that are not backed by CloudCaches raise NotImplementedError + """ + msg = 'Method {mname} does not exist for this ' + msg += 'VisualBehaviorOphysProjectCache, which is based on MockApi' + with pytest.raises(NotImplementedError, + match=msg.format(mname='construct_local_manifest')): + TempdirBehaviorCache.construct_local_manifest() + + with pytest.raises(NotImplementedError, + match=msg.format(mname='compare_manifests')): + TempdirBehaviorCache.compare_manifests('a', 'b') + + with pytest.raises(NotImplementedError, + match=msg.format(mname='load_latest_manifest')): + TempdirBehaviorCache.load_latest_manifest() + + this_msg = msg.format(mname='latest_downloaded_manifest_file') + with pytest.raises(NotImplementedError, + match=this_msg): + TempdirBehaviorCache.latest_downloaded_manifest_file() + + with pytest.raises(NotImplementedError, + match=msg.format(mname='latest_manifest_file')): + TempdirBehaviorCache.latest_manifest_file() + + with pytest.raises(NotImplementedError, + match=msg.format(mname='load_manifest')): + TempdirBehaviorCache.load_manifest('a') + + with pytest.raises(NotImplementedError, + match=msg.format(mname='current_manifest')): + TempdirBehaviorCache.current_manifest() + + + this_msg = msg.format(mname='list_manifest_file_names') + with pytest.raises(NotImplementedError, + match=this_msg): + TempdirBehaviorCache.list_manifest_file_names() diff --git a/allensdk/test/brain_observatory/behavior/behavior_project_cache/test_behavior_project_cloud_api.py b/allensdk/test/brain_observatory/behavior/behavior_project_cache/test_behavior_project_cloud_api.py index d28474d47..497e8c181 100644 --- a/allensdk/test/brain_observatory/behavior/behavior_project_cache/test_behavior_project_cloud_api.py +++ b/allensdk/test/brain_observatory/behavior/behavior_project_cache/test_behavior_project_cloud_api.py @@ -51,6 +51,9 @@ def data_path(self, file_id): 'exists': True } + def load_latest_manifest(self): + return None + @pytest.fixture def mock_cache(request, tmpdir): diff --git a/allensdk/test/brain_observatory/behavior/behavior_project_cache/test_from_s3.py b/allensdk/test/brain_observatory/behavior/behavior_project_cache/test_from_s3.py new file mode 100644 index 000000000..4e24e79bc --- /dev/null +++ b/allensdk/test/brain_observatory/behavior/behavior_project_cache/test_from_s3.py @@ -0,0 +1,290 @@ +import pytest +from .utils import create_bucket, load_dataset +import boto3 +from moto import mock_s3 +import pathlib +import re +import json + +from allensdk.api.cloud_cache.cloud_cache import MissingLocalManifestWarning +from allensdk.api.cloud_cache.cloud_cache import OutdatedManifestWarning +from allensdk.brain_observatory.behavior.behavior_project_cache.behavior_project_cache import VisualBehaviorOphysProjectCache + + +@mock_s3 +def test_manifest_methods(tmpdir, s3_cloud_cache_data): + + cache_dir = pathlib.Path(tmpdir) / "test_manifest_list" + bucket_name = "vis-behav-test-bucket" + project_name = "vis-behav-test-proj" + create_bucket(bucket_name, + project_name, + s3_cloud_cache_data['data'], + s3_cloud_cache_data['metadata']) + + cache = VisualBehaviorOphysProjectCache.from_s3_cache(cache_dir, + bucket_name, + project_name) + + m_list = cache.list_manifest_file_names() + + v1_name = f'{project_name}_manifest_v0.1.0.json' + v2_name = f'{project_name}_manifest_v0.2.0.json' + + assert len(m_list) == 2 + assert v1_name in m_list + assert v2_name in m_list + + cache.load_manifest(v1_name) + + # because the BehaviorProjectCloudApi automatically + # loads the latest manifest, so the latest manifest + # will always be the latest_downloaded_manifest + assert cache.latest_downloaded_manifest_file() == v2_name + assert cache.latest_manifest_file() == v2_name + + change_msg = cache.compare_manifests(v1_name, v2_name) + + for mname in ('behavior_session_table', + 'ophys_session_table', + 'ophys_experiment_table'): + assert f'project_metadata/{mname} changed' in change_msg + + assert 'ophys_file_1.nwb changed' in change_msg + assert 'ophys_file_5.nwb created' in change_msg + assert 'ophys_file_2.nwb' not in change_msg + assert 'behavior_file_3.nwb' not in change_msg + assert 'behavior_file_4.nwb' not in change_msg + + +@mock_s3 +def test_local_cache_construction(tmpdir, s3_cloud_cache_data): + + cache_dir = pathlib.Path(tmpdir) / "test_construction" + bucket_name = "vis-behav-test-bucket" + project_name = "vis-behav-test-proj" + create_bucket(bucket_name, + project_name, + s3_cloud_cache_data['data'], + s3_cloud_cache_data['metadata']) + + cache = VisualBehaviorOphysProjectCache.from_s3_cache(cache_dir, + bucket_name, + project_name) + + v1_name = f'{project_name}_manifest_v0.1.0.json' + cache.load_manifest(v1_name) + cache.get_behavior_ophys_experiment(ophys_experiment_id=5111) + assert cache.fetch_api.cache._downloaded_data_path.is_file() + cache.fetch_api.cache._downloaded_data_path.unlink() + assert not cache.fetch_api.cache._downloaded_data_path.is_file() + del cache + + with pytest.warns(MissingLocalManifestWarning) as warnings: + cache = VisualBehaviorOphysProjectCache.from_s3_cache(cache_dir, + bucket_name, + project_name) + + cmd = 'VisualBehaviorOphysProjectCache.construct_local_manifest()' + assert cmd in f'{warnings[0].message}' + + # check that downloaded data is not in local manifest + # before running construction function (because + # VisualBehaviorOphysProjectCache automatically + # downloads metadata files, those will already + # be in there) + manifest_path = cache.fetch_api.cache._downloaded_data_path + with open(manifest_path, 'rb') as in_file: + local_manifest = json.load(in_file) + fnames = set([pathlib.Path(k).name for k in local_manifest]) + assert 'ophys_file_1.nwb' not in fnames + assert len(local_manifest) == 3 + + cache.construct_local_manifest() + assert cache.fetch_api.cache._downloaded_data_path.is_file() + + with open(manifest_path, 'rb') as in_file: + local_manifest = json.load(in_file) + fnames = set([pathlib.Path(k).name for k in local_manifest]) + assert 'ophys_file_1.nwb' in fnames + assert len(local_manifest) == 7 # 6 metadata files and 1 data file + + +@mock_s3 +def test_load_out_of_date_manifest(tmpdir, s3_cloud_cache_data): + """ + Test that VisualBehaviorOphysProjectCache can load a + manifest other than the latest and download files + from that manifest. + """ + cache_dir = pathlib.Path(tmpdir) / "test_linkage" + bucket_name = "vis-behav-test-bucket" + project_name = "vis-behav-test-proj" + create_bucket(bucket_name, + project_name, + s3_cloud_cache_data['data'], + s3_cloud_cache_data['metadata']) + + cache = VisualBehaviorOphysProjectCache.from_s3_cache(cache_dir, + bucket_name, + project_name) + + v1_name = f'{project_name}_manifest_v0.1.0.json' + cache.load_manifest(v1_name) + for sess_id in (333, 444): + cache.get_behavior_session(behavior_session_id=sess_id) + for exp_id in (5111, 5222): + cache.get_behavior_ophys_experiment(ophys_experiment_id=exp_id) + + v1_dir = cache_dir / f'{project_name}-0.1.0/data' + + # Check that all expected file were downloaded + dir_glob = v1_dir.glob('*') + file_names = set() + file_contents ={} + for p in dir_glob: + file_names.add(p.name) + with open(p, 'rb') as in_file: + data = in_file.read() + file_contents[p.name] = data + expected = {'ophys_file_1.nwb', 'ophys_file_2.nwb', + 'behavior_file_3.nwb', 'behavior_file_4.nwb'} + + assert file_names == expected + + expected = {} + expected['ophys_file_1.nwb'] = b'abcde' + expected['ophys_file_2.nwb'] = b'fghijk' + expected['behavior_file_3.nwb'] = b'12345' + expected['behavior_file_4.nwb'] = b'67890' + + assert file_contents == expected + + +@mock_s3 +@pytest.mark.parametrize("delete_cache", [True, False]) +def test_file_linkage(tmpdir, s3_cloud_cache_data, delete_cache): + """ + Test that symlinks are used where appropriate + + if delete_cache == True, will delete the local cache + file between loading v1 and v2 manifests, then run + construct_local_cache() to make sure that the symlinks + are still properly constructed + """ + cache_dir = pathlib.Path(tmpdir) / "test_linkage" + bucket_name = "vis-behav-test-bucket" + project_name = "vis-behav-test-proj" + create_bucket(bucket_name, + project_name, + s3_cloud_cache_data['data'], + s3_cloud_cache_data['metadata']) + + cache = VisualBehaviorOphysProjectCache.from_s3_cache(cache_dir, + bucket_name, + project_name) + + v1_name = f'{project_name}_manifest_v0.1.0.json' + v2_name = f'{project_name}_manifest_v0.2.0.json' + v1_dir = cache_dir / f'{project_name}-0.1.0/data' + v2_dir = cache_dir / f'{project_name}-0.2.0/data' + + assert cache.current_manifest() == v2_name + + cache.load_manifest(v1_name) + assert cache.current_manifest() == v1_name + for sess_id in (333, 444): + cache.get_behavior_session(behavior_session_id=sess_id) + for exp_id in (5111, 5222): + cache.get_behavior_ophys_experiment(ophys_experiment_id=exp_id) + + v1_glob = v1_dir.glob('*') + v1_paths = {} + for p in v1_glob: + v1_paths[p.name] = p + + if delete_cache: + local_cache = cache.fetch_api.cache._downloaded_data_path + assert local_cache.is_file() + local_cache.unlink() + assert not local_cache.is_file() + del cache + + cache = VisualBehaviorOphysProjectCache.from_s3_cache(cache_dir, + bucket_name, + project_name) + cache.construct_local_manifest() + + cache.load_manifest(v2_name) + assert cache.current_manifest() == v2_name + for sess_id in (777, 888): + cache.get_behavior_session(behavior_session_id=sess_id) + for exp_id in (5444, 5666, 5777): + cache.get_behavior_ophys_experiment(ophys_experiment_id=exp_id) + + v2_glob = v2_dir.glob('*') + v2_paths = {} + for p in v2_glob: + v2_paths[p.name] = p + + # check symlinks + for name in ('ophys_file_2.nwb', + 'behavior_file_3.nwb', + 'behavior_file_4.nwb'): + + assert v2_paths[name].is_symlink() + assert v2_paths[name].resolve() == v1_paths[name].resolve() + assert v2_paths[name].absolute() != v1_paths[name].absolute() + + name = 'ophys_file_1.nwb' + assert not v2_paths[name].is_symlink() + assert not v2_paths[name].absolute() == v1_paths[name].absolute() + + assert 'ophys_file_5.nwb' in v2_paths + + +@mock_s3 +def test_when_data_updated(tmpdir, s3_cloud_cache_data, data_update): + """ + Test that when a cache is instantiated after an update has + been loaded to the dataset, the correct warning is emitted + """ + cache_dir = pathlib.Path(tmpdir) / "test_update" + bucket_name = "vis-behav-test-bucket" + project_name = "vis-behav-test-proj" + create_bucket(bucket_name, + project_name, + s3_cloud_cache_data['data'], + s3_cloud_cache_data['metadata']) + cache = VisualBehaviorOphysProjectCache.from_s3_cache(cache_dir, + bucket_name, + project_name) + + del cache + + client = boto3.client('s3', region_name='us-east-1') + + load_dataset(data_update['data'], + data_update['metadata'], + '0.3.0', + bucket_name, + project_name, + client) + + name3 = f'{project_name}_manifest_v0.3.0' + name2 = f'{project_name}_manifest_v0.2.0' + cmd = 'VisualBehaviorOphysProjectCache.load_manifest' + with pytest.warns(OutdatedManifestWarning, match=name3) as warnings: + cache = VisualBehaviorOphysProjectCache.from_s3_cache(cache_dir, + bucket_name, + project_name) + + checked_msg = False + for w in warnings.list: + if w._category_name == 'OutdatedManifestWarning': + msg = str(w.message) + assert name3 in msg + assert name2 in msg + assert cmd in msg + checked_msg = True + assert checked_msg diff --git a/allensdk/test/brain_observatory/behavior/behavior_project_cache/utils.py b/allensdk/test/brain_observatory/behavior/behavior_project_cache/utils.py new file mode 100644 index 000000000..e4d54adb3 --- /dev/null +++ b/allensdk/test/brain_observatory/behavior/behavior_project_cache/utils.py @@ -0,0 +1,154 @@ +from typing import Union, Optional +import boto3 +import json +import hashlib + + +def load_dataset(data_blobs: dict, + metadata_blobs: Union[dict, None], + manifest_version: str, + bucket_name: str, + project_name: str, + client: boto3.client) -> None: + """ + Load a test dataset into moto's mocked S3 + + Parameters + ---------- + data_blobs: dict + Maps filename to a dict + 'data': the bytes in the data file + 'file_id': the file_id of the data file + + metadata_blobs: Union[dict, None] + A dict mapping metadata filename to bytes in the file + + manifest_version: str + The version of the manifest (manifest will be + uploaded to moto3 as manifest_{manifest_version}.json) + + bucket_name: str + + project_name: str + + client: boto3.client + + Returns + ------- + None + Uploads the provided data, generates the manifest, + and uploads the manifest to moto3 + """ + + for fname in data_blobs: + client.put_object(Bucket=bucket_name, + Key=f'{project_name}/data/{fname}', + Body=data_blobs[fname]['data']) + + if metadata_blobs is not None: + for fname in metadata_blobs: + client.put_object(Bucket=bucket_name, + Key=f'{project_name}/project_metadata/{fname}', + Body=metadata_blobs[fname]) + + response = client.list_object_versions(Bucket=bucket_name) + fname_to_version = {} + for obj in response['Versions']: + if obj['IsLatest']: + fname = obj['Key'].split('/')[-1] + fname_to_version[fname] = obj['VersionId'] + + manifest = {} + manifest['manifest_version'] = manifest_version + manifest['project_name'] = project_name + manifest['metadata_file_id_column_name'] = 'file_id' + manifest['metadata_files'] = {} + manifest['data_pipeline'] = [{'name': 'AllenSDK', 'version': '1.1.1'}] + + data_file_dict = {} + url_root = f'http://{bucket_name}.s3.amazonaws.com/{project_name}/data' + for fname in data_blobs: + url = f'{url_root}/{fname}' + hasher = hashlib.blake2b() + hasher.update(data_blobs[fname]['data']) + checksum = hasher.hexdigest() + + data_file = {'url': url, + 'version_id': fname_to_version[fname], + 'file_hash': checksum} + + data_file_dict[data_blobs[fname]['file_id']] = data_file + + manifest['data_files'] = data_file_dict + + if metadata_blobs is not None: + url_root = f'http://{bucket_name}.s3.amazonaws.com/{project_name}/' + url_root += 'project_metadata' + + metadata_dict = {} + for fname in metadata_blobs: + url = f'{url_root}/{fname}' + hasher = hashlib.blake2b() + hasher.update(metadata_blobs[fname]) + metadata_dict[fname] = {'url': url, + 'file_hash': hasher.hexdigest(), + 'version_id': fname_to_version[fname]} + + manifest['metadata_files'] = metadata_dict + + manifest_k = f'{project_name}/manifests/' + manifest_k += f'{project_name}_manifest_v{manifest_version}.json' + client.put_object(Bucket=bucket_name, + Key=manifest_k, + Body=bytes(json.dumps(manifest), 'utf-8')) + + return None + + +def create_bucket(test_bucket_name: str, + project_name: str, + datasets: dict, + metadatasets: dict) -> None: + """ + Create a bucket and populate it with example datasets + + Parameters + ---------- + test_bucket_name: str + Name of the bucket + + project_name: str + Name of project + + datasets: dict + Keyed on version names; values are dicts of individual + data files to be loaded to the bucket + + metadatasets: dict + Keyed on version names; values are dicts of individual + metadata files to be loaded to the bucket (default: None) + """ + + conn = boto3.resource('s3', region_name='us-east-1') + conn.create_bucket(Bucket=test_bucket_name, ACL='public-read') + + # https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/s3.html#bucketversioning + bucket_versioning = conn.BucketVersioning(test_bucket_name) + bucket_versioning.enable() + + client = boto3.client('s3', region_name='us-east-1') + + # upload first dataset + for v in datasets.keys(): + if metadatasets is not None: + m = metadatasets[v] + else: + m = None + load_dataset(datasets[v], + m, + v, + test_bucket_name, + project_name, + client) + + return None From 117d9abf29cf0f2ff35f4192bae184ab90712cb5 Mon Sep 17 00:00:00 2001 From: danielsf Date: Wed, 5 May 2021 13:02:51 -0700 Subject: [PATCH 71/86] use context manager for local cache construction progress bar --- allensdk/api/cloud_cache/cloud_cache.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/allensdk/api/cloud_cache/cloud_cache.py b/allensdk/api/cloud_cache/cloud_cache.py index fd3699bd8..4c8e60d98 100644 --- a/allensdk/api/cloud_cache/cloud_cache.py +++ b/allensdk/api/cloud_cache/cloud_cache.py @@ -126,13 +126,13 @@ def construct_local_manifest(self) -> None: if 'json' not in file_name.name: files_to_hash.add(file_name.resolve()) - pbar = tqdm.tqdm(files_to_hash, - total=len(files_to_hash), - unit='(files hashed)') + with tqdm.tqdm(files_to_hash, + total=len(files_to_hash), + unit='(files hashed)') as pbar: - for local_path in pbar: - hsh = file_hash_from_path(local_path) - lookup[str(local_path.absolute())] = hsh + for local_path in pbar: + hsh = file_hash_from_path(local_path) + lookup[str(local_path.absolute())] = hsh with open(self._downloaded_data_path, 'w') as out_file: out_file.write(json.dumps(lookup, indent=2, sort_keys=True)) From 2842164c8922600165554756acd6ae00ccdd91c8 Mon Sep 17 00:00:00 2001 From: danielsf Date: Wed, 5 May 2021 13:38:57 -0700 Subject: [PATCH 72/86] pep8 changes in allensdk/test/.../behavior_project_cache/ pep8 changes to api/warehouse_cache/cache.py --- allensdk/api/warehouse_cache/cache.py | 99 ++++++++++++------- .../behavior_project_cache/__init__.py | 2 +- .../behavior_project_cache/conftest.py | 53 +++++----- .../test_behavior_project_cache.py | 1 - .../behavior_project_cache/test_from_s3.py | 13 +-- .../behavior/behavior_project_cache/utils.py | 2 +- 6 files changed, 96 insertions(+), 74 deletions(-) diff --git a/allensdk/api/warehouse_cache/cache.py b/allensdk/api/warehouse_cache/cache.py index 5f317b0f0..2c12f7089 100755 --- a/allensdk/api/warehouse_cache/cache.py +++ b/allensdk/api/warehouse_cache/cache.py @@ -51,7 +51,7 @@ def memoize(f): """ Creates an unbound cache of function calls and results. Note that arguments - of different types are not cached separately (so f(3.0) and f(3) are not + of different types are not cached separately (so f(3.0) and f(3) are not treated as distinct calls) Arguments to the cached function must be hashable. @@ -63,12 +63,15 @@ def memoize(f): cache = {} sentinel = object() # unique object for cache misses make_key = _make_key # efficient key building from function args - cache_get = cache.get - cache_len = cache.__len__ + cache_get = cache.get + cache_len = cache.__len__ @wraps(f) def wrapper(*args, **kwargs): - key = make_key(args, kwargs, typed=False) # Don't consider 3.0 and 3 different + + # Don't consider 3.0 and 3 different + key = make_key(args, kwargs, typed=False) + result = cache_get(key, sentinel) if result is not sentinel: return result @@ -158,14 +161,24 @@ def load_manifest(self, file_name, version=None): elif e.outdated is None: intro = "version did not match the expected version" + ref_url = "https://github.com/alleninstitute/allensdk/wiki" raise ManifestVersionError(("Your manifest file (%s) %s" + - " (its version is '%s', but version '%s' is expected). Please remove this file" + - " and it will be regenerated for you the next" + - " time you instantiate this class." + - " WARNING: There may be new data files available that replace the ones you already have downloaded." + - " Read the notes for this release for more details on what has changed" + - " (https://github.com/alleninstitute/allensdk/wiki).") % - (file_name, intro, e.found_version, e.version), + " (its version is '%s', but" + + " version '%s' is expected). " + + " Please remove this file" + + " and it will be regenerated for" + + " you the next time you" + + " instantiate this class." + + " WARNING: There may be new data" + + " files available that replace" + + " the ones you already have" + + " downloaded. Read the notes" + + " for this release for more" + + " details on what has changed" + + " (%s).") % + (file_name, intro, + e.found_version, e.version, + ref_url), e.version, e.found_version) self.manifest_path = file_name @@ -189,7 +202,6 @@ def build_manifest(self, file_name): manifest_builder.write_json_file(file_name) - def add_manifest_paths(self, manifest_builder): '''Add cache-class specific paths to the manifest. In derived classes, should call super. @@ -200,7 +212,6 @@ def add_manifest_paths(self, manifest_builder): manifest_builder.add_path(key, **config) return manifest_builder - def manifest_dataframe(self): '''Convenience method to view manifest as a pandas dataframe. ''' @@ -324,7 +335,8 @@ def cacher(fn, 'lazy' queries the server if no file exists, None generates the data and bypasses all caching behavior pre : function - df|json->df|json, takes one data argument and returns filtered version, None for pass-through + df|json->df|json, takes one data argument and returns + filtered version, None for pass-through post : function df|json->?, takes one data argument and returns Object reader : function, optional @@ -352,7 +364,8 @@ def cacher(fn, else: strategy = 'pass_through' - if not strategy in ['lazy', 'pass_through', 'file', 'create']: + if strategy not in ['lazy', 'pass_through', + 'file', 'create']: raise ValueError("Unknown query strategy: {}.".format(strategy)) if 'lazy' == strategy: @@ -362,7 +375,7 @@ def cacher(fn, strategy = 'create' if strategy == 'pass_through': - data = fn(*args, **kwargs) + data = fn(*args, **kwargs) elif strategy in ['create']: Manifest.safe_make_parent_dirs(path) @@ -384,7 +397,7 @@ def cacher(fn, try: data return data - except: + except Exception: pass return @@ -399,7 +412,7 @@ def csv_writer(pth, gen): with open(pth, 'w') as output: for row in gen: if first_row: - field_names = [ str(k) for k in row.keys() ] + field_names = [str(k) for k in row.keys()] csv_writer = csv.DictWriter(output, fieldnames=field_names, delimiter=',', @@ -412,16 +425,20 @@ def csv_writer(pth, gen): @staticmethod def cache_csv_json(): + + def reader(f): + return pd.read_csv(f, parse_dates=True).to_dict('records') + return { 'writer': Cache.csv_writer, - 'reader': lambda f: pd.read_csv(f, parse_dates=True).to_dict('records') + 'reader': reader } @staticmethod def cache_csv_dataframe(): return { 'writer': Cache.csv_writer, - 'reader' : lambda f: pd.read_csv(f, parse_dates=True) + 'reader': lambda f: pd.read_csv(f, parse_dates=True) } @staticmethod @@ -446,7 +463,7 @@ def cache_json_dataframe(): def cache_json(): return { 'writer': ju.write, - 'reader' : ju.read + 'reader': ju.read } @staticmethod @@ -461,22 +478,25 @@ def pathfinder(file_name_position, secondary_file_name_position=None, path_keyword=None): '''helper method to find path argument in legacy methods written - prior to the @cacheable decorator. Do not use for new @cacheable methods. + prior to the @cacheable decorator. Do not use for new + @cacheable methods. Parameters ---------- file_name_position : integer - zero indexed position in the decorated method args where file path may be found. + zero indexed position in the decorated method args + where file path may be found. secondary_file_name_position : integer - zero indexed position in the decorated method args where tha file path may be found. + zero indexed position in the decorated method args where + the file path may be found. path_keyword : string kwarg that may have the file path. Notes ----- - This method is only intended to provide backward-compatibility for some - methods that otherwise do not follow the path conventions of the @cacheable - decorator. + This method is only intended to provide backward-compatibility + for some methods that otherwise do not follow the path conventions + of the @cacheable decorator. ''' def pf(*args, **kwargs): file_name = None @@ -489,7 +509,7 @@ def pf(*args, **kwargs): if (file_name is None and secondary_file_name_position and - secondary_file_name_position < len(args)): + secondary_file_name_position < len(args)): # noqa E129 file_name = args[secondary_file_name_position] return file_name @@ -515,7 +535,8 @@ def wrap(self, fn, path, cache, save_as_json : boolean, optional True (default) will save data as json, False as csv return_dataframe : boolean, optional - True will cast the return value to a pandas dataframe, False (default) will not + True will cast the return value to a pandas dataframe, + False (default) will not index : string, optional column to use as the pandas index rename : list of string tuples, optional @@ -559,7 +580,8 @@ def wrap(self, fn, path, cache, data = pd.read_csv(path, parse_dates=True) else: raise ValueError( - 'save_as_json=False cannot be used with return_dataframe=False') + 'save_as_json=False cannot be used with ' + 'return_dataframe=False') return data @@ -584,7 +606,8 @@ def cacheable(strategy=None, 'lazy' creates the data and saves to file if no file exists, None queries the server and bypasses all caching behavior pre : function - df|json->df|json, takes one data argument and returns filtered version, None for pass-through + df|json->df|json, takes one data argument and returns + filtered version, None for pass-through post : function df|json->?, takes one data argument and returns Object reader : function, optional @@ -604,7 +627,7 @@ def cacheable(strategy=None, Column renaming happens after the file is reloaded for json ''' def decor(func): - decor.strategy=strategy + decor.strategy = strategy decor.pre = pre decor.writer = writer decor.reader = reader @@ -614,23 +637,23 @@ def decor(func): @functools.wraps(func) def w(*args, **kwargs): - if decor.pathfinder and not 'pathfinder' in kwargs: + if decor.pathfinder and 'pathfinder' not in kwargs: pathfinder = decor.pathfinder else: pathfinder = kwargs.pop('pathfinder', None) - if pathfinder and not 'path' in kwargs: + if pathfinder and 'path' not in kwargs: found_path = pathfinder(*args, **kwargs) if found_path: kwargs['path'] = found_path - if decor.strategy and not 'strategy' in kwargs: + if decor.strategy and 'strategy' not in kwargs: kwargs['strategy'] = decor.strategy - if decor.pre and not 'pre' in kwargs: + if decor.pre and 'pre' not in kwargs: kwargs['pre'] = decor.pre - if decor.writer and not 'writer' in kwargs: + if decor.writer and 'writer' not in kwargs: kwargs['writer'] = decor.writer - if decor.reader and not 'reader' in kwargs: + if decor.reader and 'reader' not in kwargs: kwargs['reader'] = decor.reader if decor.post and not 'post in kwargs': kwargs['post'] = decor.post diff --git a/allensdk/test/brain_observatory/behavior/behavior_project_cache/__init__.py b/allensdk/test/brain_observatory/behavior/behavior_project_cache/__init__.py index ea30561d8..1bb8bf6d7 100644 --- a/allensdk/test/brain_observatory/behavior/behavior_project_cache/__init__.py +++ b/allensdk/test/brain_observatory/behavior/behavior_project_cache/__init__.py @@ -1 +1 @@ -#empty +# empty diff --git a/allensdk/test/brain_observatory/behavior/behavior_project_cache/conftest.py b/allensdk/test/brain_observatory/behavior/behavior_project_cache/conftest.py index 5feae8df5..d10a4ac34 100644 --- a/allensdk/test/brain_observatory/behavior/behavior_project_cache/conftest.py +++ b/allensdk/test/brain_observatory/behavior/behavior_project_cache/conftest.py @@ -23,13 +23,13 @@ def s3_cloud_cache_data(): data['behavior_file_3.nwb'] = {'file_id': 3, 'data': b'12345'} - data['behavior_file_4.nwb'] = {'file_id': 4, - 'data': b'67890'} + data['behavior_file_4.nwb'] = {'file_id': 4, + 'data': b'67890'} o_session = [{'ophys_session_id': 111, 'file_id': 1}, - {'ophys_session_id': 222, - 'file_id': 2}] + {'ophys_session_id': 222, + 'file_id': 2}] o_session = pd.DataFrame(o_session) buff = io.StringIO() @@ -53,8 +53,8 @@ def s3_cloud_cache_data(): o_session = [{'ophys_experiment_id': 5111, 'file_id': 1}, - {'ophys_experiment_id': 5222, - 'file_id': 2}] + {'ophys_experiment_id': 5222, + 'file_id': 2}] o_session = pd.DataFrame(o_session) buff = io.StringIO() @@ -79,17 +79,16 @@ def s3_cloud_cache_data(): data['behavior_file_3.nwb'] = {'file_id': 3, 'data': b'12345'} - data['behavior_file_4.nwb'] = {'file_id': 4, - 'data': b'67890'} - - data['ophys_file_5.nwb'] = {'file_id': 5, - 'data': b'98765'} + data['behavior_file_4.nwb'] = {'file_id': 4, + 'data': b'67890'} + data['ophys_file_5.nwb'] = {'file_id': 5, + 'data': b'98765'} o_session = [{'ophys_session_id': 222, 'file_id': 1}, - {'ophys_session_id': 333, - 'file_id': 2}] + {'ophys_session_id': 333, + 'file_id': 2}] o_session = pd.DataFrame(o_session) buff = io.StringIO() @@ -113,10 +112,10 @@ def s3_cloud_cache_data(): o_session = [{'ophys_experiment_id': 5444, 'file_id': 1}, - {'ophys_experiment_id': 5666, - 'file_id': 2}, - {'ophys_experiment_id': 5777, - 'file_id': 5}] + {'ophys_experiment_id': 5666, + 'file_id': 2}, + {'ophys_experiment_id': 5777, + 'file_id': 5}] o_session = pd.DataFrame(o_session) buff = io.StringIO() @@ -145,16 +144,16 @@ def data_update(): data['behavior_file_3.nwb'] = {'file_id': 3, 'data': b'04916'} - data['behavior_file_4.nwb'] = {'file_id': 4, - 'data': b'253649'} + data['behavior_file_4.nwb'] = {'file_id': 4, + 'data': b'253649'} - data['ophys_file_5.nwb'] = {'file_id': 5, - 'data': b'98765'} + data['ophys_file_5.nwb'] = {'file_id': 5, + 'data': b'98765'} o_session = [{'ophys_session_id': 1110, 'file_id': 1}, - {'ophys_session_id': 2220, - 'file_id': 2}] + {'ophys_session_id': 2220, + 'file_id': 2}] o_session = pd.DataFrame(o_session) buff = io.StringIO() @@ -178,10 +177,10 @@ def data_update(): o_session = [{'ophys_experiment_id': 6111, 'file_id': 1}, - {'ophys_experiment_id': 6222, - 'file_id': 2}, - {'ophys_experiment_id': 63456, - 'file_id': 5}] + {'ophys_experiment_id': 6222, + 'file_id': 2}, + {'ophys_experiment_id': 63456, + 'file_id': 5}] o_session = pd.DataFrame(o_session) buff = io.StringIO() diff --git a/allensdk/test/brain_observatory/behavior/behavior_project_cache/test_behavior_project_cache.py b/allensdk/test/brain_observatory/behavior/behavior_project_cache/test_behavior_project_cache.py index 91e530c4b..52128d048 100644 --- a/allensdk/test/brain_observatory/behavior/behavior_project_cache/test_behavior_project_cache.py +++ b/allensdk/test/brain_observatory/behavior/behavior_project_cache/test_behavior_project_cache.py @@ -234,7 +234,6 @@ def test_cloud_manifest_errors(TempdirBehaviorCache): match=msg.format(mname='current_manifest')): TempdirBehaviorCache.current_manifest() - this_msg = msg.format(mname='list_manifest_file_names') with pytest.raises(NotImplementedError, match=this_msg): diff --git a/allensdk/test/brain_observatory/behavior/behavior_project_cache/test_from_s3.py b/allensdk/test/brain_observatory/behavior/behavior_project_cache/test_from_s3.py index 4e24e79bc..663d87776 100644 --- a/allensdk/test/brain_observatory/behavior/behavior_project_cache/test_from_s3.py +++ b/allensdk/test/brain_observatory/behavior/behavior_project_cache/test_from_s3.py @@ -3,12 +3,13 @@ import boto3 from moto import mock_s3 import pathlib -import re import json from allensdk.api.cloud_cache.cloud_cache import MissingLocalManifestWarning from allensdk.api.cloud_cache.cloud_cache import OutdatedManifestWarning -from allensdk.brain_observatory.behavior.behavior_project_cache.behavior_project_cache import VisualBehaviorOphysProjectCache +from allensdk.brain_observatory.\ + behavior.behavior_project_cache.behavior_project_cache \ + import VisualBehaviorOphysProjectCache @mock_s3 @@ -141,7 +142,7 @@ def test_load_out_of_date_manifest(tmpdir, s3_cloud_cache_data): # Check that all expected file were downloaded dir_glob = v1_dir.glob('*') file_names = set() - file_contents ={} + file_contents = {} for p in dir_glob: file_names.add(p.name) with open(p, 'rb') as in_file: @@ -275,9 +276,9 @@ def test_when_data_updated(tmpdir, s3_cloud_cache_data, data_update): name2 = f'{project_name}_manifest_v0.2.0' cmd = 'VisualBehaviorOphysProjectCache.load_manifest' with pytest.warns(OutdatedManifestWarning, match=name3) as warnings: - cache = VisualBehaviorOphysProjectCache.from_s3_cache(cache_dir, - bucket_name, - project_name) + _ = VisualBehaviorOphysProjectCache.from_s3_cache(cache_dir, + bucket_name, + project_name) checked_msg = False for w in warnings.list: diff --git a/allensdk/test/brain_observatory/behavior/behavior_project_cache/utils.py b/allensdk/test/brain_observatory/behavior/behavior_project_cache/utils.py index e4d54adb3..0a51ff20a 100644 --- a/allensdk/test/brain_observatory/behavior/behavior_project_cache/utils.py +++ b/allensdk/test/brain_observatory/behavior/behavior_project_cache/utils.py @@ -1,4 +1,4 @@ -from typing import Union, Optional +from typing import Union import boto3 import json import hashlib From 5bfa31e5fe489756d615505ae77b270dc5ef4343 Mon Sep 17 00:00:00 2001 From: danielsf Date: Wed, 5 May 2021 16:34:28 -0700 Subject: [PATCH 73/86] defend against NWB files at the root of symlinks getting moved --- allensdk/api/cloud_cache/cloud_cache.py | 9 +- .../api/cloud_cache/test_smart_download.py | 143 ++++++++++++++++++ 2 files changed, 151 insertions(+), 1 deletion(-) diff --git a/allensdk/api/cloud_cache/cloud_cache.py b/allensdk/api/cloud_cache/cloud_cache.py index 4c8e60d98..f758c807d 100644 --- a/allensdk/api/cloud_cache/cloud_cache.py +++ b/allensdk/api/cloud_cache/cloud_cache.py @@ -456,7 +456,14 @@ def _check_for_identical_copy(self, for abs_path in available_files: if available_files[abs_path] == file_attributes.file_hash: matched_path = pathlib.Path(abs_path) - break + + # check that the file still exists, + # in case someone accidentally deleted + # the file at the root of a symlink + if matched_path.is_file(): + break + else: + matched_path = None if matched_path is None: return False diff --git a/allensdk/test/api/cloud_cache/test_smart_download.py b/allensdk/test/api/cloud_cache/test_smart_download.py index 37d50ebb7..da56bb53e 100644 --- a/allensdk/test/api/cloud_cache/test_smart_download.py +++ b/allensdk/test/api/cloud_cache/test_smart_download.py @@ -169,6 +169,149 @@ def test_on_corrupted_files(tmpdir, example_datasets): assert other_path.absolute() != redownloaded_path.absolute() +@mock_s3 +def test_on_removed_files(tmpdir, example_datasets): + """ + Test that the CloudCache re-downloads files when the + the files at the root of the symlinks have been removed + """ + bucket_name = 'corruption_bucket' + create_bucket(bucket_name, + example_datasets) + + cache_dir = pathlib.Path(tmpdir) / 'cache' + cache = S3CloudCache(cache_dir, bucket_name, 'project-x') + + version_list = ('1.0.0', '2.0.0', '3.0.0') + file_id_list = ('1', '2', '3') + + for version in version_list: + cache.load_manifest(f'project-x_manifest_v{version}.json') + for file_id in file_id_list: + cache.download_data(file_id) + + # make sure that all files exist + for version in version_list: + cache.load_manifest(f'project-x_manifest_v{version}.json') + for file_id in file_id_list: + attr = cache.data_path(file_id) + assert attr['exists'] + + hasher = hashlib.blake2b() + hasher.update(b'4567890') + true_hash = hasher.hexdigest() + + p1 = cache_dir / 'project-x-1.0.0' / 'data' / 'f2.txt' + p2 = cache_dir / 'project-x-2.0.0' / 'data' / 'f2.txt' + + # note that f2.txt is identical between v 1.0.0 and 2.0.0 + assert p1.is_file() + assert not p1.is_symlink() + assert p2.is_symlink() + assert p1.resolve() == p2.resolve() + + # remove p1 + p1.unlink() + assert not p1.exists() + assert not p1.is_file() + assert not p2.is_file() + assert p2.is_symlink() + + # make sure that the file which has been moved is now + # marked as not existing + cache.load_manifest('project-x_manifest_v1.0.0.json') + test_path = cache.data_path('2') + assert not test_path['exists'] + + cache.load_manifest('project-x_manifest_v2.0.0.json') + test_path = cache.data_path('2') + assert not test_path['exists'] + + # now, re-download the data by way of manifest 2 + # and verify that the symlink relationship is + # re-established + p2 = cache.download_data('2') + assert p2.is_file() + assert p2.is_symlink() # because the symlink was not removed + + cache.load_manifest('project-x_manifest_v1.0.0.json') + p1 = cache.download_data('2') + + assert p1.is_file() + assert not p1.is_symlink() + assert p1.resolve() == p2.resolve() + assert p1.absolute() != p2.absolute() + + hasher = hashlib.blake2b() + with open(p2, 'rb') as in_file: + hasher.update(in_file.read()) + assert hasher.hexdigest() == true_hash + + +@mock_s3 +def test_on_removed_symlinks(tmpdir, example_datasets): + """ + Test that the CloudCache re-downloads files when the + the symlinks have been removed + """ + bucket_name = 'corruption_bucket' + create_bucket(bucket_name, + example_datasets) + + cache_dir = pathlib.Path(tmpdir) / 'cache' + cache = S3CloudCache(cache_dir, bucket_name, 'project-x') + + version_list = ('1.0.0', '2.0.0', '3.0.0') + file_id_list = ('1', '2', '3') + + for version in version_list: + cache.load_manifest(f'project-x_manifest_v{version}.json') + for file_id in file_id_list: + cache.download_data(file_id) + + # make sure that all files exist + for version in version_list: + cache.load_manifest(f'project-x_manifest_v{version}.json') + for file_id in file_id_list: + attr = cache.data_path(file_id) + assert attr['exists'] + + hasher = hashlib.blake2b() + hasher.update(b'4567890') + true_hash = hasher.hexdigest() + + p1 = cache_dir / 'project-x-1.0.0' / 'data' / 'f2.txt' + p2 = cache_dir / 'project-x-2.0.0' / 'data' / 'f2.txt' + + # note that f2.txt is identical between v 1.0.0 and 2.0.0 + assert p1.is_file() + assert not p1.is_symlink() + assert p2.is_symlink() + assert p1.resolve() == p2.resolve() + + # remove symlink at p2 and show that the file + # still exists (and that the symlink gets restored + # once you ask for the file path) + p2.unlink() + assert not p2.exists() + assert not p2.is_symlink() + assert p1.is_file() + + cache.load_manifest('project-x_manifest_v2.0.0.json') + test_path = cache.data_path('2') + assert test_path['exists'] + p2 = pathlib.Path(test_path['local_path']) + assert p2.is_symlink() + assert p2.exists() + assert p1.absolute() != p2.absolute() + assert p1.resolve() == p2.resolve() + + hasher = hashlib.blake2b() + with open(p2, 'rb') as in_file: + hasher.update(in_file.read()) + assert hasher.hexdigest() == true_hash + + @mock_s3 def test_corrupted_download_manifest(tmpdir, example_datasets): """ From fd730d1e7d03edf068eda5298c503ced372d8f0c Mon Sep 17 00:00:00 2001 From: Dan Date: Thu, 6 May 2021 11:52:26 -0700 Subject: [PATCH 74/86] adds self-hosted test for python 3.7 --- .github/workflows/github-actions-ci.yml | 28 +++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/.github/workflows/github-actions-ci.yml b/.github/workflows/github-actions-ci.yml index afe5bf4b8..119fddff0 100644 --- a/.github/workflows/github-actions-ci.yml +++ b/.github/workflows/github-actions-ci.yml @@ -65,3 +65,31 @@ jobs: - name: Test run: | py.test --cov=allensdk -n 4 + + onprem: + name: python ${{ matrix.image }} on-prem test + runs-on: ["self-hosted"] + strategy: + matrix: + image: ["allensdk_local_py37:latest"] + steps: + - uses: actions/checkout@v2 + - name: run test in docker + run: | + docker run \ + --env-file /home/github_worker/env.list \ + --mount type=bind,source=$PWD,target=/home/ghworker,bind-propagation=rshared \ + --mount type=bind,source=/data/informatics/module_test_data/,target=/data/informatics/module_test_data/,bind-propagation=rshared,ro \ + --mount type=bind,source=/allen/,target=/allen/,bind-propagation=rshared,ro \ + ${{ matrix.image }} \ + /bin/bash -c "pip install -r requirements.txt; \ + pip install -r test_requirements.txt; \ + python -m pytest \ + --capture=no \ + --cov=allensdk \ + --cov-config coveragerc \ + --cov-report html \ + --junitxml=test-reports/test.xml \ + --boxed \ + --numprocesses 4 \ + --durations=0" From a4d6c8194963c357e19328769157c215fdfd4910 Mon Sep 17 00:00:00 2001 From: danielsf Date: Fri, 7 May 2021 14:35:33 -0700 Subject: [PATCH 75/86] add CloudCache.load_last_manifest which will load the manifest last used in this cache --- allensdk/api/cloud_cache/cloud_cache.py | 53 +++++++++++- allensdk/test/api/cloud_cache/test_cache.py | 96 ++++++++++++++++++++- 2 files changed, 145 insertions(+), 4 deletions(-) diff --git a/allensdk/api/cloud_cache/cloud_cache.py b/allensdk/api/cloud_cache/cloud_cache.py index f758c807d..3cbc02743 100644 --- a/allensdk/api/cloud_cache/cloud_cache.py +++ b/allensdk/api/cloud_cache/cloud_cache.py @@ -70,11 +70,16 @@ def __init__(self, cache_dir, project_name, ui_class_name=None): # was emitted self._manifest_last_warned_on = None + c_path = pathlib.Path(self._cache_dir) + + # self._manifest_last_used contains the name of the manifest + # last loaded from this cache dir (if applicable) + self._manifest_last_used = c_path / '_manifest_last_used.txt' + # self._downloaded_data_path is where we will keep a JSONized # dict mapping paths to downloaded files to their file_hashes; # this will be used when determining if a downloaded file # can instead be a symlink - c_path = pathlib.Path(self._cache_dir) self._downloaded_data_path = c_path / '_downloaded_data.json' # if the local manifest is missing but there are @@ -124,7 +129,8 @@ def construct_local_manifest(self) -> None: for file_name in file_iterator: if file_name.is_file(): if 'json' not in file_name.name: - files_to_hash.add(file_name.resolve()) + if file_name != self._manifest_last_used: + files_to_hash.add(file_name.resolve()) with tqdm.tqdm(files_to_hash, total=len(files_to_hash), @@ -226,6 +232,45 @@ def latest_downloaded_manifest_file(self) -> str: return '' return self._find_latest_file(self.list_all_downloaded_manifests()) + def load_last_manifest(self): + """ + If this Cache was used previously, load the last manifest + used in this cache. If this cache has never been used, load + the latest manifest. + """ + if not self._manifest_last_used.exists(): + self.load_latest_manifest() + return None + + with open(self._manifest_last_used, 'r') as in_file: + to_load = in_file.read() + + latest = self.latest_manifest_file + + if to_load not in self.manifest_file_names: + msg = 'The manifest version recorded as last used ' + msg += f'for this cache -- {to_load}-- ' + msg += 'is not a valid manifest for this dataset. ' + msg += f'Loading latest version -- {latest} -- ' + msg += 'instead.' + warnings.warn(msg, UserWarning) + self.load_latest_manifest() + return None + + if latest != to_load: + self._manifest_last_warned_on = self.latest_manifest_file + msg = f"You are loading {to_load}. A more up to date " + msg += f"version of the dataset -- {latest} -- exists " + msg += "online. To see the changes between the two " + msg += "versions of the dataset, run\n" + msg += f"{self.ui}.compare_manifests('{to_load}'," + msg += f" '{latest}')\n" + msg += "To load another version of the dataset, run\n" + msg += f"{self.ui}.load_manifest('{latest}')" + warnings.warn(msg, OutdatedManifestWarning) + self.load_manifest(to_load) + return None + def load_latest_manifest(self): latest_downloaded = self.latest_downloaded_manifest_file latest = self.latest_manifest_file @@ -374,6 +419,10 @@ def _load_manifest(self, manifest_name: str) -> Manifest: cache_dir=self._cache_dir, json_input=f ) + + with open(self._manifest_last_used, 'w') as out_file: + out_file.write(manifest_name) + return local_manifest def load_manifest(self, manifest_name: str): diff --git a/allensdk/test/api/cloud_cache/test_cache.py b/allensdk/test/api/cloud_cache/test_cache.py index af1839152..1638a8922 100644 --- a/allensdk/test/api/cloud_cache/test_cache.py +++ b/allensdk/test/api/cloud_cache/test_cache.py @@ -709,12 +709,15 @@ def test_list_all_downloaded(tmpdir, example_datasets_with_metadata): assert cache.list_all_downloaded_manifests() == [] cache.load_manifest('project-x_manifest_v5.0.0.json') + assert cache.current_manifest == 'project-x_manifest_v5.0.0.json' cache.load_manifest('project-x_manifest_v2.0.0.json') - cache.load_manifest('project-x_manifest_v2.0.0.json') + assert cache.current_manifest == 'project-x_manifest_v2.0.0.json' + cache.load_manifest('project-x_manifest_v3.0.0.json') + assert cache.current_manifest == 'project-x_manifest_v3.0.0.json' expected = {'project-x_manifest_v5.0.0.json', 'project-x_manifest_v2.0.0.json', - 'project-x_manifest_v2.0.0.json'} + 'project-x_manifest_v3.0.0.json'} downloaded = set(cache.list_all_downloaded_manifests()) assert downloaded == expected @@ -746,3 +749,92 @@ def test_latest_manifest_warning(tmpdir, example_datasets_with_metadata): assert 'It is possible that some data files' in msg cmd = "S3CloudCache.load_manifest('project-x_manifest_v4.0.0.json')" assert cmd in msg + + +@mock_s3 +def test_load_last_manifest(tmpdir, example_datasets_with_metadata): + """ + Test that load_last_manifest works + """ + bucket_name = 'load_lst_manifest_bucket' + metadatasets = example_datasets_with_metadata['metadata'] + create_bucket(bucket_name, + example_datasets_with_metadata['data'], + metadatasets=metadatasets) + + cache_dir = pathlib.Path(tmpdir) / 'load_last_cache' + cache = S3CloudCache(cache_dir, bucket_name, 'project-x') + + # check that load_last_manifest in a new cache loads the + # latest manifest without emitting a warning + with pytest.warns(None) as warnings: + cache.load_last_manifest() + ct = 0 + for w in warnings.list: + if w._category_name == 'OutdatedManifestWarning': + ct += 1 + assert ct == 0 + assert cache.current_manifest == 'project-x_manifest_v15.0.0.json' + + cache.load_manifest('project-x_manifest_v7.0.0.json') + + del cache + + # check that load_last_manifest on an old cache emits the + # expected warning and loads the correct manifest + cache = S3CloudCache(cache_dir, bucket_name, 'project-x') + with pytest.warns(OutdatedManifestWarning) as warnings: + cache.load_last_manifest() + for w in warnings.list: + if w._category_name == 'OutdatedManifestWarning': + msg = str(w.message) + expected = 'A more up to date version of the ' + expected += 'dataset -- project-x_manifest_v15.0.0.json ' + expected += '-- exists online' + assert expected in msg + + assert cache.current_manifest == 'project-x_manifest_v7.0.0.json' + cache.load_manifest('project-x_manifest_v4.0.0.json') + del cache + + # repeat the above test, making sure the correct manifest is + # loaded again + cache = S3CloudCache(cache_dir, bucket_name, 'project-x') + with pytest.warns(OutdatedManifestWarning) as warnings: + cache.load_last_manifest() + for w in warnings.list: + if w._category_name == 'OutdatedManifestWarning': + msg = str(w.message) + expected = 'A more up to date version of the ' + expected += 'dataset -- project-x_manifest_v15.0.0.json ' + expected += '-- exists online' + assert expected in msg + + assert cache.current_manifest == 'project-x_manifest_v4.0.0.json' + + +@mock_s3 +def test_corrupted_load_last_manifest(tmpdir, + example_datasets_with_metadata): + """ + Test that load_last_manifest works when the record of the last + manifest has been corrupted + """ + bucket_name = 'load_lst_manifest_bucket' + metadatasets = example_datasets_with_metadata['metadata'] + create_bucket(bucket_name, + example_datasets_with_metadata['data'], + metadatasets=metadatasets) + + cache_dir = pathlib.Path(tmpdir) / 'load_last_cache' + cache = S3CloudCache(cache_dir, bucket_name, 'project-x') + cache.load_manifest('project-x_manifest_v9.0.0.json') + fname = cache._manifest_last_used.resolve() + del cache + with open(fname, 'w') as out_file: + out_file.write('babababa') + cache = S3CloudCache(cache_dir, bucket_name, 'project-x') + expected = 'Loading latest version -- project-x_manifest_v15.0.0.json' + with pytest.warns(UserWarning, match=expected): + cache.load_last_manifest() + assert cache.current_manifest == 'project-x_manifest_v15.0.0.json' From af4272fb530460a9d5ea2f74ef937dd5fd20dc59 Mon Sep 17 00:00:00 2001 From: danielsf Date: Fri, 7 May 2021 15:27:36 -0700 Subject: [PATCH 76/86] remove unnecessary load_latest_manifest() calls BehaviorProjectCloudApi calls load_manifest in its constructor --- .../project_apis/data_io/behavior_project_cloud_api.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/allensdk/brain_observatory/behavior/behavior_project_cache/project_apis/data_io/behavior_project_cloud_api.py b/allensdk/brain_observatory/behavior/behavior_project_cache/project_apis/data_io/behavior_project_cloud_api.py index 843f8e16b..b014da913 100644 --- a/allensdk/brain_observatory/behavior/behavior_project_cache/project_apis/data_io/behavior_project_cloud_api.py +++ b/allensdk/brain_observatory/behavior/behavior_project_cache/project_apis/data_io/behavior_project_cloud_api.py @@ -155,7 +155,6 @@ def from_s3_cache(cache_dir: Union[str, Path], bucket_name, project_name, ui_class_name=ui_class_name) - cache.load_latest_manifest() return BehaviorProjectCloudApi(cache) @staticmethod @@ -185,7 +184,6 @@ def from_local_cache(cache_dir: Union[str, Path], cache = LocalCache(cache_dir, project_name, ui_class_name=ui_class_name) - cache.load_latest_manifest() return BehaviorProjectCloudApi(cache, local=True) def get_behavior_session( From e08bed1ecc4f6c15c8e509474fb9de25bf238fce Mon Sep 17 00:00:00 2001 From: danielsf Date: Fri, 7 May 2021 15:40:17 -0700 Subject: [PATCH 77/86] BehaviorProjectCloudApi uses load_last_manifest --- .../data_io/behavior_project_cloud_api.py | 2 +- .../test_behavior_project_cloud_api.py | 2 +- .../behavior_project_cache/test_from_s3.py | 32 +++++++++++++++++++ 3 files changed, 34 insertions(+), 2 deletions(-) diff --git a/allensdk/brain_observatory/behavior/behavior_project_cache/project_apis/data_io/behavior_project_cloud_api.py b/allensdk/brain_observatory/behavior/behavior_project_cache/project_apis/data_io/behavior_project_cloud_api.py index b014da913..716400fa9 100644 --- a/allensdk/brain_observatory/behavior/behavior_project_cache/project_apis/data_io/behavior_project_cloud_api.py +++ b/allensdk/brain_observatory/behavior/behavior_project_cache/project_apis/data_io/behavior_project_cloud_api.py @@ -90,7 +90,7 @@ def load_manifest(self, manifest_name: Optional[str] = None): (default: None) """ if manifest_name is None: - self.cache.load_latest_manifest() + self.cache.load_last_manifest() else: self.cache.load_manifest(manifest_name) diff --git a/allensdk/test/brain_observatory/behavior/behavior_project_cache/test_behavior_project_cloud_api.py b/allensdk/test/brain_observatory/behavior/behavior_project_cache/test_behavior_project_cloud_api.py index 497e8c181..c205719eb 100644 --- a/allensdk/test/brain_observatory/behavior/behavior_project_cache/test_behavior_project_cloud_api.py +++ b/allensdk/test/brain_observatory/behavior/behavior_project_cache/test_behavior_project_cloud_api.py @@ -51,7 +51,7 @@ def data_path(self, file_id): 'exists': True } - def load_latest_manifest(self): + def load_last_manifest(self): return None diff --git a/allensdk/test/brain_observatory/behavior/behavior_project_cache/test_from_s3.py b/allensdk/test/brain_observatory/behavior/behavior_project_cache/test_from_s3.py index 663d87776..de9854122 100644 --- a/allensdk/test/brain_observatory/behavior/behavior_project_cache/test_from_s3.py +++ b/allensdk/test/brain_observatory/behavior/behavior_project_cache/test_from_s3.py @@ -289,3 +289,35 @@ def test_when_data_updated(tmpdir, s3_cloud_cache_data, data_update): assert cmd in msg checked_msg = True assert checked_msg + + +@mock_s3 +def test_load_last(tmpdir, s3_cloud_cache_data, data_update): + """ + Test that, when a cache is instantiated over an old + cache_dir, it loads the most recently loaded manifest, + not the most up to date manifest + """ + cache_dir = pathlib.Path(tmpdir) / "test_update" + bucket_name = "vis-behav-test-bucket" + project_name = "vis-behav-test-proj" + create_bucket(bucket_name, + project_name, + s3_cloud_cache_data['data'], + s3_cloud_cache_data['metadata']) + cache = VisualBehaviorOphysProjectCache.from_s3_cache(cache_dir, + bucket_name, + project_name) + + assert cache.current_manifest() == f'{project_name}_manifest_v0.2.0.json' + cache.load_manifest(f'{project_name}_manifest_v0.1.0.json') + assert cache.current_manifest() == f'{project_name}_manifest_v0.1.0.json' + del cache + + msg = 'VisualBehaviorOphysProjectCache.compare_manifests' + with pytest.warns(OutdatedManifestWarning, match=msg): + cache = VisualBehaviorOphysProjectCache.from_s3_cache(cache_dir, + bucket_name, + project_name) + + assert cache.current_manifest() == f'{project_name}_manifest_v0.1.0.json' From 0eb1ff905a8f792485fa5cd2c7bb77f37c1eee02 Mon Sep 17 00:00:00 2001 From: danielsf Date: Fri, 7 May 2021 17:58:18 -0700 Subject: [PATCH 78/86] remove file hash from CloudCache._file_exists it was slowing down access to files that had already been downloaded --- allensdk/api/cloud_cache/cloud_cache.py | 13 ++++++---- allensdk/test/api/cloud_cache/test_cache.py | 24 +------------------ .../api/cloud_cache/test_smart_download.py | 10 ++++---- 3 files changed, 14 insertions(+), 33 deletions(-) diff --git a/allensdk/api/cloud_cache/cloud_cache.py b/allensdk/api/cloud_cache/cloud_cache.py index 3cbc02743..25d41b775 100644 --- a/allensdk/api/cloud_cache/cloud_cache.py +++ b/allensdk/api/cloud_cache/cloud_cache.py @@ -560,10 +560,7 @@ def _file_exists(self, file_attributes: CacheFileAttributes) -> bool: "exists, but is not a file;\n" "unsure how to proceed") - full_path = file_attributes.local_path.resolve() - test_checksum = file_hash_from_path(full_path) - if test_checksum == file_attributes.file_hash: - file_exists = True + file_exists = True if not file_exists: file_exists = self._check_for_identical_copy(file_attributes) @@ -1066,6 +1063,13 @@ def _download_file(self, file_attributes: CacheFileAttributes) -> bool: out_file.write(chunk) pbar.update(len(chunk)) + # Verify the hash of the downloaded file + full_path = file_attributes.local_path.resolve() + test_checksum = file_hash_from_path(full_path) + if test_checksum != file_attributes.file_hash: + file_attributes.local_path.exists() + file_attributes.local_path.unlink() + n_iter += 1 if n_iter > max_iter: pbar.close() @@ -1074,6 +1078,7 @@ def _download_file(self, file_attributes: CacheFileAttributes) -> bool: "In {max_iter} iterations") if pbar is not None: pbar.close() + return None diff --git a/allensdk/test/api/cloud_cache/test_cache.py b/allensdk/test/api/cloud_cache/test_cache.py index 1638a8922..bada36398 100644 --- a/allensdk/test/api/cloud_cache/test_cache.py +++ b/allensdk/test/api/cloud_cache/test_cache.py @@ -164,13 +164,6 @@ def test_file_exists(tmpdir): test_file_path) assert cache._file_exists(good_attribute) - # test when checksum is wrong - bad_attribute = CacheFileAttributes('http://silly.url.com', - '12345', - 'probably_not_the_checksum', - test_file_path) - assert not cache._file_exists(bad_attribute) - # test when file path is wrong bad_path = pathlib.Path('definitely/not/a/file.txt') bad_attribute = CacheFileAttributes('http://silly.url.com', @@ -333,7 +326,7 @@ def test_download_file_multiple_versions(tmpdir): def test_re_download_file(tmpdir): """ Test that S3CloudCache._download_file will re-download a file - when it has been altered locally + when it has been removed from the local system """ hasher = hashlib.blake2b() @@ -388,21 +381,6 @@ def test_re_download_file(tmpdir): hasher.update(in_file.read()) assert hasher.hexdigest() == true_checksum - # now, alter the file, and see if it gets re-downloaded - with open(expected_path, 'wb') as out_file: - out_file.write(b'778899') - hasher = hashlib.blake2b() - with open(expected_path, 'rb') as in_file: - hasher.update(in_file.read()) - assert hasher.hexdigest() != true_checksum - - cache._download_file(good_attributes) - assert expected_path.exists() - hasher = hashlib.blake2b() - with open(expected_path, 'rb') as in_file: - hasher.update(in_file.read()) - assert hasher.hexdigest() == true_checksum - @mock_s3 def test_download_data(tmpdir): diff --git a/allensdk/test/api/cloud_cache/test_smart_download.py b/allensdk/test/api/cloud_cache/test_smart_download.py index da56bb53e..f45f06b88 100644 --- a/allensdk/test/api/cloud_cache/test_smart_download.py +++ b/allensdk/test/api/cloud_cache/test_smart_download.py @@ -126,14 +126,13 @@ def test_on_corrupted_files(tmpdir, example_datasets): hasher.update(b'4567890') true_hash = hasher.hexdigest() - # Check that, when a file on disk gets corrupted, + # Check that, when a file on disk gets removed, # all of the symlinks that point back to that file # get marked as `not exists` cache.load_manifest('project-x_manifest_v1.0.0.json') attr = cache.data_path('2') - with open(attr['local_path'], 'wb') as out_file: - out_file.write(b'xxxxxx') + attr['local_path'].unlink() attr = cache.data_path('2') assert not attr['exists'] @@ -353,9 +352,8 @@ def test_corrupted_download_manifest(tmpdir, example_datasets): # CloudCache won't consult _downloaded_data_path assert attr['exists'] - # now corrupt one of the data files - with open(attr['local_path'], 'wb') as out_file: - out_file.write(b'xxxxx') + # now remove one of the data files + attr['local_path'].unlink() # now that the file is corrupted, 'exists' is False attr = cache.data_path('2') From b2e2307412f8cf4ca4911c64b5c909851fcf6741 Mon Sep 17 00:00:00 2001 From: danielsf Date: Fri, 7 May 2021 18:10:07 -0700 Subject: [PATCH 79/86] remove extra file hashing from CloudCache._check_for_identical_copy to prevent hashing from slowing down the symlink construction process --- allensdk/api/cloud_cache/cloud_cache.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/allensdk/api/cloud_cache/cloud_cache.py b/allensdk/api/cloud_cache/cloud_cache.py index 25d41b775..4bfb9d925 100644 --- a/allensdk/api/cloud_cache/cloud_cache.py +++ b/allensdk/api/cloud_cache/cloud_cache.py @@ -517,12 +517,6 @@ def _check_for_identical_copy(self, if matched_path is None: return False - # double check that locally downloaded file still has - # the expected hash - candidate_hash = file_hash_from_path(matched_path) - if candidate_hash != file_attributes.file_hash: - return False - local_parent = file_attributes.local_path.parent.resolve() if not local_parent.exists(): os.makedirs(local_parent) From a0d53e7aa746d6bb1788fa30825e8c3b7abf6888 Mon Sep 17 00:00:00 2001 From: danielsf Date: Mon, 10 May 2021 10:01:37 -0700 Subject: [PATCH 80/86] remove iteration over warnings --- allensdk/test/api/cloud_cache/test_cache.py | 26 ++++++++------------- 1 file changed, 10 insertions(+), 16 deletions(-) diff --git a/allensdk/test/api/cloud_cache/test_cache.py b/allensdk/test/api/cloud_cache/test_cache.py index bada36398..8c40822b8 100644 --- a/allensdk/test/api/cloud_cache/test_cache.py +++ b/allensdk/test/api/cloud_cache/test_cache.py @@ -761,15 +761,12 @@ def test_load_last_manifest(tmpdir, example_datasets_with_metadata): # check that load_last_manifest on an old cache emits the # expected warning and loads the correct manifest cache = S3CloudCache(cache_dir, bucket_name, 'project-x') - with pytest.warns(OutdatedManifestWarning) as warnings: + expected = 'A more up to date version of the ' + expected += 'dataset -- project-x_manifest_v15.0.0.json ' + expected += '-- exists online' + with pytest.warns(OutdatedManifestWarning, + match=expected) as warnings: cache.load_last_manifest() - for w in warnings.list: - if w._category_name == 'OutdatedManifestWarning': - msg = str(w.message) - expected = 'A more up to date version of the ' - expected += 'dataset -- project-x_manifest_v15.0.0.json ' - expected += '-- exists online' - assert expected in msg assert cache.current_manifest == 'project-x_manifest_v7.0.0.json' cache.load_manifest('project-x_manifest_v4.0.0.json') @@ -778,15 +775,12 @@ def test_load_last_manifest(tmpdir, example_datasets_with_metadata): # repeat the above test, making sure the correct manifest is # loaded again cache = S3CloudCache(cache_dir, bucket_name, 'project-x') - with pytest.warns(OutdatedManifestWarning) as warnings: + expected = 'A more up to date version of the ' + expected += 'dataset -- project-x_manifest_v15.0.0.json ' + expected += '-- exists online' + with pytest.warns(OutdatedManifestWarning, + match=expected) as warnings: cache.load_last_manifest() - for w in warnings.list: - if w._category_name == 'OutdatedManifestWarning': - msg = str(w.message) - expected = 'A more up to date version of the ' - expected += 'dataset -- project-x_manifest_v15.0.0.json ' - expected += '-- exists online' - assert expected in msg assert cache.current_manifest == 'project-x_manifest_v4.0.0.json' From e57cb99c545078875b6521fd145d88bca5daca1f Mon Sep 17 00:00:00 2001 From: danielsf Date: Mon, 10 May 2021 10:13:25 -0700 Subject: [PATCH 81/86] test that LocalCache can also create symlinks where appropriate --- .../api/cloud_cache/test_smart_download.py | 47 ++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/allensdk/test/api/cloud_cache/test_smart_download.py b/allensdk/test/api/cloud_cache/test_smart_download.py index f45f06b88..ef6f939ea 100644 --- a/allensdk/test/api/cloud_cache/test_smart_download.py +++ b/allensdk/test/api/cloud_cache/test_smart_download.py @@ -5,7 +5,7 @@ from moto import mock_s3 from .utils import create_bucket from allensdk.api.cloud_cache.cloud_cache import MissingLocalManifestWarning -from allensdk.api.cloud_cache.cloud_cache import S3CloudCache +from allensdk.api.cloud_cache.cloud_cache import S3CloudCache, LocalCache from allensdk.api.cloud_cache.file_attributes import CacheFileAttributes # noqa: E501 @@ -476,3 +476,48 @@ def _download_file(self, file_attributes: CacheFileAttributes): dummy.load_manifest('project-x_manifest_v3.0.0.json') with pytest.raises(RuntimeError): dummy.download_data('1') + + +@mock_s3 +def test_local_cache_symlink(tmpdir, example_datasets): + """ + Test that a LocalCache is smart enough to construct + a symlink where appropriate + """ + test_bucket_name = 'local_cache_test_bucket' + create_bucket(test_bucket_name, + example_datasets) + + cache_dir = pathlib.Path(tmpdir) / 'cache' + + # create an online cache and download some data + online_cache = S3CloudCache(cache_dir, test_bucket_name, 'project-x') + online_cache.load_manifest('project-x_manifest_v1.0.0.json') + p0 = online_cache.download_data('1') + online_cache.load_manifest('project-x_manifest_v3.0.0.json') + + # path to file we intend to download + # (just making sure it wasn't accidentally created early + # by the online cache) + shld_be = cache_dir / 'project-x-3.0.0/data/f1.txt' + assert not shld_be.exists() + + del online_cache + + # create a local cache pointing to the same cache directory + # an try to access a data file that, while not downloaded, + # is identical to a file that has been downloaded + local_cache = LocalCache(cache_dir, test_bucket_name, 'project-x') + local_cache.load_manifest('project-x_manifest_v3.0.0.json') + attr = local_cache.data_path('1') + assert attr['exists'] + assert attr['local_path'].absolute() == shld_be.absolute() + assert attr['local_path'].is_symlink() + assert attr['local_path'].resolve() == p0.resolve() + + # test that LocalCache does not have access to data that + # has not been downloaded + attr = local_cache.data_path('2') + assert not attr['exists'] + with pytest.raises(NotImplementedError): + local_cache.download_data('2') From 940a26c1c2203df0b57aeeaf6e4cbb57f89a7d16 Mon Sep 17 00:00:00 2001 From: danielsf Date: Tue, 11 May 2021 12:00:53 -0700 Subject: [PATCH 82/86] expose list_all_downloaded_manifests in VisualBehaviorOphysProjectCache --- allensdk/api/cloud_cache/cloud_cache.py | 6 ++++-- .../behavior_project_cache/behavior_project_cache.py | 9 +++++++++ .../test_behavior_project_cache.py | 5 +++++ .../behavior/behavior_project_cache/test_from_s3.py | 4 ++++ 4 files changed, 22 insertions(+), 2 deletions(-) diff --git a/allensdk/api/cloud_cache/cloud_cache.py b/allensdk/api/cloud_cache/cloud_cache.py index 4bfb9d925..eadff0c35 100644 --- a/allensdk/api/cloud_cache/cloud_cache.py +++ b/allensdk/api/cloud_cache/cloud_cache.py @@ -176,8 +176,10 @@ def list_all_downloaded_manifests(self) -> list: Return a list of all of the manifest files that have been downloaded for this dataset """ - return [x for x in os.listdir(self._cache_dir) - if re.fullmatch(".*_manifest_v.*.json", x)] + output = [x for x in os.listdir(self._cache_dir) + if re.fullmatch(".*_manifest_v.*.json", x)] + output.sort() + return output @abstractmethod def _list_all_manifests(self) -> list: diff --git a/allensdk/brain_observatory/behavior/behavior_project_cache/behavior_project_cache.py b/allensdk/brain_observatory/behavior/behavior_project_cache/behavior_project_cache.py index 64acefbda..cac555606 100644 --- a/allensdk/brain_observatory/behavior/behavior_project_cache/behavior_project_cache.py +++ b/allensdk/brain_observatory/behavior/behavior_project_cache/behavior_project_cache.py @@ -344,6 +344,15 @@ def load_manifest(self, manifest_name: str): self._cache_not_implemented('load_manifest') self.fetch_api.load_manifest(manifest_name) + def list_all_downloaded_manifests(self) -> list: + """ + Return a sorted list of the names of the manifest files + that have been downloaded to this cache. + """ + if not isinstance(self.fetch_api, BehaviorProjectCloudApi): + self._cache_not_implemented('list_all_downloaded_manifests') + return self.fetch_api.cache.list_all_downloaded_manifests() + def list_manifest_file_names(self) -> list: """ Return a sorted list of the names of the manifest files diff --git a/allensdk/test/brain_observatory/behavior/behavior_project_cache/test_behavior_project_cache.py b/allensdk/test/brain_observatory/behavior/behavior_project_cache/test_behavior_project_cache.py index 66699754e..004c13b70 100644 --- a/allensdk/test/brain_observatory/behavior/behavior_project_cache/test_behavior_project_cache.py +++ b/allensdk/test/brain_observatory/behavior/behavior_project_cache/test_behavior_project_cache.py @@ -238,6 +238,11 @@ def test_cloud_manifest_errors(TempdirBehaviorCache): match=msg.format(mname='current_manifest')): TempdirBehaviorCache.current_manifest() + this_msg = msg.format(mname='list_all_downloaded_manifests') + with pytest.raises(NotImplementedError, + match=this_msg): + TempdirBehaviorCache.list_all_downloaded_manifests() + this_msg = msg.format(mname='list_manifest_file_names') with pytest.raises(NotImplementedError, match=this_msg): diff --git a/allensdk/test/brain_observatory/behavior/behavior_project_cache/test_from_s3.py b/allensdk/test/brain_observatory/behavior/behavior_project_cache/test_from_s3.py index de9854122..629d1594e 100644 --- a/allensdk/test/brain_observatory/behavior/behavior_project_cache/test_from_s3.py +++ b/allensdk/test/brain_observatory/behavior/behavior_project_cache/test_from_s3.py @@ -191,9 +191,13 @@ def test_file_linkage(tmpdir, s3_cloud_cache_data, delete_cache): v2_dir = cache_dir / f'{project_name}-0.2.0/data' assert cache.current_manifest() == v2_name + assert cache.list_all_downloaded_manifests() == [v2_name] cache.load_manifest(v1_name) assert cache.current_manifest() == v1_name + assert cache.list_all_downloaded_manifests() == [v1_name, + v2_name] + for sess_id in (333, 444): cache.get_behavior_session(behavior_session_id=sess_id) for exp_id in (5111, 5222): From 9d0c27cf65c90e4f2972ac29f96ce7e20acef3ad Mon Sep 17 00:00:00 2001 From: danielsf Date: Tue, 11 May 2021 12:08:48 -0700 Subject: [PATCH 83/86] add cells to notebook explaining how to load specific manifest versions --- .../visual_behavior_ophys_data_access.ipynb | 170 ++++++++++++++++++ 1 file changed, 170 insertions(+) diff --git a/doc_template/examples_root/examples/nb/visual_behavior_ophys_data_access.ipynb b/doc_template/examples_root/examples/nb/visual_behavior_ophys_data_access.ipynb index 9944149e9..075a2f095 100644 --- a/doc_template/examples_root/examples/nb/visual_behavior_ophys_data_access.ipynb +++ b/doc_template/examples_root/examples/nb/visual_behavior_ophys_data_access.ipynb @@ -130,6 +130,176 @@ "If you are analyzing data without using the AllenSDK, you can load the data using your CSV file reader of choice. However, please be aware the columns in the original file do not necessarily match what's returned by the AllenSDK, which may combine information from multiple files to produce the final DataFrame." ] }, + { + "cell_type": "markdown", + "id": "38fd2b61", + "metadata": {}, + "source": [ + "### Managing versions of the dataset\n", + "\n", + "Over time, updates may be made to the released dataset. These updates will result in new versions of the dataset being available in the S3 bucket. The versions of the dataset are managed through distinct data manifests stored on S3." + ] + }, + { + "cell_type": "markdown", + "id": "9c8681ba", + "metadata": {}, + "source": [ + "#### Discovering manifests\n", + "\n", + "To see all of the manifest files available for this dataset online, run" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "0e32b98a", + "metadata": {}, + "outputs": [], + "source": [ + "cache.list_manifest_file_names()" + ] + }, + { + "cell_type": "markdown", + "id": "e2e9d0e9", + "metadata": {}, + "source": [ + "To see the most up-to-date available manifest, run" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d393998d", + "metadata": {}, + "outputs": [], + "source": [ + "cache.latest_manifest_file()" + ] + }, + { + "cell_type": "markdown", + "id": "d9b2b376", + "metadata": {}, + "source": [ + "To see the name of the most up-to-date manifest that you have already downloaded to your system run (note: this just means that the manifest file has been downloaded; it does not necessarily mean that any data has been downloaded)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cabb6558", + "metadata": {}, + "outputs": [], + "source": [ + "cache.latest_downloaded_manifest_file()" + ] + }, + { + "cell_type": "markdown", + "id": "7eec37cd", + "metadata": {}, + "source": [ + "You can list all of the manifest files currently downloaded to your system with" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3b7c2bf2", + "metadata": {}, + "outputs": [], + "source": [ + "cache.list_all_downloaded_manifests()" + ] + }, + { + "cell_type": "markdown", + "id": "83ca391e", + "metadata": {}, + "source": [ + "#### Loading manifests/dataset versions\n", + "\n", + "The `VisualBehaviorOphysProjectCache` determines which version of the dataset to use by loading one of these manifests. By default, the `VisualBehaviorProjectCache` loads either\n", + "\n", + "- the most up-to-date available data manifest, if you are instaniating it on an empty `cache_dir`\n", + "\n", + "- the data manifest you were last using, if you are instantiating it on a pre-existing `cache_dir` (in this case, the `VisualBehaviorOphysProjectCache` will emit a warning if a more up-to-data data manifest exists online letting you know that you can, if you choose, move to the more up-to-date data manifest)\n", + "\n", + "To see the manifest that you currently have loaded, run" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2f4be99e", + "metadata": {}, + "outputs": [], + "source": [ + "cache.current_manifest()" + ] + }, + { + "cell_type": "markdown", + "id": "d9981e6e", + "metadata": {}, + "source": [ + "To load a particular data manifest by hand, run (note: because we are intentionally loading an out-of-date manifest, this will emit a warning alerting us to the existence of the most up-to-date manifest)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e151a7cb", + "metadata": {}, + "outputs": [], + "source": [ + "cache.load_manifest('visual-behavior-ophys_project_manifest_v0.1.0.json')" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b13cba09", + "metadata": {}, + "outputs": [], + "source": [ + "cache.current_manifest()" + ] + }, + { + "cell_type": "markdown", + "id": "851dfeea", + "metadata": {}, + "source": [ + "As the earlier warning informed us, we can see the difference between an two versions of the dataset by running" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "03dad58a", + "metadata": {}, + "outputs": [], + "source": [ + "msg = cache.compare_manifests('visual-behavior-ophys_project_manifest_v0.1.0.json',\n", + " 'visual-behavior-ophys_project_manifest_v0.2.0.json')\n", + "print(msg)" + ] + }, + { + "cell_type": "markdown", + "id": "82103f33", + "metadata": {}, + "source": [ + "In the case we just examined, only the metadata files have changed.\n", + "\n", + "The `VisualBehaviorOphysProjectCache` is smart enough to know that, if a file has not changed between version `A` and version `B` of the dataset, and you have already downloaded the file while version `A` of the manifest was loaded, when you move to version `B`, it does not need to download the data again. It will simply construct a symlink where version `B` of the data should exist on your system, pointing to version `A` of the file.\n", + "\n", + "Because only metadata files changed between `v0.1.0` and `v0.2.0` of the dataset, we could move freely between the two versions without having to worry about downloading a bunch of new data files. This may not be the case for future dataset updates, so you should keep that in mind before moving from an older to a newer version out of hand." + ] + }, { "cell_type": "markdown", "id": "geographic-observation", From 2f27823de7b684ec3a5197046e034dbdc3f9412f Mon Sep 17 00:00:00 2001 From: danielsf Date: Tue, 11 May 2021 13:36:57 -0700 Subject: [PATCH 84/86] add Data File Changelog to VBO rst --- .../visual_behavior_optical_physiology.rst | 69 +++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/doc_template/visual_behavior_optical_physiology.rst b/doc_template/visual_behavior_optical_physiology.rst index 00e8c893c..564cdb36b 100644 --- a/doc_template/visual_behavior_optical_physiology.rst +++ b/doc_template/visual_behavior_optical_physiology.rst @@ -280,3 +280,72 @@ SUMMARY OF AVAILABLE DATA * - - Ophys timestamps - + +DATA FILE CHANGELOG +------------------- + +**v0.3.0** + +13 sessions were labeled with the wrong session_type in v0.2.0. We have +corrected that error. The offending sessions were + +.. list-table:: + :widths: 30 30 50 50 + :header-rows: 1 + + * - behavior_session_id + - ophys_session_id + - session_type_v0.2.0 + - session_type_v0.3.0 + * - 875020233 + - + - OPHYS_3_images_A + - OPHYS_2_images_A_passive + * - 902810506 + - + - TRAINING_4_images_B_training + - TRAINING_3_images_B_10uL_reward + * - 914219174 + - + - OPHYS_0_images_B_habituation + - TRAINING_5_images_B_handoff_ready + * - 863571063 + - + - TRAINING_5_images_A_handoff_ready + - TRAINING_1_gratings + * - 974330793 + - + - OPHYS_0_images_B_habituation + - TRAINING_5_images_B_handoff_ready + * - 863571072 + - + - OPHYS_5_images_B_passive + - TRAINING_4_images_A_training + * - 1010972317 + - + - OPHYS_4_images_A + - OPHYS_3_images_B + * - 1011659817 + - + - OPHYS_5_images_A_passive + - OPHYS_4_images_A + * - 1003302686 + - 1003277121 + - OPHYS_6_images_A + - OPHYS_5_images_A_passive + * - 863571054 + - + - OPHYS_7_receptive_field_mapping + - TRAINING_5_images_A_epilogue + * - 974282914 + - 974167263 + - OPHYS_6_images_B + - OPHYS_5_images_B_passive + * - 885418521 + - + - OPHYS_1_images_A + - TRAINING_5_images_A_handoff_lapsed + * - 915739774 + - + - OPHYS_1_images_A + - OPHYS_0_images_A_habituation From 6834221f95186ec43930b80d61138027b8534017 Mon Sep 17 00:00:00 2001 From: matyasz Date: Thu, 13 May 2021 10:42:45 -0700 Subject: [PATCH 85/86] Pinning Jinja2 version in test_requirements as well --- CHANGELOG.md | 2 ++ doc_template/index.rst | 2 ++ test_requirements.txt | 1 + 3 files changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b8e20c53e..4945537cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,8 @@ All notable changes to this project will be documented in this file. ## [2.11.0] = TBD - python 3.8 compatibility +- CloudCache (the class supporting cloud-based data releases) is now smart enough to construct symlinks between files that are identical across dataset versions (rather than downloading duplicate copies of files). +- VisualBehavioOphysProjectCache supports user-controlled switching between dataset versions. ## [2.10.3] = 2021-04-23 - Adds restriction to require hdmf version to be strictly less than 2.5.0 which accidentally introduced a major version breaking change diff --git a/doc_template/index.rst b/doc_template/index.rst index 9d98a5441..2adb7a756 100644 --- a/doc_template/index.rst +++ b/doc_template/index.rst @@ -122,6 +122,8 @@ See the `mouse connectivity section `_ for more details. What's New - 2.11.0 ----------------------------------------------------------------------- - python 3.8 compatibility +- CloudCache (the class supporting cloud-based data releases) is now smart enough to construct symlinks between files that are identical across dataset versions (rather than downloading duplicate copies of files). +- VisualBehavioOphysProjectCache supports user-controlled switching between dataset versions. What's New - 2.10.3 diff --git a/test_requirements.txt b/test_requirements.txt index 525a86d0f..543026ba5 100644 --- a/test_requirements.txt +++ b/test_requirements.txt @@ -13,5 +13,6 @@ moto==2.0.1 pep8==1.7.0,<2.0.0 flake8>=1.5.0,<4.0.0 pylint>=1.5.4,<3.0.0 +jinja2>=2.7.3,<2.12.0 numpydoc>=0.6.0,<1.0.0 jupyter>=1.0.0,<2.0.0 \ No newline at end of file From 6d71967cc4ba180b1a5a1be82a9e2bec338f3f51 Mon Sep 17 00:00:00 2001 From: matyasz Date: Thu, 13 May 2021 11:03:50 -0700 Subject: [PATCH 86/86] Pinning Jinja2 version in setup.py --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 0edd3984c..d410c5800 100644 --- a/setup.py +++ b/setup.py @@ -44,7 +44,7 @@ def prepend_find_packages(*roots): description = 'core libraries for the allensdk.', install_requires = required, tests_require=test_required, - setup_requires=['setuptools', 'sphinx', 'numpydoc', 'pytest-runner'], + setup_requires=['setuptools', 'jinja2>=2.7.3,<2.12.0', 'sphinx', 'numpydoc', 'pytest-runner'], url='https://github.com/AllenInstitute/AllenSDK/tree/v%s' % (allensdk.__version__), download_url = 'https://github.com/AllenInstitute/AllenSDK/tarball/v%s' % (allensdk.__version__), keywords = ['neuroscience', 'bioinformatics', 'scientific' ],