From 977fe868b4a7fb7402e32ef7e5c625738eafe842 Mon Sep 17 00:00:00 2001 From: Sajid Alam <90610031+SajidAlamQB@users.noreply.github.com> Date: Thu, 26 Sep 2024 13:34:14 +0100 Subject: [PATCH 01/35] Organise CSS Z-index (#2095) * rough z-index variables Signed-off-by: Sajid Alam * update var Signed-off-by: Sajid Alam * Update _group.scss Signed-off-by: Sajid Alam * simplify Signed-off-by: Sajid Alam * add zindex entry Signed-off-by: Sajid Alam * group by levels Signed-off-by: Sajid Alam * Update _variables.scss Signed-off-by: Sajid Alam * Update _variables.scss Signed-off-by: Sajid Alam --------- Signed-off-by: Sajid Alam Signed-off-by: Sajid Alam <90610031+SajidAlamQB@users.noreply.github.com> --- .../run-plots-modal/run-plots-modal.scss | 2 +- .../runs-list/runs-list.scss | 10 +-- .../feature-hints/feature-hints.scss | 25 +++--- .../flowchart-wrapper/flowchart-wrapper.scss | 2 +- .../global-toolbar/global-toolbar.scss | 2 +- .../metadata-modal/metadata-modal.scss | 2 +- .../metadata/styles/metadata-code.scss | 2 +- src/components/metadata/styles/metadata.scss | 4 +- src/components/node-list/styles/_group.scss | 14 ++-- src/components/node-list/styles/_panels.scss | 10 +-- src/components/node-list/styles/_row.scss | 2 +- .../preview-table/preview-table.scss | 2 +- src/components/search-list/search-list.scss | 4 +- src/components/sidebar/sidebar.scss | 8 +- src/components/ui/banner/banner.scss | 2 +- src/components/ui/dropdown/dropdown.scss | 6 +- .../ui/icon-button/icon-button.scss | 2 +- src/components/ui/modal/modal.scss | 20 ++--- src/components/ui/search-bar/search-bar.scss | 6 +- src/components/ui/switch/switch.scss | 22 +++--- src/components/ui/tooltip/tooltip.scss | 3 +- .../update-reminder/update-reminder.scss | 78 +++++++++---------- src/styles/_variables.scss | 62 +++++++++++++-- 23 files changed, 171 insertions(+), 119 deletions(-) diff --git a/src/components/experiment-tracking/run-plots-modal/run-plots-modal.scss b/src/components/experiment-tracking/run-plots-modal/run-plots-modal.scss index 792e5c912b..23bf4ff5e1 100644 --- a/src/components/experiment-tracking/run-plots-modal/run-plots-modal.scss +++ b/src/components/experiment-tracking/run-plots-modal/run-plots-modal.scss @@ -17,7 +17,7 @@ flex-direction: column; overflow-y: scroll; position: absolute; - z-index: 3; + z-index: variables.$zindex-plot-modal;; } .pipeline-run-plots-modal__top { diff --git a/src/components/experiment-tracking/runs-list/runs-list.scss b/src/components/experiment-tracking/runs-list/runs-list.scss index 5c80cdef4f..9b0327e908 100644 --- a/src/components/experiment-tracking/runs-list/runs-list.scss +++ b/src/components/experiment-tracking/runs-list/runs-list.scss @@ -1,7 +1,7 @@ -@use '../../../styles/variables' as colors; +@use '../../../styles/variables' as variables; .kui-theme--light { - --header-border-bottom: #{colors.$white-300}; + --header-border-bottom: #{variables.$white-300}; } .kui-theme--dark { @@ -11,7 +11,7 @@ .runs-list-top-wrapper { position: sticky; top: 0; - z-index: 1; + z-index: variables.$zindex-sticky-elements; } .search-bar-wrapper { @@ -19,7 +19,7 @@ padding-top: 24px; position: sticky; top: 0; - z-index: 1; + z-index: variables.$zindex-sticky-elements; } .runs-list__wrapper { @@ -40,7 +40,7 @@ padding: 3.5em 3.5em 1.2em 3em; position: sticky; top: 64px; - z-index: 1; + z-index: variables.$zindex-sticky-elements; } .compare-switch-wrapper__text { diff --git a/src/components/feature-hints/feature-hints.scss b/src/components/feature-hints/feature-hints.scss index a8d39aa353..9d75a39d4c 100644 --- a/src/components/feature-hints/feature-hints.scss +++ b/src/components/feature-hints/feature-hints.scss @@ -1,9 +1,9 @@ -@use '../../styles/variables' as colors; +@use '../../styles/variables' as variables; .feature-hints { - background-color: colors.$yellow-0; + background-color: variables.$yellow-0; bottom: 36px; - color: colors.$black-900; + color: variables.$black-900; display: flex; flex-direction: column; height: auto; @@ -11,7 +11,7 @@ position: absolute; transition: height 0.3s ease-in-out, right ease 0.4s; width: 365px; - z-index: 5; + z-index: variables.$zindex-feature-hints; &__reopen-message { font-size: 14px; @@ -20,7 +20,7 @@ } &__nav { - color: colors.$black-300; + color: variables.$black-300; font-size: 12px; font-weight: 600; letter-spacing: 0.024px; @@ -69,17 +69,17 @@ } .button button { - color: colors.$black-900; - border-color: colors.$black-900; + color: variables.$black-900; + border-color: variables.$black-900; &:hover:not(.button__btn--secondary) { - background: colors.$black-900; - color: colors.$white-0; + background: variables.$black-900; + color: variables.$white-0; } } .button__btn--secondary:hover::after { - background: colors.$black-900; + background: variables.$black-900; } .button__btn--secondary:active { @@ -101,7 +101,6 @@ pointer-events: none; position: absolute; transform: translate(-50%, -50%); - transition: left 0.3s ease-in-out, top 0.3s ease-in-out, - opacity 0.1s ease-in-out; - z-index: 5; + transition: left 0.3s ease-in-out, top 0.3s ease-in-out, opacity 0.1s ease-in-out; + z-index: variables.$zindex-feature-hints; } diff --git a/src/components/flowchart-wrapper/flowchart-wrapper.scss b/src/components/flowchart-wrapper/flowchart-wrapper.scss index 2ceaef2d3e..7046984f53 100644 --- a/src/components/flowchart-wrapper/flowchart-wrapper.scss +++ b/src/components/flowchart-wrapper/flowchart-wrapper.scss @@ -36,7 +36,7 @@ $sidebar-toolbar-width-open: variables.$sidebar-width-open + opacity: 0; pointer-events: none; position: relative; - z-index: 3; + z-index: variables.$zindex-go-back-btn; .button button { background-color: variables.$yellow-300; diff --git a/src/components/global-toolbar/global-toolbar.scss b/src/components/global-toolbar/global-toolbar.scss index b117e90442..ddb2d33b80 100644 --- a/src/components/global-toolbar/global-toolbar.scss +++ b/src/components/global-toolbar/global-toolbar.scss @@ -11,7 +11,7 @@ position: absolute; top: 0; width: variables.$global-toolbar-width; - z-index: 5; + z-index: variables.$zindex-global-toolbar; } .pipeline-global-routes-toolbar { diff --git a/src/components/metadata-modal/metadata-modal.scss b/src/components/metadata-modal/metadata-modal.scss index 5f1859590d..586c7be0b1 100644 --- a/src/components/metadata-modal/metadata-modal.scss +++ b/src/components/metadata-modal/metadata-modal.scss @@ -23,7 +23,7 @@ height: 100%; /* Full height (cover the whole page) */ inset: 0 0 0 variables.$global-toolbar-width; position: absolute; - z-index: 6; + z-index: variables.$zindex-metadata-modal; /* We don't need full width as sometime the preview table can take up more than a width of the page */ background-color: var(--color-bg-plot); diff --git a/src/components/metadata/styles/metadata-code.scss b/src/components/metadata/styles/metadata-code.scss index 29e4ceb19b..2eb79fd0c5 100644 --- a/src/components/metadata/styles/metadata-code.scss +++ b/src/components/metadata/styles/metadata-code.scss @@ -26,7 +26,7 @@ padding: 0 variables.$metadata-sidebar-width-open 0 0; transform: translateX(100vw); transition: transform ease 0.5s 0.1s, left ease 0.5s; - z-index: variables.$z-index-metadata-code; + z-index: variables.$zindex-metadata-code; &--visible { transform: translateX(variables.$global-toolbar-width); diff --git a/src/components/metadata/styles/metadata.scss b/src/components/metadata/styles/metadata.scss index b35c612d49..67f5848cf6 100644 --- a/src/components/metadata/styles/metadata.scss +++ b/src/components/metadata/styles/metadata.scss @@ -28,7 +28,7 @@ top: -1px; /* Avoids pixel rounding gaps */ right: -1px; bottom: -1px; - z-index: variables.$z-index-metadata-panel; + z-index: variables.$zindex-metadata-panel; display: flex; flex-direction: column; width: 100%; @@ -254,7 +254,7 @@ $list-inline-spacing: 0.2em; flex-direction: row; justify-content: center; padding: 0; - z-index: 1; + z-index: variables.$zindex-metadata-link; &:hover { background-color: var(--color-button-plot-hovered); diff --git a/src/components/node-list/styles/_group.scss b/src/components/node-list/styles/_group.scss index 04aa06c72d..0d456bd2f5 100644 --- a/src/components/node-list/styles/_group.scss +++ b/src/components/node-list/styles/_group.scss @@ -1,4 +1,4 @@ -@use '../../../styles/variables' as colors; +@use '../../../styles/variables' as var; @use './variables'; %nolist { @@ -28,7 +28,7 @@ $placeholder-fade: 120px; .pipeline-nodelist__placeholder-upper, .pipeline-nodelist__placeholder-lower { - z-index: 2; + z-index: var.$zindex-nodelist-placeholder; pointer-events: none; } @@ -72,7 +72,7 @@ $placeholder-fade: 120px; .pipeline-nodelist__heading { position: sticky; top: 0; - z-index: 1; + z-index: var.$zindex-nodelist-heading; margin: 0; // Avoid pixel gap above when scrolling. @@ -94,7 +94,7 @@ $placeholder-fade: 120px; &::after { position: absolute; bottom: -19px; - z-index: -1; + z-index: var.$zindex-group-background-fade; width: 100%; height: 20px; background: linear-gradient( @@ -114,7 +114,7 @@ $placeholder-fade: 120px; &::after { position: absolute; bottom: -19px; - z-index: -1; + z-index: var.$zindex-group-background-fade; width: 100%; height: 20px; background: linear-gradient( @@ -148,7 +148,7 @@ $placeholder-fade: 120px; outline: none; [data-whatintent='keyboard'] & { - box-shadow: 0 0 0 3px colors.$blue-300 inset; + box-shadow: 0 0 0 3px var.$blue-300 inset; } } @@ -167,7 +167,7 @@ $placeholder-fade: 120px; } &--disabled { - color: colors.$black-400; + color: var.$black-400; transform: rotate(90deg); } } diff --git a/src/components/node-list/styles/_panels.scss b/src/components/node-list/styles/_panels.scss index 20f4ef8aed..6300b5e064 100644 --- a/src/components/node-list/styles/_panels.scss +++ b/src/components/node-list/styles/_panels.scss @@ -1,8 +1,8 @@ -@use '../../../styles/variables' as colors; +@use '../../../styles/variables' as var; @use './variables'; .pipeline-nodelist__filter-panel { - z-index: 1; + z-index: var.$zindex-filter-panel; background: var(--color-nodelist-filter-panel); border-top: 1px solid var(--color-border-line); @@ -64,8 +64,8 @@ // Handle has keyboard focus, show outline. &:focus { - z-index: 1; - outline: 3px solid colors.$blue-300; + z-index: var.$zindex-split-handle-focus; + outline: 3px solid var.$blue-300; } // Handle is hovered, highlight border. @@ -80,7 +80,7 @@ // Handle is active resizing, highlight border. .pipeline-nodelist__split--resizing & { - border-top: 1px solid colors.$blue-300; + border-top: 1px solid var.$blue-300; } } diff --git a/src/components/node-list/styles/_row.scss b/src/components/node-list/styles/_row.scss index df86639e68..409be89666 100644 --- a/src/components/node-list/styles/_row.scss +++ b/src/components/node-list/styles/_row.scss @@ -2,7 +2,7 @@ @use './variables'; .MuiTreeItem-iconContainer svg { - z-index: var.$z-index-MuiTreeItem-icon; + z-index: var.$zindex-MuiTreeItem-icon; } .pipeline-nodelist__row { diff --git a/src/components/preview-table/preview-table.scss b/src/components/preview-table/preview-table.scss index c8a0fd5395..35a95b3d92 100644 --- a/src/components/preview-table/preview-table.scss +++ b/src/components/preview-table/preview-table.scss @@ -48,7 +48,7 @@ background: var(--color-bg); position: sticky; top: 0; - z-index: 1; + z-index: variables.$zindex-preview-table-header; } .preview-table__row:hover, diff --git a/src/components/search-list/search-list.scss b/src/components/search-list/search-list.scss index 75ded03152..914d4870cb 100644 --- a/src/components/search-list/search-list.scss +++ b/src/components/search-list/search-list.scss @@ -14,7 +14,7 @@ right: 2px; top: 18px; width: 20px; - z-index: 1; + z-index: variables.$zindex-search-close-icon; } .search-bar .icon__graphics { @@ -42,7 +42,7 @@ .search-input--focused { position: relative; - z-index: 1; + z-index: variables.$zindex-search-input-focused; outline-color: variables.$blue-300; } diff --git a/src/components/sidebar/sidebar.scss b/src/components/sidebar/sidebar.scss index 03ac7eba1e..2575bcb166 100644 --- a/src/components/sidebar/sidebar.scss +++ b/src/components/sidebar/sidebar.scss @@ -14,14 +14,14 @@ } // Ensures sidebar tooltips are above code panel - z-index: 1; + z-index: variables.$zindex-sidebar; display: flex; width: 100%; transform: translateX(calc(-100% + #{variables.$sidebar-width-closed})); transition: transform ease 0.4s; @media (min-width: variables.$metadata-sidebar-code-breakpoint) { - z-index: 3; + z-index: variables.$zindex-sidebar-expanded; } @media (min-width: variables.$sidebar-width-breakpoint) { @@ -55,7 +55,7 @@ position: relative; transition: visibility 0.3s; visibility: hidden; - z-index: 2; + z-index: variables.$zindex-ui; .pipeline-sidebar--visible & { visibility: visible; @@ -75,7 +75,7 @@ height: 100%; position: relative; width: variables.$sidebar-width-closed; - z-index: 2; + z-index: variables.$zindex-toolbar; } .compare-switch-wrapper__text { diff --git a/src/components/ui/banner/banner.scss b/src/components/ui/banner/banner.scss index cddde93cb2..2bfd914b9a 100755 --- a/src/components/ui/banner/banner.scss +++ b/src/components/ui/banner/banner.scss @@ -25,7 +25,7 @@ justify-content: space-between; padding: 16px; width: 100%; - z-index: variables.$z-index-banner; + z-index: variables.$zindex-banner; font-family: inherit; box-shadow: var(--banner-box-shadow); background-color: var(--banner--background); diff --git a/src/components/ui/dropdown/dropdown.scss b/src/components/ui/dropdown/dropdown.scss index 9942ec0d5f..b38cd4229c 100644 --- a/src/components/ui/dropdown/dropdown.scss +++ b/src/components/ui/dropdown/dropdown.scss @@ -66,7 +66,7 @@ $menu-item-padding: 12px; position: relative; user-select: none; width: 100%; - z-index: 8; + z-index: variables.$zindex-dropdown-label; &:disabled { cursor: not-allowed; @@ -107,7 +107,7 @@ $menu-item-padding: 12px; } .dropdown__options { - z-index: 2; // fix closing transition animation bug + z-index: variables.$zindex-dropdown-options; background: var(--dropdown-options-bg); border-top: none; box-shadow: 0 0 2px variables.$black-800; @@ -144,5 +144,5 @@ $menu-item-padding: 12px; justify-content: space-around; padding: 24px 0; position: sticky; - z-index: 1; + z-index: variables.$zindex-dropdown-btn-wrapper; } diff --git a/src/components/ui/icon-button/icon-button.scss b/src/components/ui/icon-button/icon-button.scss index 84ea24abe0..f143bb42fa 100644 --- a/src/components/ui/icon-button/icon-button.scss +++ b/src/components/ui/icon-button/icon-button.scss @@ -68,7 +68,7 @@ $triangle-size: 7px; white-space: nowrap; // to ensure the tooltip will show on the top of the bookmark dropdown - z-index: 1; + z-index: variables.$zindex-toolbar-label; @media (max-width: variables.$sidebar-width-breakpoint) { .pipeline-sidebar--visible & { diff --git a/src/components/ui/modal/modal.scss b/src/components/ui/modal/modal.scss index 268e55633c..5c240c6a60 100644 --- a/src/components/ui/modal/modal.scss +++ b/src/components/ui/modal/modal.scss @@ -1,4 +1,4 @@ -@use '../../../styles/variables' as colors; +@use '../../../styles/variables' as variables; /** Variables **/ @@ -8,17 +8,17 @@ $size-content-maxwidth: 400px; $duration-visibility: 0.4s; .kui-theme--light { - --color-modal-bg: #{colors.$black-700}; - --color-modal-content: #{colors.$white-0}; - --color-modal-title: #{colors.$black-900}; - --color-modal-description: #{colors.$black-700}; + --color-modal-bg: #{variables.$black-700}; + --color-modal-content: #{variables.$white-0}; + --color-modal-title: #{variables.$black-900}; + --color-modal-description: #{variables.$black-700}; } .kui-theme--dark { - --color-modal-bg: #{colors.$slate-900}; - --color-modal-content: #{colors.$slate-0}; - --color-modal-title: #{colors.$white-0}; - --color-modal-description: #{colors.$black-0}; + --color-modal-bg: #{variables.$slate-900}; + --color-modal-content: #{variables.$slate-0}; + --color-modal-title: #{variables.$white-0}; + --color-modal-description: #{variables.$black-0}; } /** Implementation **/ @@ -29,7 +29,7 @@ $duration-visibility: 0.4s; left: 0; width: 100%; height: 100%; - z-index: 6; + z-index: variables.$zindex-modal; visibility: hidden; transition: visibility ease $duration-visibility; } diff --git a/src/components/ui/search-bar/search-bar.scss b/src/components/ui/search-bar/search-bar.scss index 1ec43a1a07..e375dd9074 100644 --- a/src/components/ui/search-bar/search-bar.scss +++ b/src/components/ui/search-bar/search-bar.scss @@ -1,4 +1,4 @@ -@use '../../../styles/variables' as colors; +@use '../../../styles/variables' as variables; $default-input-width: 320px; $size-icon: 24px; @@ -23,7 +23,7 @@ $size-pos-base: 10px; .search-bar & { position: absolute; top: 50%; - z-index: 1; + z-index: variables.$zindex-search-icon; opacity: 0.3; transform: translate(0, -50%); } @@ -86,7 +86,7 @@ $size-pos-base: 10px; width: 20px; &:focus { - outline-color: colors.$blue-300; + outline-color: variables.$blue-300; } .search-bar & { diff --git a/src/components/ui/switch/switch.scss b/src/components/ui/switch/switch.scss index 94172e94f1..8356fe6f23 100644 --- a/src/components/ui/switch/switch.scss +++ b/src/components/ui/switch/switch.scss @@ -1,4 +1,4 @@ -@use '../../../styles/variables' as colors; +@use '../../../styles/variables' as variables; .switch { align-items: center; @@ -20,7 +20,7 @@ position: relative; vertical-align: middle; width: 28px; - z-index: 0; + z-index: variables.$zindex-switch-root; } .switch__base { @@ -29,7 +29,7 @@ border-radius: 50%; border: 0; box-sizing: border-box; - color: colors.$white-0; + color: variables.$white-0; cursor: pointer; display: flex; justify-content: center; @@ -42,15 +42,15 @@ top: 0; transition: left 150ms cubic-bezier(0.4, 0, 0.2, 1) 0ms, transform 150ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; - z-index: 1; + z-index: variables.$zindex-switch-base; &--active { - color: colors.$blue-300; + color: variables.$blue-300; transform: translateX(14px); } &--active + .switch__track { - background-color: colors.$blue-300; + background-color: variables.$blue-300; opacity: 0.4; } } @@ -65,24 +65,24 @@ position: absolute; top: 0; width: 100%; - z-index: 1; + z-index: variables.$zindex-switch-input; } .switch__circle { background-color: currentcolor; border-radius: 50%; - box-shadow: colors.$black-100 0 2px 1px -1px, colors.$black-100 0 1px 1px 0, - colors.$black-100 0 1px 3px 0; + box-shadow: variables.$black-100 0 2px 1px -1px, variables.$black-100 0 1px 1px 0, + variables.$black-100 0 1px 3px 0; height: 12px; width: 12px; } .switch__track { - background-color: colors.$grey-900; + background-color: variables.$grey-900; border-radius: 7px; height: 100%; transition: opacity 150ms cubic-bezier(0.4, 0, 0.2, 1) 0ms, background-color 150ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; width: 100%; - z-index: -1; + z-index: variables.$zindex-switch-track; } diff --git a/src/components/ui/tooltip/tooltip.scss b/src/components/ui/tooltip/tooltip.scss index 146fe77dff..1d813eb383 100644 --- a/src/components/ui/tooltip/tooltip.scss +++ b/src/components/ui/tooltip/tooltip.scss @@ -1,3 +1,4 @@ +@use '../../../styles/variables' as variables; @use '../../../styles/extends'; $x-offset: 20px; @@ -9,7 +10,7 @@ $triangle-size-sm: 5px; position: absolute; bottom: calc(100% + 10px); left: 50%; - z-index: 9; + z-index: variables.$zindex-tooltip; transform: translate(-50%, 0); font-size: 12px; visibility: hidden; diff --git a/src/components/update-reminder/update-reminder.scss b/src/components/update-reminder/update-reminder.scss index a04d722e77..2f499b1930 100644 --- a/src/components/update-reminder/update-reminder.scss +++ b/src/components/update-reminder/update-reminder.scss @@ -1,11 +1,11 @@ -@use '../../styles/variables' as colors; +@use '../../styles/variables' as variables; $panel-width: 484px; $spacing-left: 48px; .update-reminder { position: relative; - z-index: 10; + z-index: variables.$zindex-update-reminder; } %h3 { @@ -21,36 +21,36 @@ $spacing-left: 48px; width: 430px; a { - color: colors.$blue-900; + color: variables.$blue-900; } } .kui-theme--light { - --color-detail-bg: #{colors.$white-400}; - --color-header-bg: #{colors.$yellow-0}; - --color-header-bg--up-to-date: #{colors.$grey-400}; - --color-header-text--up-to-date: #{colors.$black-900}; - --color-link: #{colors.$blue-600}; - --color-subtext: #{colors.$black-400}; - --color-content-bg: #{colors.$white-300}; - --color-version-tag: #{colors.$black-900}; - --color-version-tag--hover-outdated: #{colors.$black-900}; - --color-version-tag--hover-up-to-date-bg: #{colors.$black-300}; - --color-version-tag--hover-up-to-date: #{colors.$white-0}; + --color-detail-bg: #{variables.$white-400}; + --color-header-bg: #{variables.$yellow-0}; + --color-header-bg--up-to-date: #{variables.$grey-400}; + --color-header-text--up-to-date: #{variables.$black-900}; + --color-link: #{variables.$blue-600}; + --color-subtext: #{variables.$black-400}; + --color-content-bg: #{variables.$white-300}; + --color-version-tag: #{variables.$black-900}; + --color-version-tag--hover-outdated: #{variables.$black-900}; + --color-version-tag--hover-up-to-date-bg: #{variables.$black-300}; + --color-version-tag--hover-up-to-date: #{variables.$white-0}; } .kui-theme--dark { - --color-detail-bg: #{colors.$slate-300}; - --color-header-bg: #{colors.$yellow-0}; - --color-header-bg--up-to-date: #{colors.$white-0}; - --color-header-text--up-to-date: #{colors.$black-900}; - --color-link: #{colors.$blue-300}; - --color-subtext: #{colors.$black-400}; - --color-content-bg: #{colors.$slate-300}; - --color-version-tag: #{colors.$white-500}; - --color-version-tag--hover-outdated: #{colors.$black-900}; - --color-version-tag--hover-up-to-date-bg: #{colors.$black-400}; - --color-version-tag--hover-up-to-date: #{colors.$white-500}; + --color-detail-bg: #{variables.$slate-300}; + --color-header-bg: #{variables.$yellow-0}; + --color-header-bg--up-to-date: #{variables.$white-0}; + --color-header-text--up-to-date: #{variables.$black-900}; + --color-link: #{variables.$blue-300}; + --color-subtext: #{variables.$black-400}; + --color-content-bg: #{variables.$slate-300}; + --color-version-tag: #{variables.$white-500}; + --color-version-tag--hover-outdated: #{variables.$black-900}; + --color-version-tag--hover-up-to-date-bg: #{variables.$black-400}; + --color-version-tag--hover-up-to-date: #{variables.$white-500}; } .update-reminder-unexpanded { @@ -64,10 +64,10 @@ $spacing-left: 48px; right: 0; top: 0; width: $panel-width; - z-index: 2; + z-index: variables.$zindex-update-reminder-elements; p { - color: colors.$slate-700; + color: variables.$slate-700; text-align: center; margin-top: 7.5px; font-size: 12px; @@ -80,7 +80,7 @@ $spacing-left: 48px; button { background-color: var(--color-header-bg); border: none; - color: colors.$black-700; + color: variables.$black-700; cursor: pointer; font-size: 12px; padding: 0; @@ -92,7 +92,7 @@ $spacing-left: 48px; } &:hover { - color: colors.$black-800; + color: variables.$black-800; } } } @@ -107,7 +107,7 @@ $spacing-left: 48px; right: 0; top: 0; width: $panel-width; - z-index: 2; + z-index: variables.$zindex-update-reminder-elements; .close-button-container { margin-right: 20px; @@ -115,7 +115,7 @@ $spacing-left: 48px; .close-button { svg { - fill: colors.$black-800; + fill: variables.$black-800; height: 24px; width: 24px; } @@ -134,13 +134,13 @@ $spacing-left: 48px; .update-reminder-expanded-detail { background-color: var(--color-header-bg); - color: colors.$black-900; + color: variables.$black-900; padding-bottom: 40px; position: absolute; right: 0; top: 36px; width: $panel-width; - z-index: 2; + z-index: variables.$zindex-update-reminder-elements; &--up-to-date { background-color: var(--color-header-bg--up-to-date); @@ -178,7 +178,7 @@ $spacing-left: 48px; } .pipeline-icon { - fill: colors.$black-900; + fill: variables.$black-900; } } @@ -193,7 +193,7 @@ $spacing-left: 48px; right: 0; top: 270px; width: $panel-width; - z-index: 2; + z-index: variables.$zindex-update-reminder-elements; &--up-to-date { height: calc(100vh - 144px); @@ -241,20 +241,20 @@ $spacing-left: 48px; position: absolute; top: 0; right: 0; - z-index: 2; + z-index: variables.$zindex-update-reminder-elements; &--outdated { &:hover { - background-color: colors.$yellow-0; + background-color: variables.$yellow-0; color: var(--color-version-tag--hover-outdated); > span { - background-color: colors.$yellow-600; + background-color: variables.$yellow-600; } } > span { - background-color: colors.$yellow-0; + background-color: variables.$yellow-0; border-radius: 50%; display: inline-block; height: 8px; diff --git a/src/styles/_variables.scss b/src/styles/_variables.scss index 83fc27b943..9898b080d6 100644 --- a/src/styles/_variables.scss +++ b/src/styles/_variables.scss @@ -27,11 +27,63 @@ $metadata-modal-breakpoint: 700px; $global-toolbar-width: 80px; $run-list-controls-height: 95px; -// -- levels -- // -$z-index-metadata-panel: 3; -$z-index-metadata-code: 3; -$z-index-MuiTreeItem-icon: 1; -$z-index-banner: 6; +// z-index values + +// z-index -1 +$zindex-switch-track: -1; +$zindex-group-background-fade: -1; + +// z-index 0 +$zindex-switch-root: 0; + +// z-index 1 +$zindex-MuiTreeItem-icon: 1; +$zindex-sidebar: 1; +$zindex-sticky-elements: 1; +$zindex-search-icon: 1; +$zindex-toolbar-label: 1; +$zindex-metadata-link: 1; +$zindex-nodelist-heading: 1; +$zindex-switch-base: 1; +$zindex-switch-input: 1; +$zindex-filter-panel: 1; +$zindex-split-handle-focus: 1; +$zindex-preview-table-header: 1; +$zindex-search-close-icon: 1; +$zindex-search-input-focused: 1; +$zindex-dropdown-btn-wrapper: 1; + +// z-index 2 +$zindex-ui: 2; +$zindex-toolbar: 2; +$zindex-nodelist-placeholder: 2; +$zindex-update-reminder-elements: 2; +$zindex-dropdown-options: 2; // fix closing transition animation bug + +// z-index 3 +$zindex-metadata-code: 3; +$zindex-sidebar-expanded: 3; +$zindex-plot-modal: 3; +$zindex-go-back-btn: 3; +$zindex-metadata-panel: 3; + +// z-index 5 +$zindex-feature-hints: 5; +$zindex-global-toolbar: 5; + +// z-index 6 +$zindex-modal: 6; +$zindex-metadata-modal: 6; +$zindex-banner: 6; + +// z-index 8 +$zindex-dropdown-label: 8; + +// z-index 9 +$zindex-tooltip: 9; + +// z-index 10 +$zindex-update-reminder: 10; // For the main update reminder // Micro-animation $run-width: 375px; From bed71c91f30c11612a0133d096370cbec2060c2f Mon Sep 17 00:00:00 2001 From: Jitendra Gundaniya <38945204+jitu5@users.noreply.github.com> Date: Thu, 26 Sep 2024 17:36:33 +0100 Subject: [PATCH 02/35] Extra Disclaimer word removed (#2115) Signed-off-by: Jitendra Gundaniya --- src/components/shareable-url-modal/main-view/main-view.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/shareable-url-modal/main-view/main-view.js b/src/components/shareable-url-modal/main-view/main-view.js index c2be7530a4..f0ad37ad37 100644 --- a/src/components/shareable-url-modal/main-view/main-view.js +++ b/src/components/shareable-url-modal/main-view/main-view.js @@ -43,8 +43,8 @@ const renderTextContent = (isPreviewEnabled, setIsPreviewEnabled) => { Disclaimer{' '}

- Disclaimer Kedro-Viz contains preview data for multiple datasets. You - can enable or disable all previews when publishing Kedro-Viz. + Kedro-Viz contains preview data for multiple datasets. You can enable or + disable all previews when publishing Kedro-Viz.

All dataset previews From 03d4c9b961dd00fc91cf0486248f050e85ec6750 Mon Sep 17 00:00:00 2001 From: Sajid Alam <90610031+SajidAlamQB@users.noreply.github.com> Date: Mon, 30 Sep 2024 13:17:37 +0100 Subject: [PATCH 03/35] Add troubleshooting note in `CONTRIBUTING.md` (#2110) * Update CONTRIBUTING.md Signed-off-by: Sajid Alam * add suppressions for too many args Signed-off-by: Sajid Alam * lint Signed-off-by: Sajid Alam * remove useless suppressions Signed-off-by: Sajid Alam * Update apps.py Signed-off-by: Sajid Alam * Update Makefile Signed-off-by: Sajid Alam * fix Signed-off-by: Sajid Alam * Update catalog.py Signed-off-by: Sajid Alam * Update catalog.py Signed-off-by: Sajid Alam * Update catalog.py Signed-off-by: Sajid Alam * revert and change max positional args Signed-off-by: Sajid Alam * disable too-many-positional-arguments Signed-off-by: Sajid Alam * Update Makefile Signed-off-by: Sajid Alam --------- Signed-off-by: Sajid Alam --- CONTRIBUTING.md | 5 +++++ package/.pylintrc | 2 +- package/kedro_viz/api/apps.py | 2 +- package/kedro_viz/data_access/repositories/catalog.py | 3 +-- 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9886fdf58b..eb3458d2c9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -139,6 +139,11 @@ Now you're ready to begin development. Start the development server: npm start ``` +> _*Note*_: If you face any issues running this, we recommend installing Node.js v18: +> +> 1. Delete `package-lock.json` and `node_modules`. +> 2. Run `npm install` to reinstall dependencies. + This will serve the app at [localhost:4141](http://localhost:4141/), and watch files in `/src` for changes. It will also update the `/lib` directory, which contains a Babel-compiled copy of the source. This directory is exported to `npm`, and is used when importing as a React component into another application. It is updated automatically when you save in case you need to test/debug it locally (e.g. with `npm link`). You can also update it manually, by running ```bash diff --git a/package/.pylintrc b/package/.pylintrc index a14d86bd4d..e8ae443c08 100644 --- a/package/.pylintrc +++ b/package/.pylintrc @@ -50,7 +50,7 @@ confidence= # --enable=similarities". If you want to run only the classes checker, but have # no Warning level messages displayed, use"--disable=all --enable=classes # --disable=W" -disable=ungrouped-imports,attribute-defined-outside-init,too-many-arguments,duplicate-code,fixme +disable=ungrouped-imports,attribute-defined-outside-init,too-many-arguments,duplicate-code,too-many-positional-arguments,fixme # Enable the message, report, category or checker with the given id(s). You can # either give multiple identifier separated by comma (,) or put this option diff --git a/package/kedro_viz/api/apps.py b/package/kedro_viz/api/apps.py index 7200ad4ab7..aef4d44715 100644 --- a/package/kedro_viz/api/apps.py +++ b/package/kedro_viz/api/apps.py @@ -42,7 +42,7 @@ def _create_base_api_app() -> FastAPI: @app.middleware("http") async def set_secure_headers(request, call_next): response = await call_next(request) - secure_headers.framework.fastapi(response) + secure_headers.framework.fastapi(response) # pylint: disable=no-member return response return app diff --git a/package/kedro_viz/data_access/repositories/catalog.py b/package/kedro_viz/data_access/repositories/catalog.py index 50fc5dce04..a7d568ca8a 100644 --- a/package/kedro_viz/data_access/repositories/catalog.py +++ b/package/kedro_viz/data_access/repositories/catalog.py @@ -131,8 +131,7 @@ def get_dataset(self, dataset_name: str) -> Optional["AbstractDataset"]: else: # pragma: no cover dataset_obj = self._catalog._get_dataset(dataset_name) except DatasetNotFoundError: - # pylint: disable=abstract-class-instantiated - dataset_obj = MemoryDataset() # type: ignore[abstract] + dataset_obj = MemoryDataset() # pylint: disable=abstract-class-instantiated return dataset_obj From 363f0dd765c19df78187ccc570758c86bdd29527 Mon Sep 17 00:00:00 2001 From: Jitendra Gundaniya <38945204+jitu5@users.noreply.github.com> Date: Mon, 30 Sep 2024 13:36:16 +0100 Subject: [PATCH 04/35] Aligned kedro-viz telemetry with kedro-telemetry (#2112) * Aligned kedro-viz telemetry with kedro-telemetry Signed-off-by: Jitendra Gundaniya * Test fix Signed-off-by: Jitendra Gundaniya * lint fix Signed-off-by: Jitendra Gundaniya * Test fix Signed-off-by: Jitendra Gundaniya * Release note added Signed-off-by: Jitendra Gundaniya * Lint fixes Signed-off-by: Jitendra Gundaniya * Lint fixes Signed-off-by: Jitendra Gundaniya * Lint fixes Signed-off-by: Jitendra Gundaniya * Lint fixes related to too-many-positional-arguments Signed-off-by: Jitendra Gundaniya * Lint fixes Signed-off-by: Jitendra Gundaniya --------- Signed-off-by: Jitendra Gundaniya --- RELEASE.md | 8 +++++ package/.pylintrc | 2 +- package/kedro_viz/data_access/managers.py | 1 + .../integrations/kedro/data_loader.py | 1 + .../kedro_viz/integrations/kedro/telemetry.py | 30 ++++++++----------- package/kedro_viz/launchers/cli/run.py | 2 +- package/kedro_viz/launchers/cli/utils.py | 1 + package/kedro_viz/models/flowchart.py | 2 ++ package/kedro_viz/server.py | 3 +- package/requirements.txt | 1 + package/tests/test_api/test_apps.py | 2 +- .../tests/test_integrations/test_telemetry.py | 22 +++++++++++--- 12 files changed, 50 insertions(+), 25 deletions(-) diff --git a/RELEASE.md b/RELEASE.md index 5e08f318d6..72cfee8cdb 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -5,6 +5,14 @@ Please follow the established format: - Use present tense (e.g. 'Add new feature') - Include the ID number for the related PR (or PRs) in parentheses --> + +# Release 10.1.0 + +## Major features and improvements + +- Update Kedro-Viz telemetry for opt-out model (#2022) + + # Release 10.0.0 ## Major features and improvements diff --git a/package/.pylintrc b/package/.pylintrc index e8ae443c08..bbf5e49a40 100644 --- a/package/.pylintrc +++ b/package/.pylintrc @@ -321,7 +321,7 @@ valid-metaclass-classmethod-first-arg=mcs [DESIGN] # Maximum number of arguments for function / method -max-args=5 +max-args=12 # Maximum number of attributes for a class (see R0902). max-attributes=7 diff --git a/package/kedro_viz/data_access/managers.py b/package/kedro_viz/data_access/managers.py index 9801c86cb7..40e00ebe55 100644 --- a/package/kedro_viz/data_access/managers.py +++ b/package/kedro_viz/data_access/managers.py @@ -230,6 +230,7 @@ def add_node( self.tags.add_tags(task_node.tags) return task_node + # pylint: disable=too-many-positional-arguments def add_node_input( self, registered_pipeline_id: str, diff --git a/package/kedro_viz/integrations/kedro/data_loader.py b/package/kedro_viz/integrations/kedro/data_loader.py index 2955d73b29..aabc5b73a5 100644 --- a/package/kedro_viz/integrations/kedro/data_loader.py +++ b/package/kedro_viz/integrations/kedro/data_loader.py @@ -108,6 +108,7 @@ def _load_data_helper( return catalog, pipelines_dict, session_store, stats_dict +# pylint: disable=too-many-positional-arguments def load_data( project_path: Path, env: Optional[str] = None, diff --git a/package/kedro_viz/integrations/kedro/telemetry.py b/package/kedro_viz/integrations/kedro/telemetry.py index 45bcefb5d8..2c57c41536 100644 --- a/package/kedro_viz/integrations/kedro/telemetry.py +++ b/package/kedro_viz/integrations/kedro/telemetry.py @@ -1,14 +1,15 @@ """`kedro_viz.integrations.kedro.telemetry` helps integrate Kedro-Viz with Kedro-Telemetry """ -import hashlib -import socket + from pathlib import Path from typing import Optional -import yaml - try: - from kedro_telemetry.plugin import _get_heap_app_id, _is_valid_syntax + from kedro_telemetry.plugin import ( + _check_for_telemetry_consent, + _get_heap_app_id, + _get_or_create_uuid, + ) _IS_TELEMETRY_INSTALLED = True except ImportError: # pragma: no cover @@ -19,23 +20,18 @@ def get_heap_app_id(project_path: Path) -> Optional[str]: """Return the Heap App ID used for Kedro telemetry if user has given consent.""" if not _IS_TELEMETRY_INSTALLED: # pragma: no cover return None - telemetry_file_path = project_path / ".telemetry" - if not telemetry_file_path.exists(): - return None - with open( - telemetry_file_path, encoding="utf8" - ) as telemetry_file: # pylint: disable: unspecified-encoding - telemetry = yaml.safe_load(telemetry_file) - if _is_valid_syntax(telemetry) and telemetry["consent"]: - return _get_heap_app_id() + + if _check_for_telemetry_consent(project_path): + return _get_heap_app_id() return None +# pylint: disable=broad-exception-caught def get_heap_identity() -> Optional[str]: # pragma: no cover - """Return the user ID in heap identical to the id used by kedro-telemetry plugin.""" + """Reads a UUID from a configuration file or generates and saves a new one if not present.""" if not _IS_TELEMETRY_INSTALLED: return None try: - return hashlib.sha512(bytes(socket.gethostname(), encoding="utf8")).hexdigest() - except socket.timeout: + return _get_or_create_uuid() + except Exception: # pragma: no cover return None diff --git a/package/kedro_viz/launchers/cli/run.py b/package/kedro_viz/launchers/cli/run.py index 9988214261..97c9ab3dbc 100644 --- a/package/kedro_viz/launchers/cli/run.py +++ b/package/kedro_viz/launchers/cli/run.py @@ -83,7 +83,7 @@ is_flag=True, help="An experimental flag to open Kedro-Viz without Kedro project dependencies", ) -# pylint: disable=import-outside-toplevel, too-many-locals +# pylint: disable=import-outside-toplevel, too-many-locals, too-many-positional-arguments def run( host, port, diff --git a/package/kedro_viz/launchers/cli/utils.py b/package/kedro_viz/launchers/cli/utils.py index eb4efdfbc9..290a0461c0 100644 --- a/package/kedro_viz/launchers/cli/utils.py +++ b/package/kedro_viz/launchers/cli/utils.py @@ -114,6 +114,7 @@ def display_cli_message(msg, msg_color=None): ) +# pylint: disable=too-many-positional-arguments def _load_and_deploy_viz( platform, is_all_previews_enabled, diff --git a/package/kedro_viz/models/flowchart.py b/package/kedro_viz/models/flowchart.py index e8e81cfb61..9e6cb087d4 100644 --- a/package/kedro_viz/models/flowchart.py +++ b/package/kedro_viz/models/flowchart.py @@ -164,6 +164,7 @@ def create_task_node( ) @classmethod + # pylint: disable=too-many-positional-arguments def create_data_node( cls, dataset_id: str, @@ -216,6 +217,7 @@ def create_data_node( ) @classmethod + # pylint: disable=too-many-positional-arguments def create_parameters_node( cls, dataset_id: str, diff --git a/package/kedro_viz/server.py b/package/kedro_viz/server.py index 76026ddbbf..37d31f2f19 100644 --- a/package/kedro_viz/server.py +++ b/package/kedro_viz/server.py @@ -44,6 +44,7 @@ def populate_data( data_access_manager.add_pipelines(pipelines) +# pylint: disable=too-many-positional-arguments def load_and_populate_data( path: Path, env: Optional[str] = None, @@ -70,7 +71,7 @@ def load_and_populate_data( populate_data(data_access_manager, catalog, pipelines, session_store, stats_dict) -# pylint: disable=too-many-locals +# pylint: disable=too-many-positional-arguments, too-many-locals def run_server( host: str = DEFAULT_HOST, port: int = DEFAULT_PORT, diff --git a/package/requirements.txt b/package/requirements.txt index 2b1dad563f..caf3fa63ea 100644 --- a/package/requirements.txt +++ b/package/requirements.txt @@ -4,6 +4,7 @@ fastapi>=0.100.0,<0.200.0 fsspec>=2021.4 ipython>=7.0.0, <9.0 kedro>=0.18.0 +kedro-telemetry>=0.6.0 networkx>=2.5 orjson>=3.9, <4.0 packaging>=23.0 diff --git a/package/tests/test_api/test_apps.py b/package/tests/test_api/test_apps.py index 310c2b679f..38e7b427ad 100644 --- a/package/tests/test_api/test_apps.py +++ b/package/tests/test_api/test_apps.py @@ -10,7 +10,7 @@ class TestIndexEndpoint: def test_index(self, client): response = client.get("/") assert response.status_code == 200 - assert "heap" not in response.text + assert "heap" in response.text assert "checkReloadStatus" not in response.text @mock.patch("kedro_viz.integrations.kedro.telemetry.get_heap_app_id") diff --git a/package/tests/test_integrations/test_telemetry.py b/package/tests/test_integrations/test_telemetry.py index f5bae4a613..f8fbfc8a26 100644 --- a/package/tests/test_integrations/test_telemetry.py +++ b/package/tests/test_integrations/test_telemetry.py @@ -5,13 +5,13 @@ def test_get_heap_app_id_no_telemetry_file(): - assert kedro_telemetry.get_heap_app_id(Path.cwd()) is None + assert kedro_telemetry.get_heap_app_id(Path.cwd()) is not None def test_get_heap_app_id_invalid_telemetry_file(tmpdir): telemetry_file = tmpdir / ".telemetry" telemetry_file.write_text("foo", encoding="utf-8") - assert kedro_telemetry.get_heap_app_id(tmpdir) is None + assert kedro_telemetry.get_heap_app_id(tmpdir) is not None def test_get_heap_app_id_no_consent(tmpdir): @@ -21,8 +21,22 @@ def test_get_heap_app_id_no_consent(tmpdir): @mock.patch("kedro_viz.integrations.kedro.telemetry._get_heap_app_id") -def test_get_heap_app_id_with_consent(original_get_heap_app_id, tmpdir): - original_get_heap_app_id.return_value = "my_heap_id" +@mock.patch("kedro_viz.integrations.kedro.telemetry._check_for_telemetry_consent") +def test_get_heap_app_id_with_consent( + mock_check_for_telemetry_consent, mock_get_heap_app_id, tmpdir +): + mock_check_for_telemetry_consent.return_value = True + mock_get_heap_app_id.return_value = "my_heap_id" telemetry_file = tmpdir / ".telemetry" telemetry_file.write_text("consent: true", encoding="utf-8") assert kedro_telemetry.get_heap_app_id(tmpdir) == "my_heap_id" + + +@mock.patch("kedro_viz.integrations.kedro.telemetry._check_for_telemetry_consent") +def test_get_heap_app_id_with_env_var(mock_check_for_telemetry_consent, tmpdir): + mock_check_for_telemetry_consent.return_value = False + with mock.patch.dict("os.environ", {"DO_NOT_TRACK": "1"}): + assert kedro_telemetry.get_heap_app_id(tmpdir) is None + + with mock.patch.dict("os.environ", {"KEDRO_DISABLE_TELEMETRY": "1"}): + assert kedro_telemetry.get_heap_app_id(tmpdir) is None From 34bcb55fea2c3ac4e51ae58454a0a1f044b60f39 Mon Sep 17 00:00:00 2001 From: Tynan DeBold Date: Thu, 3 Oct 2024 21:56:34 +0200 Subject: [PATCH 05/35] Replace CircleCI badge in readme with GitHub actions (#2124) * Replace CircleCI badge in readme with GitHub actions * Update the other readme file --- README.npm.md | 49 +++++++++++++++++++++++------------------------ package/README.md | 2 +- 2 files changed, 25 insertions(+), 26 deletions(-) diff --git a/README.npm.md b/README.npm.md index 038bf75e46..f4632b88de 100644 --- a/README.npm.md +++ b/README.npm.md @@ -15,7 +15,7 @@ Live Demo: https://demo.kedro.
-[![CircleCI](https://circleci.com/gh/kedro-org/kedro-viz/tree/main.svg?style=shield)](https://circleci.com/gh/kedro-org/kedro-viz/tree/main) +[![GitHub Actions](https://img.shields.io/github/actions/workflow/status/kedro-org/kedro-viz/merge-gatekeeper.yml?label=Actions&logo=github)](https://github.com/kedro-org/kedro-viz/actions) [![Documentation](https://readthedocs.org/projects/kedro/badge/?version=stable)](https://docs.kedro.org/en/stable/visualisation/) [![Python Version](https://img.shields.io/badge/python-3.9%20%7C%203.10%20%7C%203.11-orange.svg)](https://pypi.org/project/kedro-viz/) [![PyPI version](https://img.shields.io/pypi/v/kedro-viz.svg?color=yellow)](https://pypi.org/project/kedro-viz/) @@ -67,7 +67,7 @@ const MyApp = () => ( options={/* Options to configure Kedro Viz */} />
-); +); ``` To use with NextJS: @@ -89,7 +89,7 @@ The JSON can be obtained by running: kedro viz run --save-file= ``` -On successful execution of the command above, it will generate a folder at the specified directory, containing the following structure: +On successful execution of the command above, it will generate a folder at the specified directory, containing the following structure: ``` /api/ @@ -116,7 +116,7 @@ We also recommend wrapping the `Kedro-Viz` component with a parent HTML/JSX elem ## Configure Kedro-viz with `options` -The example below demonstrates how to configure your kedro-viz using different `options`. +The example below demonstrates how to configure your kedro-viz using different `options`. ``` : boolean}}` | - | Configuration for tag options | -| options.theme | string | dark | select `Kedro-Viz` theme : dark/light | - +| Name | Type | Default | Description | +| -------------------------- | ---------------------------------------------------------------------------------- | -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `data` | `{ edges: array (required), layers: array, nodes: array (required), tags: array }` | - | Pipeline data to be displayed on the chart | +| `onActionCallback` | function | - | Callback function to be invoked when the specified action is dispatched. e.g. `const action = { type: NODE_CLICK, payload: node }; onActionCallback(action);` | +| options.display | | | | +| `expandPipelinesBtn` | boolean | true | Show/Hide expand pipelines button | +| `exportBtn` | boolean | true | Show/Hide export button | +| `globalNavigation` | boolean | true | Show/Hide global navigation | +| `labelBtn` | boolean | true | Show/Hide label button | +| `layerBtn` | boolean | true | Show/Hide layer button | +| `metadataPanel` | boolean | true | Show/Hide Metadata Panel | +| `miniMap` | boolean | true | Show/Hide Mini map and mini map button | +| `sidebar` | boolean | true | Show/Hide Sidebar and action toolbar | +| `zoomToolbar` | boolean | true | Show/Hide zoom-in, zoom-out and zoom reset buttons together | +| options.expandAllPipelines | boolean | false | Expand/Collapse Modular pipelines on first load | +| options.nodeType | `{disabled: {parameters: boolean,task: boolean,data: boolean}}` | `{disabled: {parameters: true,task: false,data: false}}` | Configuration for node type options | +| options.tag | `{enabled: {: boolean}}` | - | Configuration for tag options | +| options.theme | string | dark | select `Kedro-Viz` theme : dark/light | ### Note + - `onActionCallback` callback is only called when the user clicks on a node in the flowchart, and we are passing the node object as the payload in the callback argument. In future releases, we will add more actions to be dispatched in this callback. - When `display.sidebar` is `false`, `display.miniMap` prop will be ignored. @@ -174,7 +174,6 @@ All components are annotated to understand their positions in the Kedro-Viz UI. ![Kedro-Viz component annotation](https://raw.githubusercontent.com/kedro-org/kedro-viz/main/.github/img/kedro_viz_annotation.png) - ## Standalone Example Repository We have created a [kedro-viz-standalone](https://github.com/kedro-org/kedro-viz-standalone.git) repository to demonstrate how to use Kedro-Viz in standalone mode or embedded in a React application. diff --git a/package/README.md b/package/README.md index cd8998d2e9..56bcbffc21 100644 --- a/package/README.md +++ b/package/README.md @@ -15,7 +15,7 @@ Live Demo: https://demo.kedro.
-[![CircleCI](https://circleci.com/gh/kedro-org/kedro-viz/tree/main.svg?style=shield)](https://circleci.com/gh/kedro-org/kedro-viz/tree/main) +[![GitHub Actions](https://img.shields.io/github/actions/workflow/status/kedro-org/kedro-viz/merge-gatekeeper.yml?label=Actions&logo=github)](https://github.com/kedro-org/kedro-viz/actions) [![Documentation](https://readthedocs.org/projects/kedro/badge/?version=stable)](https://docs.kedro.org/en/stable/visualisation/) [![Python Version](https://img.shields.io/badge/python-3.9%20%7C%203.10%20%7C%203.11-orange.svg)](https://pypi.org/project/kedro-viz/) [![PyPI version](https://img.shields.io/pypi/v/kedro-viz.svg?color=yellow)](https://pypi.org/project/kedro-viz/) From ed0e6aaff9c213473ea1218cf6390e7eefc4bd69 Mon Sep 17 00:00:00 2001 From: Ravi Kumar Pilla Date: Mon, 7 Oct 2024 16:42:46 -0500 Subject: [PATCH 06/35] Fix unserializable parameters value (#2122) * sync remote * custom resolver fix initial draft * fix lint * fix lint * update release note * use json_encoder --- RELEASE.md | 3 ++ .../data_access/repositories/catalog.py | 2 +- package/kedro_viz/models/flowchart.py | 17 +++++++- package/kedro_viz/models/utils.py | 1 + package/tests/test_models/test_flowchart.py | 42 ++++++++++++++++++- 5 files changed, 62 insertions(+), 3 deletions(-) diff --git a/RELEASE.md b/RELEASE.md index 72cfee8cdb..b93010273a 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -12,6 +12,9 @@ Please follow the established format: - Update Kedro-Viz telemetry for opt-out model (#2022) +## Bug fixes and other changes + +- Fix unserializable parameters value (#2122) # Release 10.0.0 diff --git a/package/kedro_viz/data_access/repositories/catalog.py b/package/kedro_viz/data_access/repositories/catalog.py index a7d568ca8a..38d9a6772d 100644 --- a/package/kedro_viz/data_access/repositories/catalog.py +++ b/package/kedro_viz/data_access/repositories/catalog.py @@ -131,7 +131,7 @@ def get_dataset(self, dataset_name: str) -> Optional["AbstractDataset"]: else: # pragma: no cover dataset_obj = self._catalog._get_dataset(dataset_name) except DatasetNotFoundError: - dataset_obj = MemoryDataset() # pylint: disable=abstract-class-instantiated + dataset_obj = MemoryDataset() return dataset_obj diff --git a/package/kedro_viz/models/flowchart.py b/package/kedro_viz/models/flowchart.py index 9e6cb087d4..8828650a7e 100644 --- a/package/kedro_viz/models/flowchart.py +++ b/package/kedro_viz/models/flowchart.py @@ -9,6 +9,7 @@ from types import FunctionType from typing import Any, ClassVar, Dict, List, Optional, Set, Union, cast +from fastapi.encoders import jsonable_encoder from kedro.pipeline.node import Node as KedroNode from pydantic import ( BaseModel, @@ -861,7 +862,13 @@ def parameter_value(self) -> Any: return None try: - return self.kedro_obj.load() + actual_parameter_value = self.kedro_obj.load() + # Return only json serializable value + return jsonable_encoder(actual_parameter_value) + except (TypeError, ValueError, RecursionError): + # In case the parameter is not JSON serializable, + # return the string representation + return str(actual_parameter_value) except (AttributeError, DatasetError): # This except clause triggers if the user passes a parameter that is not # defined in the catalog (DatasetError) it also catches any case where @@ -870,6 +877,14 @@ def parameter_value(self) -> Any: "Cannot find parameter `%s` in the catalog.", self.parameter_name ) return None + # pylint: disable=broad-exception-caught + except Exception as exc: # pragma: no cover + logger.error( + "An error occurred when loading parameter `%s` in the catalog :: %s", + self.parameter_name, + exc, + ) + return None class ParametersNodeMetadata(GraphNodeMetadata): diff --git a/package/kedro_viz/models/utils.py b/package/kedro_viz/models/utils.py index 1749946a3f..0024f13e22 100644 --- a/package/kedro_viz/models/utils.py +++ b/package/kedro_viz/models/utils.py @@ -1,4 +1,5 @@ """`kedro_viz.models.utils` contains utility functions used in the `kedro_viz.models` package""" + import logging from typing import TYPE_CHECKING diff --git a/package/tests/test_models/test_flowchart.py b/package/tests/test_models/test_flowchart.py index 521cc419f8..eab5283817 100644 --- a/package/tests/test_models/test_flowchart.py +++ b/package/tests/test_models/test_flowchart.py @@ -1,7 +1,7 @@ from functools import partial from pathlib import Path from textwrap import dedent -from unittest.mock import call, patch +from unittest.mock import Mock, call, patch import pytest from kedro.io import MemoryDataset @@ -215,6 +215,46 @@ def test_create_parameters_node_single_parameter( assert parameters_node.parameter_value == 0.3 assert parameters_node.modular_pipelines == expected_modular_pipelines + def test_create_single_parameter_with_complex_type(self): + parameters_dataset = MemoryDataset(data=object()) + parameters_node = GraphNode.create_parameters_node( + dataset_id="params:test_split_ratio", + dataset_name="params:test_split_ratio", + layer=None, + tags=set(), + parameters=parameters_dataset, + modular_pipelines=set(), + ) + assert isinstance(parameters_node, ParametersNode) + assert parameters_node.kedro_obj is parameters_dataset + assert not parameters_node.is_all_parameters() + assert parameters_node.is_single_parameter() + assert isinstance(parameters_node.parameter_value, str) + + def test_create_all_parameters_with_complex_type(self): + mock_object = Mock() + parameters_dataset = MemoryDataset( + data={ + "test_split_ratio": 0.3, + "num_epochs": 1000, + "complex_param": mock_object, + } + ) + parameters_node = GraphNode.create_parameters_node( + dataset_id="parameters", + dataset_name="parameters", + layer=None, + tags=set(), + parameters=parameters_dataset, + modular_pipelines=set(), + ) + assert isinstance(parameters_node, ParametersNode) + assert parameters_node.kedro_obj is parameters_dataset + assert parameters_node.id == "parameters" + assert parameters_node.is_all_parameters() + assert not parameters_node.is_single_parameter() + assert isinstance(parameters_node.parameter_value, str) + def test_create_non_existing_parameter_node(self): """Test the case where ``parameters`` is equal to None""" parameters_node = GraphNode.create_parameters_node( From 69caab407ee88a00579fd2fc684fb38f9dc50481 Mon Sep 17 00:00:00 2001 From: Jitendra Gundaniya <38945204+jitu5@users.noreply.github.com> Date: Thu, 10 Oct 2024 11:48:37 +0100 Subject: [PATCH 07/35] Improve `kedro viz build` usage documentation (#2126) * Updated build related docs Signed-off-by: Jitendra Gundaniya * Release note added Signed-off-by: Jitendra Gundaniya * Update docs/source/platform_agnostic_sharing_with_kedro_viz.md Co-authored-by: rashidakanchwala <37628668+rashidakanchwala@users.noreply.github.com> Signed-off-by: Jitendra Gundaniya <38945204+jitu5@users.noreply.github.com> * review suggestion. Signed-off-by: Jitendra Gundaniya --------- Signed-off-by: Jitendra Gundaniya Signed-off-by: Jitendra Gundaniya <38945204+jitu5@users.noreply.github.com> Co-authored-by: rashidakanchwala <37628668+rashidakanchwala@users.noreply.github.com> --- RELEASE.md | 2 ++ ...latform_agnostic_sharing_with_kedro_viz.md | 28 +++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/RELEASE.md b/RELEASE.md index b93010273a..e68596830b 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -14,8 +14,10 @@ Please follow the established format: ## Bug fixes and other changes +- Improve `kedro viz build` usage documentation (#2126) - Fix unserializable parameters value (#2122) + # Release 10.0.0 ## Major features and improvements diff --git a/docs/source/platform_agnostic_sharing_with_kedro_viz.md b/docs/source/platform_agnostic_sharing_with_kedro_viz.md index 228e875d42..a08c1adac4 100644 --- a/docs/source/platform_agnostic_sharing_with_kedro_viz.md +++ b/docs/source/platform_agnostic_sharing_with_kedro_viz.md @@ -46,6 +46,34 @@ Starting from Kedro-Viz 9.2.0, `kedro viz build` will not include dataset previe This creates a `build` folder containing your Kedro-Viz app package in your project directory. +## Running Kedro-Viz Locally + +When you generate the build folder using the command `kedro viz build`, it creates a build directory with an `index.html` file, which serves as the entry point for visualizing your pipeline. + + +To view your pipeline visualization correctly, you need to serve `index.html` using an HTTP server. Here are a few simple ways to do this: + +1. Python's Built-in HTTP Server: + - Navigate to the build directory and run: + ```bash + python -m http.server + ``` + - This starts a web server at `http://localhost:8000`, which you can use to view index.html. + +2. Node's http-server: + - First, install it globally: + ```bash + npm install -g http-server + ``` + - Then, run it from the build directory: + ```bash + http-server + ``` + +```{warning} +Simply opening `index.html` using the `file://` protocol is not supported due to Cross-Origin Resource Sharing (CORS) policies in modern browsers. +``` + ## Static website hosting platforms such as GitHub Pages Follow the steps [listed in the GitHub pages documentation](https://docs.github.com/en/pages/quickstart) to create a Git repository that supports GitHub Pages. On completion, push the contents of the previously created `build` folder to this new repository. Your site will be available at the following URL: `http://.github.io` From e83fc629e627c25e07fdd914188433a8558a551c Mon Sep 17 00:00:00 2001 From: Ravi Kumar Pilla Date: Thu, 10 Oct 2024 14:12:31 -0500 Subject: [PATCH 08/35] Fix max recursion depth build issue (#2130) * sync remote * fix deep recursions * fix deep recursions * fix deep recursions * revert win test condition * revert win test condition * revert win test condition --- package/tests/test_models/test_flowchart.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package/tests/test_models/test_flowchart.py b/package/tests/test_models/test_flowchart.py index eab5283817..01238f286d 100644 --- a/package/tests/test_models/test_flowchart.py +++ b/package/tests/test_models/test_flowchart.py @@ -1,7 +1,7 @@ from functools import partial from pathlib import Path from textwrap import dedent -from unittest.mock import Mock, call, patch +from unittest.mock import call, patch import pytest from kedro.io import MemoryDataset @@ -232,7 +232,7 @@ def test_create_single_parameter_with_complex_type(self): assert isinstance(parameters_node.parameter_value, str) def test_create_all_parameters_with_complex_type(self): - mock_object = Mock() + mock_object = object() parameters_dataset = MemoryDataset( data={ "test_split_ratio": 0.3, From 90355f8d47e9942fdc64646b07ba525fe2837eb9 Mon Sep 17 00:00:00 2001 From: Sajid Alam <90610031+SajidAlamQB@users.noreply.github.com> Date: Mon, 14 Oct 2024 11:40:48 +0100 Subject: [PATCH 09/35] Display Dataset Type with Library Prefix in Metadata Panel (#2136) * add qualifier method to get first and last part Signed-off-by: Sajid Alam * fix tests Signed-off-by: Sajid Alam * Update RELEASE.md Signed-off-by: Sajid Alam --------- Signed-off-by: Sajid Alam --- RELEASE.md | 1 + src/components/metadata/metadata.js | 13 ++++++++++--- src/components/metadata/metadata.test.js | 6 +++--- 3 files changed, 14 insertions(+), 6 deletions(-) diff --git a/RELEASE.md b/RELEASE.md index e68596830b..e0b2954250 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -16,6 +16,7 @@ Please follow the established format: - Improve `kedro viz build` usage documentation (#2126) - Fix unserializable parameters value (#2122) +- Display full dataset type with library prefix in metadata panel (#2136) # Release 10.0.0 diff --git a/src/components/metadata/metadata.js b/src/components/metadata/metadata.js index 49241e000d..5b0ed7080f 100644 --- a/src/components/metadata/metadata.js +++ b/src/components/metadata/metadata.js @@ -121,10 +121,17 @@ const MetaData = ({ const shortenDatasetType = (value) => { const isList = Array.isArray(value); + // Extract the library (first part) and the dataset type (last part) + const getQualifier = (val) => { + if (typeof val === 'string' && val.includes('.')) { + const parts = val.split('.'); + return `${parts[0]}.${parts.pop()}`; + } + // If val is not a string or does not include a dot return as is + return val; + }; - return isList - ? value.map((val) => val.split('.').pop()) - : value?.split('.').pop(); + return isList ? value.map(getQualifier) : getQualifier(value); }; return ( diff --git a/src/components/metadata/metadata.test.js b/src/components/metadata/metadata.test.js index 12ab05d395..849d8cc3e4 100644 --- a/src/components/metadata/metadata.test.js +++ b/src/components/metadata/metadata.test.js @@ -303,7 +303,7 @@ describe('MetaData', () => { mockMetadata: nodeData, }); const row = rowByLabel(wrapper, 'Dataset Type:'); - expect(textOf(rowValue(row))).toEqual(['CSVDataset']); + expect(textOf(rowValue(row))).toEqual(['pandas.CSVDataset']); }); it('shows the node filepath', () => { @@ -402,7 +402,7 @@ describe('MetaData', () => { mockMetadata: nodeTranscodedData, }); const row = rowByLabel(wrapper, 'Original Type:'); - expect(textOf(rowValue(row))).toEqual(['SparkDataset']); + expect(textOf(rowValue(row))).toEqual(['spark.SparkDataset']); }); it('shows the node transcoded type', () => { @@ -411,7 +411,7 @@ describe('MetaData', () => { mockMetadata: nodeTranscodedData, }); const row = rowByLabel(wrapper, 'Transcoded Types:'); - expect(textOf(rowValue(row))).toEqual(['ParquetDataset']); + expect(textOf(rowValue(row))).toEqual(['pandas.ParquetDataset']); }); }); describe('Metrics dataset nodes', () => { From 2eb18d97f010025d16a0bba741a0b162afeeec39 Mon Sep 17 00:00:00 2001 From: rashidakanchwala <37628668+rashidakanchwala@users.noreply.github.com> Date: Mon, 14 Oct 2024 12:09:17 +0100 Subject: [PATCH 10/35] Document python version policies and other small doc updates. (#2139) --- docs/source/kedro-viz_visualisation.md | 34 +++++++++++++++++--------- package/README.md | 11 ++++----- 2 files changed, 27 insertions(+), 18 deletions(-) diff --git a/docs/source/kedro-viz_visualisation.md b/docs/source/kedro-viz_visualisation.md index b650d32f05..db244697ea 100644 --- a/docs/source/kedro-viz_visualisation.md +++ b/docs/source/kedro-viz_visualisation.md @@ -9,6 +9,16 @@ If you haven't installed Kedro {doc}`follow the documentation to get set up Date: Mon, 14 Oct 2024 12:51:44 +0100 Subject: [PATCH 11/35] Fix Kedro in Azure ML with SQLiteSessionStore (#2131) * Update database.py Signed-off-by: Sajid Alam * Update database.py Signed-off-by: Sajid Alam * Update database.py Signed-off-by: Sajid Alam * Update database.py Signed-off-by: Sajid Alam * Update database.py Signed-off-by: Sajid Alam * Update database.py Signed-off-by: Sajid Alam * lint Signed-off-by: Sajid Alam * add test Signed-off-by: Sajid Alam * Update database.py Signed-off-by: Sajid Alam * revert comments Signed-off-by: Sajid Alam * separate azure logic Signed-off-by: Sajid Alam * Update RELEASE.md Signed-off-by: Sajid Alam * Update RELEASE.md Signed-off-by: Sajid Alam <90610031+SajidAlamQB@users.noreply.github.com> --------- Signed-off-by: Sajid Alam Signed-off-by: Sajid Alam <90610031+SajidAlamQB@users.noreply.github.com> --- RELEASE.md | 1 + package/kedro_viz/database.py | 29 +++++++++++++++++-- .../test_integrations/test_sqlite_store.py | 21 +++++++++++++- 3 files changed, 47 insertions(+), 4 deletions(-) diff --git a/RELEASE.md b/RELEASE.md index e0b2954250..66d6a51a24 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -17,6 +17,7 @@ Please follow the established format: - Improve `kedro viz build` usage documentation (#2126) - Fix unserializable parameters value (#2122) - Display full dataset type with library prefix in metadata panel (#2136) +- Enable SQLite WAL mode for Azure ML to fix database locking issues (#2131) # Release 10.0.0 diff --git a/package/kedro_viz/database.py b/package/kedro_viz/database.py index 4d97ca3187..5a62e32bcb 100644 --- a/package/kedro_viz/database.py +++ b/package/kedro_viz/database.py @@ -1,18 +1,41 @@ """Database management layer based on SQLAlchemy""" -from sqlalchemy import create_engine +import os + +from sqlalchemy import create_engine, text from sqlalchemy.orm import sessionmaker from kedro_viz.models.experiment_tracking import Base +def configure_wal_for_azure(engine): + """Applies WAL mode to SQLite if running in an Azure ML environment.""" + is_azure_ml = any( + var in os.environ + for var in [ + "AZUREML_ARM_SUBSCRIPTION", + "AZUREML_ARM_RESOURCEGROUP", + "AZUREML_RUN_ID", + ] + ) + if is_azure_ml: + with engine.connect() as conn: + conn.execute(text("PRAGMA journal_mode=WAL;")) + + def make_db_session_factory(session_store_location: str) -> sessionmaker: """SQLAlchemy connection to a SQLite DB""" database_url = f"sqlite:///{session_store_location}" engine = create_engine(database_url, connect_args={"check_same_thread": False}) - session_class = sessionmaker(engine) # TODO: making db session factory shouldn't depend on models. # So want to move the table creation elsewhere ideally. # But this means returning engine as well as session class. + + # Check if we are running in an Azure ML environment if so enable WAL mode. + configure_wal_for_azure(engine) + + # Create the database tables if they do not exist. Base.metadata.create_all(bind=engine) - return session_class + + # Return a session factory bound to the engine. + return sessionmaker(bind=engine) diff --git a/package/tests/test_integrations/test_sqlite_store.py b/package/tests/test_integrations/test_sqlite_store.py index a90d72b9bd..ec14c68730 100644 --- a/package/tests/test_integrations/test_sqlite_store.py +++ b/package/tests/test_integrations/test_sqlite_store.py @@ -8,7 +8,7 @@ import boto3 import pytest from moto import mock_aws -from sqlalchemy import create_engine, func, select +from sqlalchemy import create_engine, func, select, text from sqlalchemy.orm import sessionmaker from kedro_viz.database import make_db_session_factory @@ -372,3 +372,22 @@ def test_sync_with_merge_error(self, mocker, store_path, remote_path, caplog): mock_merge.assert_called_once() mock_upload.assert_called_once() assert "Merge failed on sync: Merge failed" in caplog.text + + def test_make_db_session_factory_with_azure_env_var(self, mocker, tmp_path): + """Test that WAL mode is enabled when running in an Azure environment.""" + mocker.patch.dict( + os.environ, + { + "AZUREML_ARM_SUBSCRIPTION": "dummy_value", + "AZUREML_ARM_RESOURCEGROUP": "dummy_value", + }, + ) + db_location = str(tmp_path / "test_session_store.db") + session_class = make_db_session_factory(db_location) + + # Ensure that the session can be created without issues. + with session_class() as session: + assert session is not None + # Check if the database is using WAL mode by querying the PRAGMA + result = session.execute(text("PRAGMA journal_mode;")).scalar() + assert result == "wal" From ddb2386a8a9469337073a26f9b57e71f64baf93c Mon Sep 17 00:00:00 2001 From: rashidakanchwala <37628668+rashidakanchwala@users.noreply.github.com> Date: Fri, 25 Oct 2024 11:11:23 +0100 Subject: [PATCH 12/35] Fix FE when `tag` name is `undefined` (#2146) Partly resolves #2106 --- src/selectors/tags.js | 3 ++- src/utils/index.js | 3 +++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/selectors/tags.js b/src/selectors/tags.js index d2a55869d7..7f040c7c4d 100644 --- a/src/selectors/tags.js +++ b/src/selectors/tags.js @@ -1,5 +1,6 @@ import { createSelector } from 'reselect'; import { getPipelineTagIDs } from './pipeline'; +import { prettifyName } from '../utils'; const getNodeTags = (state) => state.node.tags; const getTagName = (state) => state.tag.name; @@ -14,7 +15,7 @@ export const getTagData = createSelector( (tagIDs, tagName, tagActive, tagEnabled) => tagIDs.sort().map((id) => ({ id, - name: tagName[id], + name: tagName[id] || prettifyName(id), active: Boolean(tagActive[id]), enabled: Boolean(tagEnabled[id]), })) diff --git a/src/utils/index.js b/src/utils/index.js index 2b3e1b9061..83804e0a6c 100644 --- a/src/utils/index.js +++ b/src/utils/index.js @@ -121,6 +121,9 @@ export const stripNamespace = (str) => { * @returns {String} The string with or without replaced values */ export const prettifyName = (str) => { + if (!str) { + return ''; + } const replacedString = str .replace(/-/g, ' ') .replace(/_/g, ' ') From 712c5cfd2e8976288f6bd719b2e06acfc6c7158d Mon Sep 17 00:00:00 2001 From: Ravi Kumar Pilla Date: Fri, 25 Oct 2024 08:51:27 -0500 Subject: [PATCH 13/35] Add e2e test for lite mode and support earliest Kedro version (#2148) --- package/features/viz.feature | 6 ++++++ package/kedro_viz/data_access/managers.py | 9 ++++++++- .../integrations/kedro/abstract_dataset_lite.py | 15 ++++++++++++++- .../kedro_viz/integrations/kedro/data_loader.py | 9 ++++++++- package/kedro_viz/integrations/utils.py | 7 ++++++- 5 files changed, 42 insertions(+), 4 deletions(-) diff --git a/package/features/viz.feature b/package/features/viz.feature index d3c01e2f7f..dfe78dd5db 100644 --- a/package/features/viz.feature +++ b/package/features/viz.feature @@ -24,6 +24,12 @@ Feature: Viz plugin in new project When I execute the kedro viz run command Then kedro-viz should start successfully + Scenario: Execute viz lite with the earliest Kedro version that it supports + Given I have installed kedro version "0.18.3" + And I have run a non-interactive kedro new with pandas-iris starter + When I execute the kedro viz run command with lite option + Then kedro-viz should start successfully + Scenario: Execute viz lite with latest Kedro Given I have installed kedro version "latest" And I have run a non-interactive kedro new with spaceflights-pandas starter diff --git a/package/kedro_viz/data_access/managers.py b/package/kedro_viz/data_access/managers.py index 40e00ebe55..2799bf5f21 100644 --- a/package/kedro_viz/data_access/managers.py +++ b/package/kedro_viz/data_access/managers.py @@ -7,7 +7,14 @@ import networkx as nx from kedro.io import DataCatalog -from kedro.io.core import DatasetError + +try: + # kedro 0.18.11 onwards + from kedro.io.core import DatasetError +except ImportError: # pragma: no cover + # older versions + from kedro.io.core import DataSetError as DatasetError # type: ignore + from kedro.pipeline import Pipeline as KedroPipeline from kedro.pipeline.node import Node as KedroNode from sqlalchemy.orm import sessionmaker diff --git a/package/kedro_viz/integrations/kedro/abstract_dataset_lite.py b/package/kedro_viz/integrations/kedro/abstract_dataset_lite.py index 582130de00..f7317f4d18 100644 --- a/package/kedro_viz/integrations/kedro/abstract_dataset_lite.py +++ b/package/kedro_viz/integrations/kedro/abstract_dataset_lite.py @@ -5,7 +5,20 @@ import logging from typing import Any, Optional -from kedro.io.core import AbstractDataset, DatasetError +try: + # kedro 0.18.11 onwards + from kedro.io.core import DatasetError +except ImportError: # pragma: no cover + # older versions + from kedro.io.core import DataSetError as DatasetError # type: ignore + +try: + # kedro 0.18.12 onwards + from kedro.io.core import AbstractDataset +except ImportError: # pragma: no cover + # older versions + from kedro.io.core import AbstractDataSet as AbstractDataset # type: ignore + from kedro_viz.integrations.utils import UnavailableDataset diff --git a/package/kedro_viz/integrations/kedro/data_loader.py b/package/kedro_viz/integrations/kedro/data_loader.py index aabc5b73a5..ed7af1abb0 100644 --- a/package/kedro_viz/integrations/kedro/data_loader.py +++ b/package/kedro_viz/integrations/kedro/data_loader.py @@ -95,7 +95,14 @@ def _load_data_helper( # patch the AbstractDataset class for a custom # implementation to handle kedro.io.core.DatasetError if is_lite: - with patch("kedro.io.data_catalog.AbstractDataset", AbstractDatasetLite): + # kedro 0.18.12 onwards + if hasattr(sys.modules["kedro.io.data_catalog"], "AbstractDataset"): + abstract_ds_patch_target = "kedro.io.data_catalog.AbstractDataset" + else: # pragma: no cover + # older versions + abstract_ds_patch_target = "kedro.io.data_catalog.AbstractDataSet" + + with patch(abstract_ds_patch_target, AbstractDatasetLite): catalog = context.catalog else: catalog = context.catalog diff --git a/package/kedro_viz/integrations/utils.py b/package/kedro_viz/integrations/utils.py index 1875cd7a85..43c4fe6e30 100644 --- a/package/kedro_viz/integrations/utils.py +++ b/package/kedro_viz/integrations/utils.py @@ -4,7 +4,12 @@ from typing import Any, Union -from kedro.io.core import AbstractDataset +try: + # kedro 0.18.12 onwards + from kedro.io.core import AbstractDataset +except ImportError: # pragma: no cover + # older versions + from kedro.io.core import AbstractDataSet as AbstractDataset # type: ignore _EMPTY = object() From 02ce7705d1030a5f0386771106c5fba8ef2aa900 Mon Sep 17 00:00:00 2001 From: Yury Fedotov <102987839+yury-fedotov@users.noreply.github.com> Date: Mon, 28 Oct 2024 07:34:37 -0400 Subject: [PATCH 14/35] Replace `flake8`, `isort`, `pylint` and `black` by `ruff` (#2149) Closes #1670 --- CONTRIBUTING.md | 2 +- Makefile | 13 +- RELEASE.md | 1 + demo-project/.pre-commit-config.yaml | 30 +- demo-project/docs/source/conf.py | 1 + demo-project/pyproject.toml | 8 - demo-project/ruff.toml | 5 + demo-project/setup.cfg | 3 - demo-project/src/demo_project/__init__.py | 3 +- demo-project/src/demo_project/__main__.py | 1 + demo-project/src/demo_project/hooks.py | 1 + .../src/demo_project/pipeline_registry.py | 4 +- .../pipelines/data_ingestion/pipeline.py | 10 +- .../pipelines/feature_engineering/pipeline.py | 1 - .../demo_project/pipelines/modelling/nodes.py | 10 +- .../pipelines/modelling/pipeline.py | 6 +- .../pipelines/reporting/image_utils.py | 1 - .../demo_project/pipelines/reporting/nodes.py | 5 +- .../pipelines/reporting/pipeline.py | 2 +- demo-project/src/demo_project/requirements.in | 4 +- demo-project/src/tests/test_run.py | 1 + package/.flake8 | 7 - package/.isort.cfg | 9 - package/.pylintrc | 391 ------------------ package/features/environment.py | 2 +- package/features/steps/cli_steps.py | 4 +- package/kedro_viz/__init__.py | 1 + package/kedro_viz/api/apps.py | 3 +- package/kedro_viz/api/graphql/router.py | 1 + package/kedro_viz/api/graphql/schema.py | 3 - package/kedro_viz/api/graphql/types.py | 1 - package/kedro_viz/api/rest/responses.py | 1 - package/kedro_viz/api/rest/router.py | 2 - package/kedro_viz/data_access/__init__.py | 1 + package/kedro_viz/data_access/managers.py | 14 +- .../data_access/repositories/__init__.py | 1 + .../data_access/repositories/catalog.py | 7 +- .../data_access/repositories/graph.py | 2 +- .../repositories/modular_pipelines.py | 1 - .../repositories/registered_pipelines.py | 2 +- .../data_access/repositories/runs.py | 4 +- .../data_access/repositories/tags.py | 2 +- .../repositories/tracking_datasets.py | 4 +- .../integrations/deployment/azure_deployer.py | 1 + .../integrations/deployment/gcp_deployer.py | 1 + .../integrations/kedro/data_loader.py | 5 +- package/kedro_viz/integrations/kedro/hooks.py | 3 +- .../integrations/kedro/lite_parser.py | 6 +- .../integrations/kedro/sqlite_store.py | 4 +- .../kedro_viz/integrations/kedro/telemetry.py | 6 +- .../kedro_viz/integrations/pypi/__init__.py | 1 + package/kedro_viz/launchers/cli/build.py | 2 +- package/kedro_viz/launchers/cli/deploy.py | 2 +- .../launchers/cli/lazy_default_group.py | 3 +- package/kedro_viz/launchers/cli/main.py | 4 +- package/kedro_viz/launchers/cli/run.py | 1 - package/kedro_viz/launchers/cli/utils.py | 10 +- package/kedro_viz/launchers/jupyter.py | 6 +- package/kedro_viz/launchers/utils.py | 5 +- .../kedro_viz/models/experiment_tracking.py | 6 +- package/kedro_viz/models/flowchart.py | 11 +- package/kedro_viz/models/metadata.py | 1 - package/kedro_viz/server.py | 12 +- package/kedro_viz/services/__init__.py | 1 + package/kedro_viz/services/layers.py | 1 + package/ruff.toml | 5 + package/test_requirements.txt | 6 +- package/tests/conftest.py | 8 +- .../test_api/test_graphql/test_queries.py | 2 - .../test_api/test_rest/test_responses.py | 3 +- package/tests/test_import.py | 9 +- .../test_integrations/test_sqlite_store.py | 3 - ruff.toml | 85 ++++ 73 files changed, 200 insertions(+), 588 deletions(-) create mode 100644 demo-project/ruff.toml delete mode 100644 demo-project/setup.cfg delete mode 100644 package/.flake8 delete mode 100644 package/.isort.cfg delete mode 100644 package/.pylintrc create mode 100644 package/ruff.toml create mode 100644 ruff.toml diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index eb3458d2c9..8b8fda0c15 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -311,7 +311,7 @@ make pytest make e2e-tests ``` -#### Linting tests (`isort`, `black`, `pylint`, `flake8` and `mypy`) +#### Linting tests (`ruff` linter and formatter, and `mypy`) ```bash make lint diff --git a/Makefile b/Makefile index 9536ca63cc..864537c6b1 100644 --- a/Makefile +++ b/Makefile @@ -27,18 +27,15 @@ e2e-tests: lint: format-fix lint-check format-fix: - isort package/kedro_viz package/tests package/features - black package/kedro_viz package/tests package/features + ruff check --fix + ruff format format-check: - isort --check package/kedro_viz package/tests package/features - black --check package/kedro_viz package/tests package/features + ruff check + ruff format --check lint-check: - pylint --rcfile=package/.pylintrc -j 0 package/kedro_viz - pylint --rcfile=package/.pylintrc -j 0 --disable=protected-access,missing-docstring,redefined-outer-name,invalid-name,too-few-public-methods,no-member,unused-argument,duplicate-code,abstract-class-instantiated package/tests - pylint --rcfile=package/.pylintrc -j 0 --disable=missing-docstring,no-name-in-module,unused-argument package/features - flake8 --config=package/.flake8 package + ruff check mypy --config-file=package/mypy.ini package/kedro_viz package/features mypy --disable-error-code abstract --config-file=package/mypy.ini package/tests diff --git a/RELEASE.md b/RELEASE.md index 66d6a51a24..aaed5e9a5f 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -18,6 +18,7 @@ Please follow the established format: - Fix unserializable parameters value (#2122) - Display full dataset type with library prefix in metadata panel (#2136) - Enable SQLite WAL mode for Azure ML to fix database locking issues (#2131) +- Replace `flake8`, `isort`, `pylint` and `black` by `ruff` (#2149) # Release 10.0.0 diff --git a/demo-project/.pre-commit-config.yaml b/demo-project/.pre-commit-config.yaml index 9213629835..cbf9699872 100644 --- a/demo-project/.pre-commit-config.yaml +++ b/demo-project/.pre-commit-config.yaml @@ -14,31 +14,11 @@ repos: - id: check-json # Checks json files for parseable syntax. - id: check-case-conflict # Check for files that would conflict in case-insensitive filesystems - id: check-merge-conflict # Check for files that contain merge conflict strings. - - id: debug-statements # Check for debugger imports and py37+ `breakpoint()` calls in python source. - id: requirements-txt-fixer # Sorts entries in requirements.txt - - id: flake8 - args: - - "--max-line-length=100" - - "--max-complexity=18" - - "--max-complexity=18" - - "--select=B,C,E,F,W,T4,B9" - - "--ignore=E203,E266,E501,W503" - - repo: local + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.7.0 hooks: - - id: isort - name: "Sort imports" - language: system - types: [file, python] - entry: isort - - id: black - name: "Black" - language: system - types: [file, python] - entry: black - - id: kedro lint - name: "Kedro lint" - language: python_venv - types: [file, python] - entry: kedro lint - stages: [commit] + - id: ruff + args: [--fix] + - id: ruff-format diff --git a/demo-project/docs/source/conf.py b/demo-project/docs/source/conf.py index 4220ef0a6b..25f0f26269 100644 --- a/demo-project/docs/source/conf.py +++ b/demo-project/docs/source/conf.py @@ -22,6 +22,7 @@ from kedro.framework.cli.utils import find_stylesheets from recommonmark.transform import AutoStructify + from demo_project import __version__ as release # -- Project information ----------------------------------------------------- diff --git a/demo-project/pyproject.toml b/demo-project/pyproject.toml index 0be0a5449b..76b0de5fcf 100644 --- a/demo-project/pyproject.toml +++ b/demo-project/pyproject.toml @@ -3,14 +3,6 @@ package_name = "demo_project" project_name = "modular-spaceflights" kedro_init_version = "0.19.0" -[tool.isort] -multi_line_output = 3 -include_trailing_comma = true -force_grid_wrap = 0 -use_parentheses = true -line_length = 88 -known_third_party = "kedro" - [tool.pytest.ini_options] addopts = """ --cov-report term-missing \ diff --git a/demo-project/ruff.toml b/demo-project/ruff.toml new file mode 100644 index 0000000000..ca826c2941 --- /dev/null +++ b/demo-project/ruff.toml @@ -0,0 +1,5 @@ +extend = "../ruff.toml" + +[lint.isort] +known-first-party = ["demo_project"] +known-third-party = ["kedro"] diff --git a/demo-project/setup.cfg b/demo-project/setup.cfg deleted file mode 100644 index 63ea673001..0000000000 --- a/demo-project/setup.cfg +++ /dev/null @@ -1,3 +0,0 @@ -[flake8] -max-line-length=88 -extend-ignore=E203 diff --git a/demo-project/src/demo_project/__init__.py b/demo-project/src/demo_project/__init__.py index 8ed604c689..72074eaac8 100644 --- a/demo-project/src/demo_project/__init__.py +++ b/demo-project/src/demo_project/__init__.py @@ -1,4 +1,3 @@ -"""demo-project -""" +"""demo-project""" __version__ = "0.1" diff --git a/demo-project/src/demo_project/__main__.py b/demo-project/src/demo_project/__main__.py index e9c8eea9f3..9b25b41ce3 100644 --- a/demo-project/src/demo_project/__main__.py +++ b/demo-project/src/demo_project/__main__.py @@ -1,6 +1,7 @@ """demo-project file for ensuring the package is executable as `demo-project` and `python -m demo_project` """ + from pathlib import Path from kedro.framework.project import configure_project diff --git a/demo-project/src/demo_project/hooks.py b/demo-project/src/demo_project/hooks.py index c2bb3ef03e..3f4b474986 100644 --- a/demo-project/src/demo_project/hooks.py +++ b/demo-project/src/demo_project/hooks.py @@ -1,4 +1,5 @@ """Project hooks.""" + import logging import time from typing import Any diff --git a/demo-project/src/demo_project/pipeline_registry.py b/demo-project/src/demo_project/pipeline_registry.py index 659a3701a3..49fa22539d 100644 --- a/demo-project/src/demo_project/pipeline_registry.py +++ b/demo-project/src/demo_project/pipeline_registry.py @@ -1,4 +1,5 @@ """Project pipelines.""" + from typing import Dict from kedro.pipeline import Pipeline @@ -8,6 +9,7 @@ from demo_project.pipelines import modelling as mod from demo_project.pipelines import reporting as rep + def register_pipelines() -> Dict[str, Pipeline]: """Register the project's pipelines. @@ -24,7 +26,7 @@ def register_pipelines() -> Dict[str, Pipeline]: ) reporting_pipeline = rep.create_pipeline() - + return { "__default__": ( ingestion_pipeline diff --git a/demo-project/src/demo_project/pipelines/data_ingestion/pipeline.py b/demo-project/src/demo_project/pipelines/data_ingestion/pipeline.py index 1acbdf9531..49904271bb 100755 --- a/demo-project/src/demo_project/pipelines/data_ingestion/pipeline.py +++ b/demo-project/src/demo_project/pipelines/data_ingestion/pipeline.py @@ -27,21 +27,21 @@ def create_pipeline(**kwargs) -> Pipeline: func=apply_types_to_companies, inputs="companies", outputs="int_typed_companies", - name='apply_types_to_companies', - tags='companies' + name="apply_types_to_companies", + tags="companies", ), node( func=apply_types_to_shuttles, inputs="shuttles", outputs="int_typed_shuttles@pandas1", - name='apply_types_to_shuttles', - tags='shuttles' + name="apply_types_to_shuttles", + tags="shuttles", ), node( func=apply_types_to_reviews, inputs=["reviews", "params:typing.reviews.columns_as_floats"], outputs="int_typed_reviews", - name='apply_types_to_reviews' + name="apply_types_to_reviews", ), node( func=aggregate_company_data, diff --git a/demo-project/src/demo_project/pipelines/feature_engineering/pipeline.py b/demo-project/src/demo_project/pipelines/feature_engineering/pipeline.py index f4706df75c..fe8f87e275 100644 --- a/demo-project/src/demo_project/pipelines/feature_engineering/pipeline.py +++ b/demo-project/src/demo_project/pipelines/feature_engineering/pipeline.py @@ -3,7 +3,6 @@ generated using Kedro 0.18.1 """ - from kedro.pipeline import Pipeline, node from kedro.pipeline.modular_pipeline import pipeline diff --git a/demo-project/src/demo_project/pipelines/modelling/nodes.py b/demo-project/src/demo_project/pipelines/modelling/nodes.py index 94382c0ec4..a56b974cee 100755 --- a/demo-project/src/demo_project/pipelines/modelling/nodes.py +++ b/demo-project/src/demo_project/pipelines/modelling/nodes.py @@ -85,15 +85,11 @@ def evaluate_model( """ y_pred = regressor.predict(X_test) score = r2_score(y_test, y_pred) - a2_score = random.randint(0,100)*0.1 - b2_score = random.randint(0,100)*0.1 + a2_score = random.randint(0, 100) * 0.1 + b2_score = random.randint(0, 100) * 0.1 logger = logging.getLogger(__name__) logger.info( f"Model has a coefficient R^2 of {score:.3f} on test data using a " f"regressor of type '{type(regressor)}'" ) - return { - "r2_score": score, - "a2_score":a2_score, - "b2_score":b2_score - } + return {"r2_score": score, "a2_score": a2_score, "b2_score": b2_score} diff --git a/demo-project/src/demo_project/pipelines/modelling/pipeline.py b/demo-project/src/demo_project/pipelines/modelling/pipeline.py index 808a4e77e2..1244720b11 100755 --- a/demo-project/src/demo_project/pipelines/modelling/pipeline.py +++ b/demo-project/src/demo_project/pipelines/modelling/pipeline.py @@ -22,13 +22,13 @@ def new_train_eval_template() -> Pipeline: func=train_model, inputs=["X_train", "y_train", "params:dummy_model_options"], outputs=["regressor", "experiment_params"], - tags="train" + tags="train", ), node( func=evaluate_model, inputs=["regressor", "X_test", "y_test"], outputs="r2_score", - tags="evaluate" + tags="evaluate", ), ] ) @@ -83,7 +83,7 @@ def create_pipeline(model_types: List[str]) -> Pipeline: pipeline( pipe=new_train_eval_template(), parameters={"dummy_model_options": f"model_options.{model_type}"}, - inputs={k: k for k in test_train_refs}, + inputs={k: k for k in test_train_refs}, namespace=model_type, ) for model_type in model_types diff --git a/demo-project/src/demo_project/pipelines/reporting/image_utils.py b/demo-project/src/demo_project/pipelines/reporting/image_utils.py index 701d1d1b21..00334dd9fb 100644 --- a/demo-project/src/demo_project/pipelines/reporting/image_utils.py +++ b/demo-project/src/demo_project/pipelines/reporting/image_utils.py @@ -22,7 +22,6 @@ def __init__(self, _df: pd.DataFrame, x: int = 500, y: int = 200): self._populate(_df) def _draw_grid(self): - width, height = self.image.size row_step = (height - self.border * 2) / (self.rows) col_step = (width - self.border * 2) / (self.cols) diff --git a/demo-project/src/demo_project/pipelines/reporting/nodes.py b/demo-project/src/demo_project/pipelines/reporting/nodes.py index cd4796ceb1..dbcc8033a2 100644 --- a/demo-project/src/demo_project/pipelines/reporting/nodes.py +++ b/demo-project/src/demo_project/pipelines/reporting/nodes.py @@ -2,6 +2,9 @@ This is a boilerplate pipeline 'reporting' generated using Kedro 0.18.1 """ + +from typing import Dict + import matplotlib.pyplot as plt import numpy as np import pandas as pd @@ -9,7 +12,7 @@ import plotly.express as px import seaborn as sn from plotly import graph_objects as go -from typing import Dict + from .image_utils import DrawTable diff --git a/demo-project/src/demo_project/pipelines/reporting/pipeline.py b/demo-project/src/demo_project/pipelines/reporting/pipeline.py index 4b6eb4e6de..e7db18ef57 100644 --- a/demo-project/src/demo_project/pipelines/reporting/pipeline.py +++ b/demo-project/src/demo_project/pipelines/reporting/pipeline.py @@ -8,10 +8,10 @@ from demo_project.pipelines.reporting.nodes import ( create_feature_importance_plot, create_matplotlib_chart, + get_top_shuttles_data, make_cancel_policy_bar_chart, make_price_analysis_image, make_price_histogram, - get_top_shuttles_data, ) diff --git a/demo-project/src/demo_project/requirements.in b/demo-project/src/demo_project/requirements.in index dff89fa8d9..3d69566188 100644 --- a/demo-project/src/demo_project/requirements.in +++ b/demo-project/src/demo_project/requirements.in @@ -1,7 +1,4 @@ -black~=22.0 -flake8>=3.7.9, <4.0 ipython~=7.0 -isort~=5.0 jupyter~=1.0 jupyter_client>=5.1, <7.0 jupyterlab~=3.0 @@ -16,4 +13,5 @@ wheel>=0.35, <0.37 pillow~=9.0 matplotlib==3.5.0 pre-commit~=1.17 +ruff==0.7.0 seaborn>=0.13.0 diff --git a/demo-project/src/tests/test_run.py b/demo-project/src/tests/test_run.py index cedc6bcd49..0097339aad 100644 --- a/demo-project/src/tests/test_run.py +++ b/demo-project/src/tests/test_run.py @@ -7,6 +7,7 @@ To run the tests, run ``kedro test``. """ + from pathlib import Path import pytest diff --git a/package/.flake8 b/package/.flake8 deleted file mode 100644 index 9e226baf4e..0000000000 --- a/package/.flake8 +++ /dev/null @@ -1,7 +0,0 @@ -# copied from black - -[flake8] -ignore = E203,E231,E266,E501,F401,W503 -max-line-length = 88 -max-complexity = 18 -select = B,C,E,F,W,T4,B9 diff --git a/package/.isort.cfg b/package/.isort.cfg deleted file mode 100644 index 78ae47d50a..0000000000 --- a/package/.isort.cfg +++ /dev/null @@ -1,9 +0,0 @@ -# copied from black - -[settings] -multi_line_output=3 -include_trailing_comma=True -force_grid_wrap=0 -use_parentheses=True -line_length=88 -known_third_party=behave diff --git a/package/.pylintrc b/package/.pylintrc deleted file mode 100644 index bbf5e49a40..0000000000 --- a/package/.pylintrc +++ /dev/null @@ -1,391 +0,0 @@ -[MASTER] - -# A comma-separated list of package or module names from where C extensions may -# be loaded. Extensions are loading into the active Python interpreter and may -# run arbitrary code -extension-pkg-whitelist=pydantic - -# Add files or directories to the blacklist. They should be base names, not -# paths. -ignore=CVS - -# Add files or directories matching the regex patterns to the blacklist. The -# regex matches against base names, not paths. -ignore-patterns= - -# Python code to execute, usually for sys.path manipulation such as -# pygtk.require(). -#init-hook= - -# Use multiple processes to speed up Pylint. -jobs=1 - -# List of plugins (as comma separated values of python modules names) to load, -# usually to register additional checkers. -load-plugins=pylint.extensions.docparams,pylint_pydantic - -# Pickle collected data for later comparisons. -persistent=yes - -# Specify a configuration file. -#rcfile= - -# Allow loading of arbitrary C extensions. Extensions are imported into the -# active Python interpreter and may run arbitrary code. -unsafe-load-any-extension=no - - -[MESSAGES CONTROL] - -# Only show warnings with the listed confidence levels. Leave empty to show -# all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED -confidence= - -# Disable the message, report, category or checker with the given id(s). You -# can either give multiple identifiers separated by comma (,) or put this -# option multiple times (only on the command line, not in the configuration -# file where it should appear only once).You can also use "--disable=all" to -# disable everything first and then reenable specific checks. For example, if -# you want to run only the similarities checker, you can use "--disable=all -# --enable=similarities". If you want to run only the classes checker, but have -# no Warning level messages displayed, use"--disable=all --enable=classes -# --disable=W" -disable=ungrouped-imports,attribute-defined-outside-init,too-many-arguments,duplicate-code,too-many-positional-arguments,fixme - -# Enable the message, report, category or checker with the given id(s). You can -# either give multiple identifier separated by comma (,) or put this option -# multiple time (only on the command line, not in the configuration file where -# it should appear only once). See also the "--disable" option for examples. -enable=useless-suppression - - -[REPORTS] - -# Python expression which should return a note less than 10 (10 is the highest -# note). You have access to the variables errors warning, statement which -# respectively contain the number of errors / warnings messages and the total -# number of statements analyzed. This is used by the global evaluation report -# (RP0004). -evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) - -# Template used to display messages. This is a python new-style format string -# used to format the message information. See doc for all details -#msg-template= - -# Set the output format. Available formats are text, parseable, colorized, json -# and msvs (visual studio).You can also give a reporter class, eg -# mypackage.mymodule.MyReporterClass. -output-format=text - -# Tells whether to display a full report or only the messages -reports=no - -# Activate the evaluation score. -score=yes - - -[REFACTORING] - -# Maximum number of nested blocks for function / method body -max-nested-blocks=5 - - -[BASIC] - -# Regular expression matching correct argument names -argument-rgx=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$ - - -# Regular expression matching correct attribute names -attr-rgx=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$ - -# Bad variable names which should always be refused, separated by a comma -bad-names=foo,bar,baz,toto,tutu,tata - -# Regular expression matching correct class attribute names -class-attribute-rgx=([A-Za-z_][A-Za-z0-9_]{2,30}|(__.*__))$ - -# Regular expression matching correct class names -class-rgx=[A-Z_][a-zA-Z0-9]+$ - - -# Regular expression matching correct constant names -const-rgx=(([A-Z_][A-Z0-9_]*)|(__.*__))$ - -# Minimum line length for functions/classes that require docstrings, shorter -# ones are exempt. -docstring-min-length=-1 - -# Regular expression matching correct function names -function-rgx=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$ - -# Good variable names which should always be accepted, separated by a comma -good-names=i,j,k,ex,Run,_,id - -# Include a hint for the correct naming format with invalid-name -include-naming-hint=no - -# Regular expression matching correct inline iteration names -inlinevar-rgx=[A-Za-z_][A-Za-z0-9_]*$ - -# Regular expression matching correct method names -method-rgx=(([a-z][a-z0-9_]{2,60})|(_[a-z0-9_]*))$ - -# Regular expression matching correct module names -module-rgx=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$ - -# Colon-delimited sets of names that determine each other's naming style when -# the name regexes allow several styles. -name-group= - -# Regular expression which should only match function or class names that do -# not require a docstring. -no-docstring-rgx=^_ - -# List of decorators that produce properties, such as abc.abstractproperty. Add -# to this list to register other decorators that produce valid properties. -property-classes=abc.abstractproperty - -# Regular expression matching correct variable names -variable-rgx=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$ - - -[FORMAT] - -# Expected format of line ending, e.g. empty (any line ending), LF or CRLF. -expected-line-ending-format= - -# Regexp for a line that is allowed to be longer than the limit. -ignore-long-lines=^\s*(# )??$ - -# Number of spaces of indent required inside a hanging or continued line. -indent-after-paren=4 - -# String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 -# tab). -indent-string=' ' - -# Maximum number of characters on a single line. -max-line-length=100 - -# Maximum number of lines in a module -max-module-lines=1000 - -# Allow the body of a class to be on the same line as the declaration if body -# contains single statement. -single-line-class-stmt=no - -# Allow the body of an if to be on the same line as the test if there is no -# else. -single-line-if-stmt=no - - -[LOGGING] - -# Logging modules to check that the string format arguments are in logging -# function parameter format -logging-modules=logging - - -[MISCELLANEOUS] - -# List of note tags to take in consideration, separated by a comma. -notes=FIXME,XXX,TODO - - -[SIMILARITIES] - -# Ignore comments when computing similarities. -ignore-comments=yes - -# Ignore docstrings when computing similarities. -ignore-docstrings=yes - -# Ignore imports when computing similarities. -ignore-imports=no - -# Minimum lines number of a similarity. -min-similarity-lines=20 - - -[SPELLING] - -# Spelling dictionary name. Available dictionaries: none. To make it working -# install python-enchant package. -spelling-dict= - -# List of comma separated words that should not be checked. -spelling-ignore-words= - -# A path to a file that contains private dictionary; one word per line. -spelling-private-dict-file= - -# Tells whether to store unknown words to indicated private dictionary in -# --spelling-private-dict-file option instead of raising a message. -spelling-store-unknown-words=no - - -[TYPECHECK] - -# List of decorators that produce context managers, such as -# contextlib.contextmanager. Add to this list to register other decorators that -# produce valid context managers. -contextmanager-decorators=contextlib.contextmanager - -# List of members which are set dynamically and missed by pylint inference -# system, and so shouldn't trigger E1101 when accessed. Python regular -# expressions are accepted. -generated-members= - -# Tells whether missing members accessed in mixin class should be ignored. A -# mixin class is detected if its name ends with "mixin" (case insensitive). -ignore-mixin-members=yes - -# This flag controls whether pylint should warn about no-member and similar -# checks whenever an opaque object is returned when inferring. The inference -# can return multiple potential results while evaluating a Python object, but -# some branches might not be evaluated, which results in partial inference. In -# that case, it might be useful to still emit no-member and other checks for -# the rest of the inferred objects. -ignore-on-opaque-inference=yes - -# List of class names for which member attributes should not be checked (useful -# for classes with dynamically set attributes). This supports the use of -# qualified names. -ignored-classes=optparse.Values,thread._local,_thread._local - -# List of module names for which member attributes should not be checked -# (useful for modules/projects where namespaces are manipulated during runtime -# and thus existing member attributes cannot be deduced by static analysis. It -# supports qualified module names, as well as Unix pattern matching. -ignored-modules=orjson - -# Show a hint with possible names when a member name was not found. The aspect -# of finding the hint is based on edit distance. -missing-member-hint=yes - -# The minimum edit distance a name should have in order to be considered a -# similar match for a missing member name. -missing-member-hint-distance=1 - -# The total number of similar names that should be taken in consideration when -# showing a hint for a missing member. -missing-member-max-choices=1 - - -[VARIABLES] - -# List of additional names supposed to be defined in builtins. Remember that -# you should avoid to define new builtins when possible. -additional-builtins= - -# Tells whether unused global variables should be treated as a violation. -allow-global-unused-variables=yes - -# List of strings which can identify a callback function by name. A callback -# name must start or end with one of those strings. -callbacks=cb_,_cb - -# A regular expression matching the name of dummy variables (i.e. expectedly -# not used). -dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_ - -# Argument names that match this expression will be ignored. Default to name -# with leading underscore -ignored-argument-names=_.*|^ignored_|^unused_ - -# Tells whether we should check for unused import in __init__ files. -init-import=no - -# List of qualified module names which can have objects that can redefine -# builtins. -redefining-builtins-modules=six.moves,future.builtins - - -[CLASSES] - -# List of method names used to declare (i.e. assign) instance attributes. -defining-attr-methods=__init__,__new__,setUp - -# List of member names, which should be excluded from the protected access -# warning. -exclude-protected=_asdict,_fields,_replace,_source,_make - -# List of valid names for the first argument in a class method. -valid-classmethod-first-arg=cls - -# List of valid names for the first argument in a metaclass class method. -valid-metaclass-classmethod-first-arg=mcs - - -[DESIGN] - -# Maximum number of arguments for function / method -max-args=12 - -# Maximum number of attributes for a class (see R0902). -max-attributes=7 - -# Maximum number of boolean expressions in a if statement -max-bool-expr=5 - -# Maximum number of branch for function / method body -max-branches=12 - -# Maximum number of locals for function / method body -max-locals=15 - -# Maximum number of parents for a class (see R0901). -max-parents=7 - -# Maximum number of public methods for a class (see R0904). -max-public-methods=20 - -# Maximum number of return / yield for function / method body -max-returns=6 - -# Maximum number of statements in function / method body -max-statements=50 - -# Minimum number of public methods for a class (see R0903). -min-public-methods=1 - - -[IMPORTS] - -# Allow wildcard imports from modules that define __all__. -allow-wildcard-with-all=no - -# Analyse import fallback blocks. This can be used to support both Python 2 and -# 3 compatible code, which means that the block might have code that exists -# only in one or another interpreter, leading to false positives when analysed. -analyse-fallback-blocks=no - -# Deprecated modules which should not be used, separated by a comma -deprecated-modules=optparse,tkinter.tix - -# Create a graph of external dependencies in the given file (report RP0402 must -# not be disabled) -ext-import-graph= - -# Create a graph of every (i.e. internal and external) dependencies in the -# given file (report RP0402 must not be disabled) -import-graph= - -# Create a graph of internal dependencies in the given file (report RP0402 must -# not be disabled) -int-import-graph= - -# Force import order to recognize a module as part of the standard -# compatibility libraries. -known-standard-library= - -# Force import order to recognize a module as part of a third party library. -known-third-party=enchant - - -[EXCEPTIONS] - -# Exceptions that will emit a warning when being caught. Defaults to -# "Exception" -overgeneral-exceptions=builtins.Exception diff --git a/package/features/environment.py b/package/features/environment.py index b0b17df598..38fb8e0e61 100644 --- a/package/features/environment.py +++ b/package/features/environment.py @@ -97,7 +97,7 @@ def _setup_context_with_venv(context, venv_dir): return context -def after_scenario(context, scenario): +def after_scenario(context, scenario): # noqa: ARG001 for path in _PATHS_TO_REMOVE: # ignore errors when attempting to remove already removed directories shutil.rmtree(path, ignore_errors=True) diff --git a/package/features/steps/cli_steps.py b/package/features/steps/cli_steps.py index 769cb08d64..3f9deb1304 100644 --- a/package/features/steps/cli_steps.py +++ b/package/features/steps/cli_steps.py @@ -166,7 +166,7 @@ def check_kedroviz_up(context): while time() < end_by: try: data_json = requests.get("http://localhost:4141/api/main").json() - except Exception: + except Exception: # noqa: BLE001 sleep(2.0) continue else: @@ -191,7 +191,7 @@ def get_main_api_response(context): response = requests.get("http://localhost:4141/api/main") context.response = response.json() assert response.status_code == 200 - except Exception: + except Exception: # noqa: BLE001 sleep(2.0) continue else: diff --git a/package/kedro_viz/__init__.py b/package/kedro_viz/__init__.py index 6053ab4eac..806f783d93 100644 --- a/package/kedro_viz/__init__.py +++ b/package/kedro_viz/__init__.py @@ -1,4 +1,5 @@ """Kedro plugin for visualising a Kedro pipeline""" + import sys import warnings diff --git a/package/kedro_viz/api/apps.py b/package/kedro_viz/api/apps.py index aef4d44715..d5b5c535ca 100644 --- a/package/kedro_viz/api/apps.py +++ b/package/kedro_viz/api/apps.py @@ -1,6 +1,7 @@ """`kedro_viz.api.app` defines the FastAPI app to serve Kedro data in a RESTful API. This data could either come from a real Kedro project or a file. """ + import json import os import time @@ -42,7 +43,7 @@ def _create_base_api_app() -> FastAPI: @app.middleware("http") async def set_secure_headers(request, call_next): response = await call_next(request) - secure_headers.framework.fastapi(response) # pylint: disable=no-member + secure_headers.framework.fastapi(response) return response return app diff --git a/package/kedro_viz/api/graphql/router.py b/package/kedro_viz/api/graphql/router.py index eb0b257ef7..803a5b7527 100644 --- a/package/kedro_viz/api/graphql/router.py +++ b/package/kedro_viz/api/graphql/router.py @@ -1,4 +1,5 @@ """`kedro_viz.api.graphql.router` defines GraphQL routes.""" + # mypy: ignore-errors from fastapi import APIRouter from strawberry.asgi import GraphQL diff --git a/package/kedro_viz/api/graphql/schema.py b/package/kedro_viz/api/graphql/schema.py index bb0fb5b552..24632b57b4 100644 --- a/package/kedro_viz/api/graphql/schema.py +++ b/package/kedro_viz/api/graphql/schema.py @@ -1,5 +1,4 @@ """`kedro_viz.api.graphql.schema` defines the GraphQL schema: queries and mutations.""" -# pylint: disable=missing-function-docstring,missing-class-docstring from __future__ import annotations @@ -77,7 +76,6 @@ def run_tracking_data( group: TrackingDatasetGroup, show_diff: Optional[bool] = True, ) -> List[TrackingDataset]: - # pylint: disable=line-too-long tracking_dataset_models = data_access_manager.tracking_datasets.get_tracking_datasets_by_group_by_run_ids( run_ids, group ) @@ -110,7 +108,6 @@ def run_metrics_data(self, limit: Optional[int] = 25) -> MetricPlotDataset: ] group = TrackingDatasetGroup.METRIC - # pylint: disable=line-too-long metric_dataset_models = data_access_manager.tracking_datasets.get_tracking_datasets_by_group_by_run_ids( run_ids, group ) diff --git a/package/kedro_viz/api/graphql/types.py b/package/kedro_viz/api/graphql/types.py index 86848d7e6e..d5ec8ad527 100644 --- a/package/kedro_viz/api/graphql/types.py +++ b/package/kedro_viz/api/graphql/types.py @@ -1,6 +1,5 @@ """`kedro_viz.api.graphql.types` defines strawberry types.""" -# pylint: disable=too-few-public-methods,missing-class-docstring from __future__ import annotations import sys diff --git a/package/kedro_viz/api/rest/responses.py b/package/kedro_viz/api/rest/responses.py index 2f59d33b16..5a38ef6b4c 100644 --- a/package/kedro_viz/api/rest/responses.py +++ b/package/kedro_viz/api/rest/responses.py @@ -1,6 +1,5 @@ """`kedro_viz.api.rest.responses` defines REST response types.""" -# pylint: disable=missing-class-docstring,invalid-name import abc import json import logging diff --git a/package/kedro_viz/api/rest/router.py b/package/kedro_viz/api/rest/router.py index 3cd6a18e9f..a32e204281 100644 --- a/package/kedro_viz/api/rest/router.py +++ b/package/kedro_viz/api/rest/router.py @@ -1,6 +1,5 @@ """`kedro_viz.api.rest.router` defines REST routes and handling logic.""" -# pylint: disable=missing-function-docstring, broad-exception-caught import logging from fastapi import APIRouter @@ -74,7 +73,6 @@ async def deploy_kedro_viz(input_values: DeployerConfiguration): status_code=401, content={"message": "Please provide valid credentials"} ) except ( - # pylint: disable=catching-non-exception (FileNotFoundError, ServiceRequestError) if ServiceRequestError is not None else FileNotFoundError diff --git a/package/kedro_viz/data_access/__init__.py b/package/kedro_viz/data_access/__init__.py index 2dd525fd7b..c5f408f9ef 100644 --- a/package/kedro_viz/data_access/__init__.py +++ b/package/kedro_viz/data_access/__init__.py @@ -1,4 +1,5 @@ """`kedro_viz.data_access` provides an interface to save and load data for viz backend.""" + from .managers import DataAccessManager data_access_manager = DataAccessManager() diff --git a/package/kedro_viz/data_access/managers.py b/package/kedro_viz/data_access/managers.py index 2799bf5f21..40e8ac56f6 100644 --- a/package/kedro_viz/data_access/managers.py +++ b/package/kedro_viz/data_access/managers.py @@ -1,6 +1,5 @@ """`kedro_viz.data_access.managers` defines data access managers.""" -# pylint: disable=too-many-instance-attributes,protected-access import logging from collections import defaultdict from typing import Dict, List, Set, Union @@ -94,8 +93,7 @@ def resolve_dataset_factory_patterns( for dataset_name in datasets: try: catalog._get_dataset(dataset_name, suggest=False) - # pylint: disable=broad-except - except Exception: # pragma: no cover + except Exception: # noqa: BLE001 # pragma: no cover continue def add_catalog(self, catalog: DataCatalog, pipelines: Dict[str, KedroPipeline]): @@ -237,7 +235,6 @@ def add_node( self.tags.add_tags(task_node.tags) return task_node - # pylint: disable=too-many-positional-arguments def add_node_input( self, registered_pipeline_id: str, @@ -399,9 +396,9 @@ def add_parameters_to_task_node( if parameters_node.is_all_parameters(): task_node.parameters = parameters_node.parameter_value else: - task_node.parameters[ - parameters_node.parameter_name - ] = parameters_node.parameter_value + task_node.parameters[parameters_node.parameter_name] = ( + parameters_node.parameter_value + ) def get_default_selected_pipeline(self) -> RegisteredPipeline: """Return the default selected pipeline ID to display on first page load. @@ -473,8 +470,7 @@ def get_sorted_layers_for_registered_pipeline( self.get_node_dependencies_for_registered_pipeline(registered_pipeline_id), ) - # pylint: disable=too-many-locals,too-many-branches - def create_modular_pipelines_tree_for_registered_pipeline( + def create_modular_pipelines_tree_for_registered_pipeline( # noqa: PLR0912 self, registered_pipeline_id: str = DEFAULT_REGISTERED_PIPELINE_ID ) -> Dict[str, ModularPipelineNode]: """Create the modular pipelines tree for a specific registered pipeline. diff --git a/package/kedro_viz/data_access/repositories/__init__.py b/package/kedro_viz/data_access/repositories/__init__.py index d1210cb981..6c0d3842c6 100644 --- a/package/kedro_viz/data_access/repositories/__init__.py +++ b/package/kedro_viz/data_access/repositories/__init__.py @@ -1,5 +1,6 @@ """`kedro_viz.data_access.repositories` defines repositories to centralise access to application data.""" + from .catalog import CatalogRepository from .graph import GraphEdgesRepository, GraphNodesRepository from .modular_pipelines import ModularPipelinesRepository diff --git a/package/kedro_viz/data_access/repositories/catalog.py b/package/kedro_viz/data_access/repositories/catalog.py index 38d9a6772d..d136c498e8 100644 --- a/package/kedro_viz/data_access/repositories/catalog.py +++ b/package/kedro_viz/data_access/repositories/catalog.py @@ -1,7 +1,6 @@ """`kedro_viz.data_access.repositories.catalog` defines interface to centralise access to Kedro data catalog.""" -# pylint: disable=missing-class-docstring,missing-function-docstring,protected-access import logging from typing import TYPE_CHECKING, Dict, Optional @@ -52,8 +51,7 @@ def _validate_layers_for_transcoding(self, dataset_name, layer): ) @property - def layers_mapping(self): - # pylint: disable=too-many-branches + def layers_mapping(self): # noqa: PLR0912 """Return layer mapping: dataset_name -> layer it belongs to in the catalog From kedro-datasets 1.3.0 onwards, the 'layers' attribute is defined inside the 'metadata' under 'kedro-viz' plugin. @@ -83,8 +81,7 @@ def layers_mapping(self): # Temporary try/except block so the Kedro develop branch can work with Viz. try: datasets = self._catalog._data_sets - # pylint: disable=broad-exception-caught - except Exception: # pragma: no cover + except Exception: # noqa: BLE001 # pragma: no cover datasets = self._catalog._datasets # Support for Kedro 0.18.x diff --git a/package/kedro_viz/data_access/repositories/graph.py b/package/kedro_viz/data_access/repositories/graph.py index 90f734ec1d..601e52d060 100644 --- a/package/kedro_viz/data_access/repositories/graph.py +++ b/package/kedro_viz/data_access/repositories/graph.py @@ -1,6 +1,6 @@ """`kedro_viz.data_access.repositories.graph` defines interface to centralise access to graph objects.""" -# pylint: disable=missing-class-docstring,missing-function-docstring + from typing import Dict, Generator, List, Optional, Set from kedro_viz.models.flowchart import GraphEdge, GraphNode diff --git a/package/kedro_viz/data_access/repositories/modular_pipelines.py b/package/kedro_viz/data_access/repositories/modular_pipelines.py index 25b7645ff4..746f6700df 100644 --- a/package/kedro_viz/data_access/repositories/modular_pipelines.py +++ b/package/kedro_viz/data_access/repositories/modular_pipelines.py @@ -1,7 +1,6 @@ """`kedro_viz.data_access.repositories.modular_pipelines` defines repository to centralise access for modular pipelines data.""" - from collections import defaultdict from typing import Dict, List, Set, Tuple, Union diff --git a/package/kedro_viz/data_access/repositories/registered_pipelines.py b/package/kedro_viz/data_access/repositories/registered_pipelines.py index 16cdd98adf..d73f621867 100644 --- a/package/kedro_viz/data_access/repositories/registered_pipelines.py +++ b/package/kedro_viz/data_access/repositories/registered_pipelines.py @@ -1,6 +1,6 @@ """`kedro_viz.data_access.repositories.registered_pipelines` defines repository to centralise access to registered pipelines data.""" -# pylint: disable=missing-class-docstring,missing-function-docstring + from collections import OrderedDict, defaultdict from typing import Dict, List, Optional, Set diff --git a/package/kedro_viz/data_access/repositories/runs.py b/package/kedro_viz/data_access/repositories/runs.py index 453cb244c6..c2e5b76282 100644 --- a/package/kedro_viz/data_access/repositories/runs.py +++ b/package/kedro_viz/data_access/repositories/runs.py @@ -1,6 +1,6 @@ """`kedro_viz.data_access.repositories.runs` defines repository to centralise access to runs data from the session store.""" -# pylint: disable=missing-class-docstring,missing-function-docstring + import logging from functools import wraps from typing import Callable, Dict, Iterable, List, Optional @@ -19,7 +19,7 @@ def check_db_session(method: Callable) -> Callable: @wraps(method) def func(self: "RunsRepository", *method_args, **method_kwargs): - if not self._db_session_class: # pylint: disable=protected-access + if not self._db_session_class: return None return method(self, *method_args, **method_kwargs) diff --git a/package/kedro_viz/data_access/repositories/tags.py b/package/kedro_viz/data_access/repositories/tags.py index eae5c68bb0..0bb46949ac 100644 --- a/package/kedro_viz/data_access/repositories/tags.py +++ b/package/kedro_viz/data_access/repositories/tags.py @@ -1,6 +1,6 @@ """`kedro_viz.data_access.repositories.tags` defines repository to centralise access to tags data.""" -# pylint: disable=missing-class-docstring,missing-function-docstring + from typing import Iterable, List, Set from kedro_viz.models.flowchart import Tag diff --git a/package/kedro_viz/data_access/repositories/tracking_datasets.py b/package/kedro_viz/data_access/repositories/tracking_datasets.py index d8d06cb9a0..911bc439a7 100644 --- a/package/kedro_viz/data_access/repositories/tracking_datasets.py +++ b/package/kedro_viz/data_access/repositories/tracking_datasets.py @@ -1,6 +1,6 @@ """`kedro_viz.data_access.repositories.tracking_datasets` defines an interface to centralise access to datasets used in experiment tracking.""" -# pylint: disable=missing-class-docstring,missing-function-docstring,protected-access + from collections import defaultdict from typing import TYPE_CHECKING, Dict, List @@ -17,7 +17,7 @@ from kedro.io import AbstractVersionedDataset except ImportError: # older versions - from kedro.io import ( # type: ignore # isort:skip + from kedro.io import ( # type: ignore AbstractVersionedDataSet as AbstractVersionedDataset, ) diff --git a/package/kedro_viz/integrations/deployment/azure_deployer.py b/package/kedro_viz/integrations/deployment/azure_deployer.py index a147902545..ad7130e5c8 100644 --- a/package/kedro_viz/integrations/deployment/azure_deployer.py +++ b/package/kedro_viz/integrations/deployment/azure_deployer.py @@ -1,5 +1,6 @@ """`kedro_viz.integrations.deployment.azure_deployer` defines deployment class for Azure Blob Storage""" + import glob import logging import mimetypes diff --git a/package/kedro_viz/integrations/deployment/gcp_deployer.py b/package/kedro_viz/integrations/deployment/gcp_deployer.py index c02010b24f..3e9a6fae09 100644 --- a/package/kedro_viz/integrations/deployment/gcp_deployer.py +++ b/package/kedro_viz/integrations/deployment/gcp_deployer.py @@ -1,5 +1,6 @@ """`kedro_viz.integrations.deployment.gcp_deployer` defines deployment class for Google Cloud Storage Bucket""" + import glob import logging import mimetypes diff --git a/package/kedro_viz/integrations/kedro/data_loader.py b/package/kedro_viz/integrations/kedro/data_loader.py index ed7af1abb0..6232270368 100644 --- a/package/kedro_viz/integrations/kedro/data_loader.py +++ b/package/kedro_viz/integrations/kedro/data_loader.py @@ -3,8 +3,6 @@ load data from projects created in a range of Kedro versions. """ -# pylint: disable=protected-access - import json import logging import sys @@ -46,7 +44,7 @@ def _get_dataset_stats(project_path: Path) -> Dict: stats = json.load(stats_file) return stats - except Exception as exc: # pylint: disable=broad-exception-caught + except Exception as exc: # noqa: BLE001 logger.warning( "Unable to get dataset statistics from project path %s : %s", project_path, @@ -115,7 +113,6 @@ def _load_data_helper( return catalog, pipelines_dict, session_store, stats_dict -# pylint: disable=too-many-positional-arguments def load_data( project_path: Path, env: Optional[str] = None, diff --git a/package/kedro_viz/integrations/kedro/hooks.py b/package/kedro_viz/integrations/kedro/hooks.py index 97da89319b..3089e61f50 100644 --- a/package/kedro_viz/integrations/kedro/hooks.py +++ b/package/kedro_viz/integrations/kedro/hooks.py @@ -1,4 +1,3 @@ -# pylint: disable=broad-exception-caught, protected-access """`kedro_viz.integrations.kedro.hooks` defines hooks to add additional functionalities for a kedro run.""" @@ -108,7 +107,7 @@ def create_dataset_stats(self, dataset_name: str, data: Any): """ try: - import pandas as pd # pylint: disable=import-outside-toplevel + import pandas as pd stats_dataset_name = self.get_stats_dataset_name(dataset_name) diff --git a/package/kedro_viz/integrations/kedro/lite_parser.py b/package/kedro_viz/integrations/kedro/lite_parser.py index 9fe619fe5c..e3af8b42e6 100755 --- a/package/kedro_viz/integrations/kedro/lite_parser.py +++ b/package/kedro_viz/integrations/kedro/lite_parser.py @@ -48,8 +48,7 @@ def _is_module_importable(module_name: str) -> bool: except ValueError as val_exc: logger.debug("ValueError in resolving %s : %s", module_name, val_exc) return False - # pylint: disable=broad-except - except Exception as exc: # pragma: no cover + except Exception as exc: # noqa: BLE001 # pragma: no cover logger.debug( "An exception occurred while resolving %s : %s", module_name, exc ) @@ -262,8 +261,7 @@ def parse(self, target_path: Path) -> Union[Dict[str, Set[str]], None]: ) if len(missing_dependencies) > 0: unresolved_imports[str(file_path)] = missing_dependencies - # pylint: disable=broad-except - except Exception as exc: # pragma: no cover + except Exception as exc: # noqa: BLE001 # pragma: no cover logger.error( "An error occurred in LiteParser while mocking dependencies : %s", exc, diff --git a/package/kedro_viz/integrations/kedro/sqlite_store.py b/package/kedro_viz/integrations/kedro/sqlite_store.py index 9b9b9e7309..8ba1a5ac9e 100644 --- a/package/kedro_viz/integrations/kedro/sqlite_store.py +++ b/package/kedro_viz/integrations/kedro/sqlite_store.py @@ -1,8 +1,6 @@ """kedro_viz.intergrations.kedro.sqlite_store is a child of BaseSessionStore which stores sessions data in the SQLite database""" -# pylint: disable=no-member, broad-exception-caught - import getpass import json import logging @@ -81,7 +79,7 @@ def _to_json(self) -> str: for key, value in self.data.items(): if key == "git": try: - import git # pylint: disable=import-outside-toplevel + import git branch = git.Repo(search_parent_directories=True).active_branch value["branch"] = branch.name diff --git a/package/kedro_viz/integrations/kedro/telemetry.py b/package/kedro_viz/integrations/kedro/telemetry.py index 2c57c41536..572da919d8 100644 --- a/package/kedro_viz/integrations/kedro/telemetry.py +++ b/package/kedro_viz/integrations/kedro/telemetry.py @@ -1,5 +1,4 @@ -"""`kedro_viz.integrations.kedro.telemetry` helps integrate Kedro-Viz with Kedro-Telemetry -""" +"""`kedro_viz.integrations.kedro.telemetry` helps integrate Kedro-Viz with Kedro-Telemetry""" from pathlib import Path from typing import Optional @@ -26,12 +25,11 @@ def get_heap_app_id(project_path: Path) -> Optional[str]: return None -# pylint: disable=broad-exception-caught def get_heap_identity() -> Optional[str]: # pragma: no cover """Reads a UUID from a configuration file or generates and saves a new one if not present.""" if not _IS_TELEMETRY_INSTALLED: return None try: return _get_or_create_uuid() - except Exception: # pragma: no cover + except Exception: # noqa: BLE001 # pragma: no cover return None diff --git a/package/kedro_viz/integrations/pypi/__init__.py b/package/kedro_viz/integrations/pypi/__init__.py index 06f97172d9..4383f24751 100644 --- a/package/kedro_viz/integrations/pypi/__init__.py +++ b/package/kedro_viz/integrations/pypi/__init__.py @@ -1,4 +1,5 @@ """`kedro_viz.integrations.pypi` provides an interface to integrate Kedro-Viz with PyPI.""" + import logging from typing import Optional, Union diff --git a/package/kedro_viz/launchers/cli/build.py b/package/kedro_viz/launchers/cli/build.py index d506266019..6e54639782 100644 --- a/package/kedro_viz/launchers/cli/build.py +++ b/package/kedro_viz/launchers/cli/build.py @@ -1,6 +1,6 @@ """`kedro_viz.launchers.cli.build` provides a cli command to build a Kedro-Viz instance""" -# pylint: disable=import-outside-toplevel + import click from kedro_viz.launchers.cli.main import viz diff --git a/package/kedro_viz/launchers/cli/deploy.py b/package/kedro_viz/launchers/cli/deploy.py index 10bb31870f..75d0b8bb43 100644 --- a/package/kedro_viz/launchers/cli/deploy.py +++ b/package/kedro_viz/launchers/cli/deploy.py @@ -1,6 +1,6 @@ """`kedro_viz.launchers.cli.deploy` provides a cli command to deploy a Kedro-Viz instance on cloud platforms""" -# pylint: disable=import-outside-toplevel + import click from kedro_viz.constants import SHAREABLEVIZ_SUPPORTED_PLATFORMS diff --git a/package/kedro_viz/launchers/cli/lazy_default_group.py b/package/kedro_viz/launchers/cli/lazy_default_group.py index 861d023221..9e832d2b93 100644 --- a/package/kedro_viz/launchers/cli/lazy_default_group.py +++ b/package/kedro_viz/launchers/cli/lazy_default_group.py @@ -1,7 +1,6 @@ """`kedro_viz.launchers.cli.lazy_default_group` provides a custom mutli-command subclass for a lazy subcommand loader""" -# pylint: disable=import-outside-toplevel from typing import Any, Union import click @@ -30,7 +29,7 @@ def __init__( super().__init__(*args, **kwargs) - def list_commands(self, ctx: click.Context) -> list[str]: + def list_commands(self, ctx: click.Context) -> list[str]: # noqa: ARG002 return sorted(self.lazy_subcommands.keys()) def get_command( # type: ignore[override] diff --git a/package/kedro_viz/launchers/cli/main.py b/package/kedro_viz/launchers/cli/main.py index 0ccb1515e1..9d556ab6dd 100644 --- a/package/kedro_viz/launchers/cli/main.py +++ b/package/kedro_viz/launchers/cli/main.py @@ -6,7 +6,7 @@ @click.group(name="Kedro-Viz") -def viz_cli(): # pylint: disable=missing-function-docstring +def viz_cli(): pass @@ -22,5 +22,5 @@ def viz_cli(): # pylint: disable=missing-function-docstring default_if_no_args=True, ) @click.pass_context -def viz(ctx): # pylint: disable=unused-argument +def viz(ctx): """Visualise a Kedro pipeline using Kedro viz.""" diff --git a/package/kedro_viz/launchers/cli/run.py b/package/kedro_viz/launchers/cli/run.py index 97c9ab3dbc..4fab6c1869 100644 --- a/package/kedro_viz/launchers/cli/run.py +++ b/package/kedro_viz/launchers/cli/run.py @@ -83,7 +83,6 @@ is_flag=True, help="An experimental flag to open Kedro-Viz without Kedro project dependencies", ) -# pylint: disable=import-outside-toplevel, too-many-locals, too-many-positional-arguments def run( host, port, diff --git a/package/kedro_viz/launchers/cli/utils.py b/package/kedro_viz/launchers/cli/utils.py index 290a0461c0..b5a376022b 100644 --- a/package/kedro_viz/launchers/cli/utils.py +++ b/package/kedro_viz/launchers/cli/utils.py @@ -1,5 +1,5 @@ """`kedro_viz.launchers.cli.utils` provides utility functions for cli commands.""" -# pylint: disable=import-outside-toplevel + from pathlib import Path from time import sleep from typing import Union @@ -96,8 +96,7 @@ def create_shareableviz_process( "you have write access to the current directory", "red", ) - # pylint: disable=broad-exception-caught - except Exception as exc: # pragma: no cover + except Exception as exc: # noqa: BLE001 # pragma: no cover display_cli_message(f"ERROR: Failed to build/deploy Kedro-Viz : {exc} ", "red") finally: @@ -114,7 +113,6 @@ def display_cli_message(msg, msg_color=None): ) -# pylint: disable=too-many-positional-arguments def _load_and_deploy_viz( platform, is_all_previews_enabled, @@ -144,14 +142,12 @@ def _load_and_deploy_viz( deployer.deploy(is_all_previews_enabled) except ( - # pylint: disable=catching-non-exception (FileNotFoundError, ServiceRequestError) if ServiceRequestError is not None else FileNotFoundError ): # pragma: no cover exception_queue.put(Exception("The specified bucket does not exist")) - # pylint: disable=broad-exception-caught - except Exception as exc: # pragma: no cover + except Exception as exc: # noqa: BLE001 # pragma: no cover exception_queue.put(exc) finally: process_completed.value = 1 diff --git a/package/kedro_viz/launchers/jupyter.py b/package/kedro_viz/launchers/jupyter.py index f51f6ce7eb..22af9fb99a 100644 --- a/package/kedro_viz/launchers/jupyter.py +++ b/package/kedro_viz/launchers/jupyter.py @@ -75,7 +75,7 @@ def dbutils_get(attr): def _display_databricks_html(port: int): # pragma: no cover url = _make_databricks_url(port) - displayHTML = _get_databricks_object("displayHTML") # pylint: disable=invalid-name + displayHTML = _get_databricks_object("displayHTML") if displayHTML is not None: displayHTML(f"""
Open Kedro-Viz""") else: @@ -92,9 +92,7 @@ def parse_args(args): # pragma: no cover return arg_dict -def run_viz( # pylint: disable=too-many-locals - args: str = "", local_ns: Dict[str, Any] = None -) -> None: +def run_viz(args: str = "", local_ns: Dict[str, Any] = None) -> None: """ Line magic function to start Kedro Viz with optional arguments. diff --git a/package/kedro_viz/launchers/utils.py b/package/kedro_viz/launchers/utils.py index c4b0076677..00fcde64eb 100644 --- a/package/kedro_viz/launchers/utils.py +++ b/package/kedro_viz/launchers/utils.py @@ -49,7 +49,7 @@ def _wait_for( while time() <= end: try: retval = func(**kwargs) - except Exception as err: # pylint: disable=broad-except + except Exception as err: # noqa: BLE001 if print_error: logger.error(err) else: @@ -103,8 +103,7 @@ def _is_project(project_path: Union[str, Path]) -> bool: try: return "[tool.kedro]" in metadata_file.read_text(encoding="utf-8") - # pylint: disable=broad-exception-caught - except Exception: + except Exception: # noqa: BLE001 return False diff --git a/package/kedro_viz/models/experiment_tracking.py b/package/kedro_viz/models/experiment_tracking.py index d662a3fead..516b1d2a16 100644 --- a/package/kedro_viz/models/experiment_tracking.py +++ b/package/kedro_viz/models/experiment_tracking.py @@ -1,6 +1,6 @@ """kedro_viz.models.experiment_tracking` defines data models to represent run data and tracking datasets.""" -# pylint: disable=too-few-public-methods,protected-access,missing-function-docstring + import logging from dataclasses import dataclass, field from enum import Enum @@ -21,7 +21,7 @@ from kedro.io import AbstractVersionedDataset except ImportError: # older versions - from kedro.io import ( # type: ignore # isort:skip + from kedro.io import ( # type: ignore AbstractVersionedDataSet as AbstractVersionedDataset, ) @@ -112,7 +112,7 @@ def load_tracking_data(self, run_id: str): } else: self.runs[run_id] = self.dataset.preview() # type: ignore - except Exception as exc: # pylint: disable=broad-except # pragma: no cover + except Exception as exc: # noqa: BLE001 # pragma: no cover logger.warning( "'%s' with version '%s' could not be loaded. Full exception: %s: %s", self.dataset_name, diff --git a/package/kedro_viz/models/flowchart.py b/package/kedro_viz/models/flowchart.py index 8828650a7e..299dbc120e 100644 --- a/package/kedro_viz/models/flowchart.py +++ b/package/kedro_viz/models/flowchart.py @@ -1,6 +1,5 @@ """`kedro_viz.models.flowchart` defines data models to represent Kedro entities in a viz graph.""" -# pylint: disable=protected-access, missing-function-docstring import abc import inspect import logging @@ -165,7 +164,6 @@ def create_task_node( ) @classmethod - # pylint: disable=too-many-positional-arguments def create_data_node( cls, dataset_id: str, @@ -218,7 +216,6 @@ def create_data_node( ) @classmethod - # pylint: disable=too-many-positional-arguments def create_parameters_node( cls, dataset_id: str, @@ -468,7 +465,6 @@ def set_outputs(cls, _): return cls.kedro_node.outputs -# pylint: disable=missing-function-docstring class DataNode(GraphNode): """Represent a graph node of type data @@ -693,7 +689,7 @@ def set_preview(cls, _): return cls.dataset.preview() return cls.dataset.preview(**preview_args) - except Exception as exc: # pylint: disable=broad-except + except Exception as exc: # noqa: BLE001 logger.warning( "'%s' could not be previewed. Full exception: %s: %s", cls.data_node.name, @@ -723,7 +719,7 @@ def set_preview_type(cls, _): ) return preview_type_name - except Exception as exc: # pylint: disable=broad-except # pragma: no cover + except Exception as exc: # noqa: BLE001 # pragma: no cover logger.warning( "'%s' did not have preview type. Full exception: %s: %s", cls.data_node.name, @@ -877,8 +873,7 @@ def parameter_value(self) -> Any: "Cannot find parameter `%s` in the catalog.", self.parameter_name ) return None - # pylint: disable=broad-exception-caught - except Exception as exc: # pragma: no cover + except Exception as exc: # noqa: BLE001 # pragma: no cover logger.error( "An error occurred when loading parameter `%s` in the catalog :: %s", self.parameter_name, diff --git a/package/kedro_viz/models/metadata.py b/package/kedro_viz/models/metadata.py index debe1f04e3..6e73c104f1 100644 --- a/package/kedro_viz/models/metadata.py +++ b/package/kedro_viz/models/metadata.py @@ -1,6 +1,5 @@ """`kedro_viz.models.metadata` defines metadata for Kedro-Viz application.""" -# pylint: disable=missing-function-docstring from typing import ClassVar, List from pydantic import BaseModel, field_validator diff --git a/package/kedro_viz/server.py b/package/kedro_viz/server.py index 37d31f2f19..d9b8fbc2e6 100644 --- a/package/kedro_viz/server.py +++ b/package/kedro_viz/server.py @@ -25,7 +25,7 @@ def populate_data( pipelines: Dict[str, Pipeline], session_store: BaseSessionStore, stats_dict: Dict, -): # pylint: disable=redefined-outer-name +): """Populate data repositories. Should be called once on application start if creating an api app from project. """ @@ -44,7 +44,6 @@ def populate_data( data_access_manager.add_pipelines(pipelines) -# pylint: disable=too-many-positional-arguments def load_and_populate_data( path: Path, env: Optional[str] = None, @@ -71,7 +70,6 @@ def load_and_populate_data( populate_data(data_access_manager, catalog, pipelines, session_store, stats_dict) -# pylint: disable=too-many-positional-arguments, too-many-locals def run_server( host: str = DEFAULT_HOST, port: int = DEFAULT_PORT, @@ -85,7 +83,7 @@ def run_server( package_name: Optional[str] = None, extra_params: Optional[Dict[str, Any]] = None, is_lite: bool = False, -): # pylint: disable=redefined-outer-name +): """Run a uvicorn server with a FastAPI app that either launches API response data from a file or from reading data from a real Kedro project. @@ -112,10 +110,10 @@ def run_server( # Importing below dependencies inside `run_server` to avoid ImportError # when calling `load_and_populate_data` from VSCode - import fsspec # pylint: disable=C0415 - import uvicorn # pylint: disable=C0415 + import fsspec + import uvicorn - from kedro_viz.api import apps # pylint: disable=C0415 + from kedro_viz.api import apps path = Path(project_path) if project_path else Path.cwd() diff --git a/package/kedro_viz/services/__init__.py b/package/kedro_viz/services/__init__.py index 81991d1b4d..b12ebc2051 100644 --- a/package/kedro_viz/services/__init__.py +++ b/package/kedro_viz/services/__init__.py @@ -1,2 +1,3 @@ """`kedro_viz.services` provides an additional business logic layer for the API.""" + from . import layers as layers_services diff --git a/package/kedro_viz/services/layers.py b/package/kedro_viz/services/layers.py index 4eab727e80..f8840534fc 100644 --- a/package/kedro_viz/services/layers.py +++ b/package/kedro_viz/services/layers.py @@ -1,4 +1,5 @@ """`kedro_viz.services.layers` defines layers-related logic.""" + import logging from collections import defaultdict from graphlib import CycleError, TopologicalSorter diff --git a/package/ruff.toml b/package/ruff.toml new file mode 100644 index 0000000000..c911fbf7ec --- /dev/null +++ b/package/ruff.toml @@ -0,0 +1,5 @@ +extend = "../ruff.toml" + +[lint.isort] +known-first-party = ["kedro_viz"] +known-third-party = ["kedro"] diff --git a/package/test_requirements.txt b/package/test_requirements.txt index 14741ab0ea..3260a24806 100644 --- a/package/test_requirements.txt +++ b/package/test_requirements.txt @@ -5,20 +5,16 @@ kedro-datasets[pandas.ParquetDataset, pandas.CSVDataset, pandas.ExcelDataset, pl kedro-telemetry>=0.1.1 # for testing telemetry integration bandit~=1.7 behave~=1.2 -black~=23.3 boto3~=1.34 -flake8~=7.1 -isort~=5.11 matplotlib~=3.9 mypy~=1.11 moto~=5.0.9 psutil==5.9.6 # same as Kedro for now -pylint~=3.2 -pylint-pydantic>=0.3.0 pytest~=8.3 pytest-asyncio~=0.21 pytest-mock~=3.14 pytest-cov~=5.0 +ruff==0.7.0 sqlalchemy-stubs~=0.4 strawberry-graphql[cli]>=0.99.0, <1.0 trufflehog~=2.2 diff --git a/package/tests/conftest.py b/package/tests/conftest.py index d63fca7fd3..7c66051328 100644 --- a/package/tests/conftest.py +++ b/package/tests/conftest.py @@ -60,10 +60,10 @@ def example_stats_dict(): @pytest.fixture def example_pipelines(): def process_data(raw_data, train_test_split): - ... + pass def train_model(model_inputs, parameters): - ... + pass data_processing_pipeline = pipeline( [ @@ -420,10 +420,10 @@ def example_catalog(): @pytest.fixture def example_transcoded_pipelines(): def process_data(raw_data, train_test_split): - ... + pass def train_model(model_inputs, parameters): - ... + pass data_processing_pipeline = pipeline( [ diff --git a/package/tests/test_api/test_graphql/test_queries.py b/package/tests/test_api/test_graphql/test_queries.py index 16cfd36ae4..05dcf6fcda 100644 --- a/package/tests/test_api/test_graphql/test_queries.py +++ b/package/tests/test_api/test_graphql/test_queries.py @@ -1,5 +1,3 @@ -# pylint:disable=line-too-long - import json import pytest diff --git a/package/tests/test_api/test_rest/test_responses.py b/package/tests/test_api/test_rest/test_responses.py index 3f75904404..6f4581d3a3 100644 --- a/package/tests/test_api/test_rest/test_responses.py +++ b/package/tests/test_api/test_rest/test_responses.py @@ -1,4 +1,3 @@ -# pylint: disable=too-many-lines import json import operator from pathlib import Path @@ -628,7 +627,7 @@ def test_task_node_metadata(self, client): metadata = response.json() assert ( metadata["code"].replace(" ", "") - == "defprocess_data(raw_data,train_test_split):\n...\n" + == "defprocess_data(raw_data,train_test_split):\npass\n" ) assert metadata["parameters"] == {"uk.data_processing.train_test_split": 0.1} assert metadata["inputs"] == [ diff --git a/package/tests/test_import.py b/package/tests/test_import.py index e67a60c380..e9e918c6c1 100644 --- a/package/tests/test_import.py +++ b/package/tests/test_import.py @@ -12,8 +12,7 @@ def test_import_kedro_viz_with_no_official_support_emits_warning(mocker): kedro_viz.__loader__.exec_module(kedro_viz) assert len(record) == 1 - assert ( - """Please be advised that Kedro Viz is not yet fully - compatible with the Python version you are currently using.""" - in record[0].message.args[0] - ) + assert """Please be advised that Kedro Viz is not yet fully + compatible with the Python version you are currently using.""" in record[ + 0 + ].message.args[0] diff --git a/package/tests/test_integrations/test_sqlite_store.py b/package/tests/test_integrations/test_sqlite_store.py index ec14c68730..4f0cb6a00b 100644 --- a/package/tests/test_integrations/test_sqlite_store.py +++ b/package/tests/test_integrations/test_sqlite_store.py @@ -1,6 +1,3 @@ -# We need to disable pylint because of this issue - -# https://github.com/pylint-dev/pylint/issues/8138 -# pylint: disable=E1102 import json import os from pathlib import Path diff --git a/ruff.toml b/ruff.toml new file mode 100644 index 0000000000..52a1d6c8f3 --- /dev/null +++ b/ruff.toml @@ -0,0 +1,85 @@ +target-version = "py39" + +include = [ + "package/kedro_viz/*.py", + "package/tests/*.py", + "package/features/*.py", + "demo-project/*.py", +] + +[lint] +select = [ + "I", # Isort + "B", # Bugbear + "BLE", # Blind exceptions + "PL", # Pylint + "C90", # Mccabe complexity + "E", # Pycodestyle errors + "F", # Pyflakes + "W", # Pycodestyle warnings + "N", # PEP8-compliant object names + "SLF", # Private members access + "D101", # Class docstrings + "D103", # Function docstrings + "ARG", # Unused arguments + "T10", # Debug statements +] +ignore = [ + "E203", + "E231", + "E266", + "E501", + "F401", + "B030", # Except handler is something other than exception class + "C405", # Inconsistent definition of literal collections + "PLR2004", # Magic values in comparisons +] + +[lint.per-file-ignores] +"*/cli_steps.py" = ["B011"] # assert False instead of AssertionError +"*/base_deployer.py" = ["B024"] # ABCs without abstract methods +"package/kedro_viz/__init__.py" = ["B028"] # Risky usage of positional arguments +"package/tests/test_integrations/test_sqlite_store.py" = ["C401"] # Unnecessary generators +"package/kedro_viz/data_access/repositories/tags.py" = ["C413", "D101", "D103"] +"package/kedro_viz/data_access/repositories/catalog.py" = ["PLW2901", "SLF", "D"] +"package/features/steps/sh_run.py" = ["PLW1510"] # `subprocess.run` without explicit `check` argument +"*/tests/*.py" = ["SLF", "D", "ARG"] +"package/kedro_viz/models/experiment_tracking.py" = ["SLF"] +"package/kedro_viz/models/flowchart.py" = ["SLF"] +"package/kedro_viz/integrations/kedro/hooks.py" = ["SLF", "BLE"] +"package/kedro_viz/integrations/kedro/sqlite_store.py" = ["BLE"] +"package/kedro_viz/integrations/kedro/data_loader.py" = ["SLF"] +"package/kedro_viz/data_access/managers.py" = ["SLF"] +"package/kedro_viz/data_access/repositories/tracking_datasets.py" = ["SLF", "D"] +"package/kedro_viz/launchers/cli/main.py" = ["D"] +"package/kedro_viz/api/rest/router.py" = ["D"] +"package/features/steps/cli_steps.py" = ["D"] +"package/features/environment.py" = ["D"] +"package/kedro_viz/api/graphql/schema.py" = ["D"] +"package/kedro_viz/data_access/repositories/registered_pipelines.py" = ["D"] +"package/kedro_viz/api/rest/responses.py" = ["D"] +"package/kedro_viz/api/graphql/types.py" = ["D"] +"package/kedro_viz/data_access/repositories/graph.py" = ["D"] +"package/kedro_viz/data_access/repositories/runs.py" = ["D"] +"demo-project/*.py" = ["D", "ARG", "PLW0603"] # Allow unused arguments in node functions for them to generate constant outputs, but mimic the DAG and technically have inputs. + +[lint.mccabe] +max-complexity = 18 + +[lint.pylint] +max-args = 12 + +[lint.pep8-naming] +extend-ignore-names = [ + "ServiceRequestError", + "mock_DeployerFactory", + "Session", + "WaitForException", + "displayHTML", + "nodeId*", + "pipelineId*", + "*_None_*", + "X_test", + "X_train", + "X", +] From 4db2bf95a25f438deba5d6ba739e7b7acd0b45e2 Mon Sep 17 00:00:00 2001 From: Sajid Alam <90610031+SajidAlamQB@users.noreply.github.com> Date: Wed, 30 Oct 2024 11:15:43 +0000 Subject: [PATCH 15/35] Breakdown flowchart models into separate files (#2144) * imitial Signed-off-by: Sajid Alam * update Signed-off-by: Sajid Alam * split into modular pipelines Signed-off-by: Sajid Alam * remove comment Signed-off-by: Sajid Alam * move GraphNodeType to nodes Signed-off-by: Sajid Alam * refactor Signed-off-by: Sajid Alam * fix refactors Signed-off-by: Sajid Alam * fix imports Signed-off-by: Sajid Alam * resolve circular dependency Signed-off-by: Sajid Alam * fix tests Signed-off-by: Sajid Alam * lint Signed-off-by: Sajid Alam * changes based on review Signed-off-by: Sajid Alam * split flowchart test file Signed-off-by: Sajid Alam * Update node_metadata.py Signed-off-by: Sajid Alam * Update ruff.toml Signed-off-by: Sajid Alam * lint Signed-off-by: Sajid Alam * move test files Signed-off-by: Sajid Alam * moved to named_entities.py Signed-off-by: Sajid Alam --------- Signed-off-by: Sajid Alam --- package/kedro_viz/api/rest/responses.py | 6 +- package/kedro_viz/data_access/managers.py | 8 +- .../data_access/repositories/graph.py | 3 +- .../repositories/modular_pipelines.py | 4 +- .../repositories/registered_pipelines.py | 2 +- .../data_access/repositories/tags.py | 2 +- .../kedro_viz/models/flowchart/__init__.py | 0 package/kedro_viz/models/flowchart/edge.py | 15 + .../kedro_viz/models/flowchart/model_utils.py | 45 ++ .../models/flowchart/named_entities.py | 41 ++ .../models/flowchart/node_metadata.py | 406 +++++++++++++ .../{flowchart.py => flowchart/nodes.py} | 539 ++---------------- package/kedro_viz/services/layers.py | 2 +- package/tests/conftest.py | 3 +- .../test_api/test_rest/test_responses.py | 2 +- .../tests/test_data_access/test_managers.py | 6 +- .../test_repositories/test_graph.py | 3 +- .../test_modular_pipelines.py | 7 +- .../test_models/test_flowchart/__init__.py | 0 .../test_node_metadata.py} | 268 +-------- .../test_models/test_flowchart/test_nodes.py | 248 ++++++++ .../test_flowchart/test_pipeline.py | 32 ++ package/tests/test_services/test_layers.py | 2 +- ruff.toml | 3 +- 24 files changed, 855 insertions(+), 792 deletions(-) create mode 100644 package/kedro_viz/models/flowchart/__init__.py create mode 100644 package/kedro_viz/models/flowchart/edge.py create mode 100644 package/kedro_viz/models/flowchart/model_utils.py create mode 100644 package/kedro_viz/models/flowchart/named_entities.py create mode 100644 package/kedro_viz/models/flowchart/node_metadata.py rename package/kedro_viz/models/{flowchart.py => flowchart/nodes.py} (53%) create mode 100644 package/tests/test_models/test_flowchart/__init__.py rename package/tests/test_models/{test_flowchart.py => test_flowchart/test_node_metadata.py} (55%) create mode 100644 package/tests/test_models/test_flowchart/test_nodes.py create mode 100644 package/tests/test_models/test_flowchart/test_pipeline.py diff --git a/package/kedro_viz/api/rest/responses.py b/package/kedro_viz/api/rest/responses.py index 5a38ef6b4c..1e885eced1 100644 --- a/package/kedro_viz/api/rest/responses.py +++ b/package/kedro_viz/api/rest/responses.py @@ -12,15 +12,13 @@ from kedro_viz.api.rest.utils import get_package_compatibilities from kedro_viz.data_access import data_access_manager -from kedro_viz.models.flowchart import ( - DataNode, +from kedro_viz.models.flowchart.node_metadata import ( DataNodeMetadata, ParametersNodeMetadata, - TaskNode, TaskNodeMetadata, - TranscodedDataNode, TranscodedDataNodeMetadata, ) +from kedro_viz.models.flowchart.nodes import DataNode, TaskNode, TranscodedDataNode from kedro_viz.models.metadata import Metadata, PackageCompatibility logger = logging.getLogger(__name__) diff --git a/package/kedro_viz/data_access/managers.py b/package/kedro_viz/data_access/managers.py index 40e8ac56f6..4468804c77 100644 --- a/package/kedro_viz/data_access/managers.py +++ b/package/kedro_viz/data_access/managers.py @@ -20,15 +20,15 @@ from kedro_viz.constants import DEFAULT_REGISTERED_PIPELINE_ID, ROOT_MODULAR_PIPELINE_ID from kedro_viz.integrations.utils import UnavailableDataset -from kedro_viz.models.flowchart import ( +from kedro_viz.models.flowchart.edge import GraphEdge +from kedro_viz.models.flowchart.model_utils import GraphNodeType +from kedro_viz.models.flowchart.named_entities import RegisteredPipeline +from kedro_viz.models.flowchart.nodes import ( DataNode, - GraphEdge, GraphNode, - GraphNodeType, ModularPipelineChild, ModularPipelineNode, ParametersNode, - RegisteredPipeline, TaskNode, TranscodedDataNode, ) diff --git a/package/kedro_viz/data_access/repositories/graph.py b/package/kedro_viz/data_access/repositories/graph.py index 601e52d060..bea6095bc9 100644 --- a/package/kedro_viz/data_access/repositories/graph.py +++ b/package/kedro_viz/data_access/repositories/graph.py @@ -3,7 +3,8 @@ from typing import Dict, Generator, List, Optional, Set -from kedro_viz.models.flowchart import GraphEdge, GraphNode +from kedro_viz.models.flowchart.edge import GraphEdge +from kedro_viz.models.flowchart.nodes import GraphNode class GraphNodesRepository: diff --git a/package/kedro_viz/data_access/repositories/modular_pipelines.py b/package/kedro_viz/data_access/repositories/modular_pipelines.py index 746f6700df..dc51df7f80 100644 --- a/package/kedro_viz/data_access/repositories/modular_pipelines.py +++ b/package/kedro_viz/data_access/repositories/modular_pipelines.py @@ -8,9 +8,9 @@ from kedro.pipeline.node import Node as KedroNode from kedro_viz.constants import ROOT_MODULAR_PIPELINE_ID -from kedro_viz.models.flowchart import ( +from kedro_viz.models.flowchart.model_utils import GraphNodeType +from kedro_viz.models.flowchart.nodes import ( GraphNode, - GraphNodeType, ModularPipelineChild, ModularPipelineNode, ) diff --git a/package/kedro_viz/data_access/repositories/registered_pipelines.py b/package/kedro_viz/data_access/repositories/registered_pipelines.py index d73f621867..1309548fac 100644 --- a/package/kedro_viz/data_access/repositories/registered_pipelines.py +++ b/package/kedro_viz/data_access/repositories/registered_pipelines.py @@ -4,7 +4,7 @@ from collections import OrderedDict, defaultdict from typing import Dict, List, Optional, Set -from kedro_viz.models.flowchart import RegisteredPipeline +from kedro_viz.models.flowchart.named_entities import RegisteredPipeline class RegisteredPipelinesRepository: diff --git a/package/kedro_viz/data_access/repositories/tags.py b/package/kedro_viz/data_access/repositories/tags.py index 0bb46949ac..a7bd33e31f 100644 --- a/package/kedro_viz/data_access/repositories/tags.py +++ b/package/kedro_viz/data_access/repositories/tags.py @@ -3,7 +3,7 @@ from typing import Iterable, List, Set -from kedro_viz.models.flowchart import Tag +from kedro_viz.models.flowchart.named_entities import Tag class TagsRepository: diff --git a/package/kedro_viz/models/flowchart/__init__.py b/package/kedro_viz/models/flowchart/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/package/kedro_viz/models/flowchart/edge.py b/package/kedro_viz/models/flowchart/edge.py new file mode 100644 index 0000000000..439cafc782 --- /dev/null +++ b/package/kedro_viz/models/flowchart/edge.py @@ -0,0 +1,15 @@ +"""`kedro_viz.models.flowchart.edge` defines data models to represent Kedro edges in a viz graph.""" + +from pydantic import BaseModel + + +class GraphEdge(BaseModel, frozen=True): + """Represent an edge in the graph + + Args: + source (str): The id of the source node. + target (str): The id of the target node. + """ + + source: str + target: str diff --git a/package/kedro_viz/models/flowchart/model_utils.py b/package/kedro_viz/models/flowchart/model_utils.py new file mode 100644 index 0000000000..f12e94b669 --- /dev/null +++ b/package/kedro_viz/models/flowchart/model_utils.py @@ -0,0 +1,45 @@ +"""`kedro_viz.models.flowchart.model_utils` defines utils for Kedro entities in a viz graph.""" + +import logging +from enum import Enum +from types import FunctionType +from typing import Any, Dict, Optional + +logger = logging.getLogger(__name__) + + +def _parse_filepath(dataset_description: Dict[str, Any]) -> Optional[str]: + """ + Extract the file path from a dataset description dictionary. + """ + filepath = dataset_description.get("filepath") or dataset_description.get("path") + return str(filepath) if filepath else None + + +def _extract_wrapped_func(func: FunctionType) -> FunctionType: + """Extract a wrapped decorated function to inspect the source code if available. + Adapted from https://stackoverflow.com/a/43506509/1684058 + """ + if func.__closure__ is None: + return func + closure = (c.cell_contents for c in func.__closure__) + wrapped_func = next((c for c in closure if isinstance(c, FunctionType)), None) + # return the original function if it's not a decorated function + return func if wrapped_func is None else wrapped_func + + +# ============================================================================= +# Shared base classes and enumerations for model components +# ============================================================================= + + +class GraphNodeType(str, Enum): + """Represent all possible node types in the graph representation of a Kedro pipeline. + The type needs to inherit from str as well so FastAPI can serialise it. See: + https://fastapi.tiangolo.com/tutorial/path-params/#working-with-python-enumerations + """ + + TASK = "task" + DATA = "data" + PARAMETERS = "parameters" + MODULAR_PIPELINE = "modularPipeline" # CamelCase for frontend compatibility diff --git a/package/kedro_viz/models/flowchart/named_entities.py b/package/kedro_viz/models/flowchart/named_entities.py new file mode 100644 index 0000000000..65944c0764 --- /dev/null +++ b/package/kedro_viz/models/flowchart/named_entities.py @@ -0,0 +1,41 @@ +"""kedro_viz.models.flowchart.named_entities` defines data models for representing named entities +such as tags and registered pipelines within a Kedro visualization graph.""" + +from typing import Optional + +from pydantic import BaseModel, Field, ValidationInfo, field_validator + + +class NamedEntity(BaseModel): + """Represent a named entity (Tag/Registered Pipeline) in a Kedro project + Args: + id (str): Id of the registered pipeline + + Raises: + AssertionError: If id is not supplied during instantiation + """ + + id: str + name: Optional[str] = Field( + default=None, + validate_default=True, + description="The name of the entity", + ) + + @field_validator("name") + @classmethod + def set_name(cls, _, info: ValidationInfo): + """Ensures that the 'name' field is set to the value of 'id' if 'name' is not provided.""" + assert "id" in info.data + return info.data["id"] + + +class RegisteredPipeline(NamedEntity): + """Represent a registered pipeline in a Kedro project.""" + + +class Tag(NamedEntity): + """Represent a tag in a Kedro project.""" + + def __hash__(self) -> int: + return hash(self.id) diff --git a/package/kedro_viz/models/flowchart/node_metadata.py b/package/kedro_viz/models/flowchart/node_metadata.py new file mode 100644 index 0000000000..20940a9b3a --- /dev/null +++ b/package/kedro_viz/models/flowchart/node_metadata.py @@ -0,0 +1,406 @@ +""" +`kedro_viz.models.flowchart.node_metadata` defines data models to represent +Kedro metadata in a visualization graph. +""" + +import inspect +import logging +from abc import ABC +from pathlib import Path +from typing import ClassVar, Dict, List, Optional, Union, cast + +from kedro.pipeline.node import Node as KedroNode +from pydantic import BaseModel, Field, field_validator, model_validator + +try: + # kedro 0.18.12 onwards + from kedro.io.core import AbstractDataset +except ImportError: # pragma: no cover + # older versions + from kedro.io.core import AbstractDataSet as AbstractDataset # type: ignore + +from kedro_viz.models.utils import get_dataset_type + +from .model_utils import _extract_wrapped_func, _parse_filepath +from .nodes import DataNode, ParametersNode, TaskNode, TranscodedDataNode + +logger = logging.getLogger(__name__) + + +class GraphNodeMetadata(BaseModel, ABC): + """Represent a graph node's metadata.""" + + +class TaskNodeMetadata(GraphNodeMetadata): + """Represent the metadata of a TaskNode. + + Args: + task_node (TaskNode): Task node to which this metadata belongs to. + + Raises: + AssertionError: If task_node is not supplied during instantiation. + """ + + task_node: TaskNode = Field(..., exclude=True) + + code: Optional[str] = Field( + default=None, + validate_default=True, + description="Source code of the node's function", + ) + + filepath: Optional[str] = Field( + default=None, + validate_default=True, + description="Path to the file where the node is defined", + ) + + parameters: Optional[Dict] = Field( + default=None, + validate_default=True, + description="The parameters of the node, if available", + ) + run_command: Optional[str] = Field( + default=None, + validate_default=True, + description="The command to run the pipeline to this node", + ) + + inputs: Optional[List[str]] = Field( + default=None, validate_default=True, description="The inputs to the TaskNode" + ) + outputs: Optional[List[str]] = Field( + default=None, validate_default=True, description="The outputs from the TaskNode" + ) + + @model_validator(mode="before") + @classmethod + def check_task_node_exists(cls, values): + assert "task_node" in values + cls.set_task_and_kedro_node(values["task_node"]) + return values + + @classmethod + def set_task_and_kedro_node(cls, task_node): + cls.task_node = task_node + cls.kedro_node = cast(KedroNode, task_node.kedro_obj) + + @field_validator("code") + @classmethod + def set_code(cls, code): + # this is required to handle partial, curry functions + if inspect.isfunction(cls.kedro_node.func): + code = inspect.getsource(_extract_wrapped_func(cls.kedro_node.func)) + return code + + return None + + @field_validator("filepath") + @classmethod + def set_filepath(cls, filepath): + # this is required to handle partial, curry functions + if inspect.isfunction(cls.kedro_node.func): + code_full_path = ( + Path(inspect.getfile(cls.kedro_node.func)).expanduser().resolve() + ) + + try: + filepath = code_full_path.relative_to(Path.cwd().parent) + except ValueError: # pragma: no cover + # if the filepath can't be resolved relative to the current directory, + # e.g. either during tests or during launching development server + # outside of a Kedro project, simply return the fullpath to the file. + filepath = code_full_path + + return str(filepath) + + return None + + @field_validator("parameters") + @classmethod + def set_parameters(cls, _): + return cls.task_node.parameters + + @field_validator("run_command") + @classmethod + def set_run_command(cls, _): + return f"kedro run --to-nodes='{cls.kedro_node.name}'" + + @field_validator("inputs") + @classmethod + def set_inputs(cls, _): + return cls.kedro_node.inputs + + @field_validator("outputs") + @classmethod + def set_outputs(cls, _): + return cls.kedro_node.outputs + + +class DataNodeMetadata(GraphNodeMetadata): + """Represent the metadata of a DataNode. + + Args: + data_node (DataNode): Data node to which this metadata belongs to. + + Attributes: + is_all_previews_enabled (bool): Class-level attribute to determine if + previews are enabled for all nodes. This can be configured via CLI + or UI to manage the preview settings. + + Raises: + AssertionError: If data_node is not supplied during instantiation. + """ + + data_node: DataNode = Field(..., exclude=True) + + is_all_previews_enabled: ClassVar[bool] = True + + type: Optional[str] = Field( + default=None, validate_default=True, description="The type of the data node" + ) + + filepath: Optional[str] = Field( + default=None, + validate_default=True, + description="The path to the actual data file for the underlying dataset", + ) + + run_command: Optional[str] = Field( + default=None, + validate_default=True, + description="Command to run the pipeline to this node", + ) + + preview: Optional[Union[Dict, str]] = Field( + default=None, + validate_default=True, + description="Preview data for the underlying datanode", + ) + + preview_type: Optional[str] = Field( + default=None, + validate_default=True, + description="Type of preview for the dataset", + ) + + stats: Optional[Dict] = Field( + default=None, + validate_default=True, + description="The statistics for the data node.", + ) + + @model_validator(mode="before") + @classmethod + def check_data_node_exists(cls, values): + assert "data_node" in values + cls.set_data_node_and_dataset(values["data_node"]) + return values + + @classmethod + def set_is_all_previews_enabled(cls, value: bool): + cls.is_all_previews_enabled = value + + @classmethod + def set_data_node_and_dataset(cls, data_node): + cls.data_node = data_node + cls.dataset = cast(AbstractDataset, data_node.kedro_obj) + + # dataset.release clears the cache before loading to ensure that this issue + # does not arise: https://github.com/kedro-org/kedro-viz/pull/573. + cls.dataset.release() + + @field_validator("type") + @classmethod + def set_type(cls, _): + return cls.data_node.dataset_type + + @field_validator("filepath") + @classmethod + def set_filepath(cls, _): + dataset_description = cls.dataset._describe() + return _parse_filepath(dataset_description) + + @field_validator("run_command") + @classmethod + def set_run_command(cls, _): + if not cls.data_node.is_free_input: + return f"kedro run --to-outputs={cls.data_node.name}" + return None + + @field_validator("preview") + @classmethod + def set_preview(cls, _): + if ( + not cls.data_node.is_preview_enabled() + or not hasattr(cls.dataset, "preview") + or not cls.is_all_previews_enabled + ): + return None + + try: + preview_args = ( + cls.data_node.get_preview_args() if cls.data_node.viz_metadata else None + ) + if preview_args is None: + return cls.dataset.preview() + return cls.dataset.preview(**preview_args) + + except Exception as exc: # noqa: BLE001 + logger.warning( + "'%s' could not be previewed. Full exception: %s: %s", + cls.data_node.name, + type(exc).__name__, + exc, + ) + return None + + @field_validator("preview_type") + @classmethod + def set_preview_type(cls, _): + if ( + not cls.data_node.is_preview_enabled() + or not hasattr(cls.dataset, "preview") + or not cls.is_all_previews_enabled + ): + return None + + try: + preview_type_annotation = inspect.signature( + cls.dataset.preview + ).return_annotation + # Attempt to get the name attribute, if it exists. + # Otherwise, use str to handle the annotation directly. + preview_type_name = getattr( + preview_type_annotation, "__name__", str(preview_type_annotation) + ) + return preview_type_name + + except Exception as exc: # noqa: BLE001 # pragma: no cover + logger.warning( + "'%s' did not have preview type. Full exception: %s: %s", + cls.data_node.name, + type(exc).__name__, + exc, + ) + return None + + @field_validator("stats") + @classmethod + def set_stats(cls, _): + return cls.data_node.stats + + +class TranscodedDataNodeMetadata(GraphNodeMetadata): + """Represent the metadata of a TranscodedDataNode. + Args: + transcoded_data_node: The transcoded data node to which this metadata belongs. + + Raises: + AssertionError: If `transcoded_data_node` is not supplied during instantiation. + """ + + transcoded_data_node: TranscodedDataNode = Field(..., exclude=True) + + # Only available if the dataset has filepath set. + filepath: Optional[str] = Field( + default=None, + validate_default=True, + description="The path to the actual data file for the underlying dataset", + ) + + run_command: Optional[str] = Field( + default=None, + validate_default=True, + description="Command to run the pipeline to this node", + ) + original_type: Optional[str] = Field( + default=None, + validate_default=True, + description="The dataset type of the underlying transcoded data node original version", + ) + transcoded_types: Optional[List[str]] = Field( + default=None, + validate_default=True, + description="The list of all dataset types for the transcoded versions", + ) + + # Statistics for the underlying data node + stats: Optional[Dict] = Field( + default=None, + validate_default=True, + description="The statistics for the transcoded data node metadata.", + ) + + @model_validator(mode="before") + @classmethod + def check_transcoded_data_node_exists(cls, values): + assert "transcoded_data_node" in values + cls.transcoded_data_node = values["transcoded_data_node"] + return values + + @field_validator("filepath") + @classmethod + def set_filepath(cls, _): + dataset_description = cls.transcoded_data_node.original_version._describe() + return _parse_filepath(dataset_description) + + @field_validator("run_command") + @classmethod + def set_run_command(cls, _): + if not cls.transcoded_data_node.is_free_input: + return f"kedro run --to-outputs={cls.transcoded_data_node.original_name}" + return None + + @field_validator("original_type") + @classmethod + def set_original_type(cls, _): + return get_dataset_type(cls.transcoded_data_node.original_version) + + @field_validator("transcoded_types") + @classmethod + def set_transcoded_types(cls, _): + return [ + get_dataset_type(transcoded_version) + for transcoded_version in cls.transcoded_data_node.transcoded_versions + ] + + @field_validator("stats") + @classmethod + def set_stats(cls, _): + return cls.transcoded_data_node.stats + + +class ParametersNodeMetadata(GraphNodeMetadata): + """Represent the metadata of a ParametersNode. + + Args: + parameters_node (ParametersNode): The underlying parameters node + for the parameters metadata node. + + Raises: + AssertionError: If parameters_node is not supplied during instantiation. + """ + + parameters_node: ParametersNode = Field(..., exclude=True) + parameters: Optional[Dict] = Field( + default=None, + validate_default=True, + description="The parameters dictionary for the parameters metadata node", + ) + + @model_validator(mode="before") + @classmethod + def check_parameters_node_exists(cls, values): + assert "parameters_node" in values + cls.parameters_node = values["parameters_node"] + return values + + @field_validator("parameters") + @classmethod + def set_parameters(cls, _): + if cls.parameters_node.is_single_parameter(): + return { + cls.parameters_node.parameter_name: cls.parameters_node.parameter_value + } + return cls.parameters_node.parameter_value diff --git a/package/kedro_viz/models/flowchart.py b/package/kedro_viz/models/flowchart/nodes.py similarity index 53% rename from package/kedro_viz/models/flowchart.py rename to package/kedro_viz/models/flowchart/nodes.py index 299dbc120e..0289fe1e1e 100644 --- a/package/kedro_viz/models/flowchart.py +++ b/package/kedro_viz/models/flowchart/nodes.py @@ -1,12 +1,8 @@ -"""`kedro_viz.models.flowchart` defines data models to represent Kedro entities in a viz graph.""" +"""`kedro_viz.models.flowchart.nodes` defines models to represent Kedro nodes in a viz graph.""" -import abc -import inspect import logging -from enum import Enum -from pathlib import Path -from types import FunctionType -from typing import Any, ClassVar, Dict, List, Optional, Set, Union, cast +from abc import ABC +from typing import Any, Dict, Optional, Set, Union, cast from fastapi.encoders import jsonable_encoder from kedro.pipeline.node import Node as KedroNode @@ -19,9 +15,6 @@ model_validator, ) -from kedro_viz.models.utils import get_dataset_type -from kedro_viz.utils import TRANSCODING_SEPARATOR, _strip_transcoding - try: # kedro 0.18.11 onwards from kedro.io.core import DatasetError @@ -35,75 +28,15 @@ # older versions from kedro.io.core import AbstractDataSet as AbstractDataset # type: ignore -logger = logging.getLogger(__name__) - - -def _parse_filepath(dataset_description: Dict[str, Any]) -> Optional[str]: - filepath = dataset_description.get("filepath") or dataset_description.get("path") - return str(filepath) if filepath else None - - -class NamedEntity(BaseModel): - """Represent a named entity (Tag/Registered Pipeline) in a Kedro project - Args: - id (str): Id of the registered pipeline - - Raises: - AssertionError: If id is not supplied during instantiation - """ - - id: str - name: Optional[str] = Field( - default=None, - validate_default=True, - description="The name of the registered pipeline", - ) - - @field_validator("name") - @classmethod - def set_name(cls, _, info: ValidationInfo): - assert "id" in info.data - return info.data["id"] - - -class RegisteredPipeline(NamedEntity): - """Represent a registered pipeline in a Kedro project""" - - -class GraphNodeType(str, Enum): - """Represent all possible node types in the graph representation of a Kedro pipeline. - The type needs to inherit from str as well so FastAPI can serialise it. See: - https://fastapi.tiangolo.com/tutorial/path-params/#working-with-python-enumerations - """ - - TASK = "task" - DATA = "data" - PARAMETERS = "parameters" - MODULAR_PIPELINE = ( - "modularPipeline" # camelCase so it can be referred directly to in the frontend - ) - - -class ModularPipelineChild(BaseModel, frozen=True): - """Represent a child of a modular pipeline. - - Args: - id (str): Id of the modular pipeline child - type (GraphNodeType): Type of modular pipeline child - """ - - id: str - type: GraphNodeType - +from kedro_viz.models.utils import get_dataset_type +from kedro_viz.utils import TRANSCODING_SEPARATOR, _strip_transcoding -class Tag(NamedEntity): - """Represent a tag in a Kedro project""" +from .model_utils import GraphNodeType - def __hash__(self) -> int: - return hash(self.id) +logger = logging.getLogger(__name__) -class GraphNode(BaseModel, abc.ABC): +class GraphNode(BaseModel, ABC): """Represent a node in the graph representation of a Kedro pipeline. All node models except the metadata node models should inherit from this class @@ -281,8 +214,16 @@ def has_metadata(self) -> bool: return self.kedro_obj is not None -class GraphNodeMetadata(BaseModel, abc.ABC): - """Represent a graph node's metadata""" +class ModularPipelineChild(BaseModel, frozen=True): + """Represent a child of a modular pipeline. + + Args: + id (str): Id of the modular pipeline child + type (GraphNodeType): Type of modular pipeline child + """ + + id: str + type: GraphNodeType class TaskNode(GraphNode): @@ -317,154 +258,6 @@ def set_namespace(cls, _, info: ValidationInfo): return info.data["kedro_obj"].namespace -def _extract_wrapped_func(func: FunctionType) -> FunctionType: - """Extract a wrapped decorated function to inspect the source code if available. - Adapted from https://stackoverflow.com/a/43506509/1684058 - """ - if func.__closure__ is None: - return func - closure = (c.cell_contents for c in func.__closure__) - wrapped_func = next((c for c in closure if isinstance(c, FunctionType)), None) - # return the original function if it's not a decorated function - return func if wrapped_func is None else wrapped_func - - -class ModularPipelineNode(GraphNode): - """Represent a modular pipeline node in the graph""" - - # A modular pipeline doesn't belong to any other modular pipeline, - # in the same sense as other types of GraphNode do. - # Therefore it's default to None. - # The parent-child relationship between modular pipeline themselves is modelled explicitly. - modular_pipelines: Optional[Set[str]] = None - - # Model the modular pipelines tree using a child-references representation of a tree. - # See: https://docs.mongodb.com/manual/tutorial/model-tree-structures-with-child-references/ - # for more details. - # For example, if a node namespace is "uk.data_science", - # the "uk" modular pipeline node's children are ["uk.data_science"] - children: Set[ModularPipelineChild] = Field( - set(), description="The children for the modular pipeline node" - ) - - inputs: Set[str] = Field( - set(), description="The input datasets to the modular pipeline node" - ) - - outputs: Set[str] = Field( - set(), description="The output datasets from the modular pipeline node" - ) - - # The type for Modular Pipeline Node - type: str = GraphNodeType.MODULAR_PIPELINE.value - - -class TaskNodeMetadata(GraphNodeMetadata): - """Represent the metadata of a TaskNode - - Args: - task_node (TaskNode): Task node to which this metadata belongs to. - - Raises: - AssertionError: If task_node is not supplied during instantiation - """ - - task_node: TaskNode = Field(..., exclude=True) - - code: Optional[str] = Field( - default=None, - validate_default=True, - description="Source code of the node's function", - ) - - filepath: Optional[str] = Field( - default=None, - validate_default=True, - description="Path to the file where the node is defined", - ) - - parameters: Optional[Dict] = Field( - default=None, - validate_default=True, - description="The parameters of the node, if available", - ) - run_command: Optional[str] = Field( - default=None, - validate_default=True, - description="The command to run the pipeline to this node", - ) - - inputs: Optional[List[str]] = Field( - default=None, validate_default=True, description="The inputs to the TaskNode" - ) - outputs: Optional[List[str]] = Field( - default=None, validate_default=True, description="The outputs from the TaskNode" - ) - - @model_validator(mode="before") - @classmethod - def check_task_node_exists(cls, values): - assert "task_node" in values - cls.set_task_and_kedro_node(values["task_node"]) - return values - - @classmethod - def set_task_and_kedro_node(cls, task_node): - cls.task_node = task_node - cls.kedro_node = cast(KedroNode, task_node.kedro_obj) - - @field_validator("code") - @classmethod - def set_code(cls, code): - # this is required to handle partial, curry functions - if inspect.isfunction(cls.kedro_node.func): - code = inspect.getsource(_extract_wrapped_func(cls.kedro_node.func)) - return code - - return None - - @field_validator("filepath") - @classmethod - def set_filepath(cls, filepath): - # this is required to handle partial, curry functions - if inspect.isfunction(cls.kedro_node.func): - code_full_path = ( - Path(inspect.getfile(cls.kedro_node.func)).expanduser().resolve() - ) - - try: - filepath = code_full_path.relative_to(Path.cwd().parent) - except ValueError: # pragma: no cover - # if the filepath can't be resolved relative to the current directory, - # e.g. either during tests or during launching development server - # outside of a Kedro project, simply return the fullpath to the file. - filepath = code_full_path - - return str(filepath) - - return None - - @field_validator("parameters") - @classmethod - def set_parameters(cls, _): - return cls.task_node.parameters - - @field_validator("run_command") - @classmethod - def set_run_command(cls, _): - return f"kedro run --to-nodes='{cls.kedro_node.name}'" - - @field_validator("inputs") - @classmethod - def set_inputs(cls, _): - return cls.kedro_node.inputs - - @field_validator("outputs") - @classmethod - def set_outputs(cls, _): - return cls.kedro_node.outputs - - class DataNode(GraphNode): """Represent a graph node of type data @@ -580,241 +373,6 @@ def has_metadata(self) -> bool: return True -class DataNodeMetadata(GraphNodeMetadata): - """Represent the metadata of a DataNode - - Args: - data_node (DataNode): Data node to which this metadata belongs to. - - Attributes: - is_all_previews_enabled (bool): Class-level attribute to determine if - previews are enabled for all nodes. This can be configured via CLI - or UI to manage the preview settings. - - Raises: - AssertionError: If data_node is not supplied during instantiation - """ - - data_node: DataNode = Field(..., exclude=True) - - is_all_previews_enabled: ClassVar[bool] = True - - type: Optional[str] = Field( - default=None, validate_default=True, description="The type of the data node" - ) - - filepath: Optional[str] = Field( - default=None, - validate_default=True, - description="The path to the actual data file for the underlying dataset", - ) - - run_command: Optional[str] = Field( - default=None, - validate_default=True, - description="Command to run the pipeline to this node", - ) - - preview: Optional[Union[Dict, str]] = Field( - default=None, - validate_default=True, - description="Preview data for the underlying datanode", - ) - - preview_type: Optional[str] = Field( - default=None, - validate_default=True, - description="Type of preview for the dataset", - ) - - stats: Optional[Dict] = Field( - default=None, - validate_default=True, - description="The statistics for the data node.", - ) - - @model_validator(mode="before") - @classmethod - def check_data_node_exists(cls, values): - assert "data_node" in values - cls.set_data_node_and_dataset(values["data_node"]) - return values - - @classmethod - def set_is_all_previews_enabled(cls, value: bool): - cls.is_all_previews_enabled = value - - @classmethod - def set_data_node_and_dataset(cls, data_node): - cls.data_node = data_node - cls.dataset = cast(AbstractDataset, data_node.kedro_obj) - - # dataset.release clears the cache before loading to ensure that this issue - # does not arise: https://github.com/kedro-org/kedro-viz/pull/573. - cls.dataset.release() - - @field_validator("type") - @classmethod - def set_type(cls, _): - return cls.data_node.dataset_type - - @field_validator("filepath") - @classmethod - def set_filepath(cls, _): - dataset_description = cls.dataset._describe() - return _parse_filepath(dataset_description) - - @field_validator("run_command") - @classmethod - def set_run_command(cls, _): - if not cls.data_node.is_free_input: - return f"kedro run --to-outputs={cls.data_node.name}" - return None - - @field_validator("preview") - @classmethod - def set_preview(cls, _): - if ( - not cls.data_node.is_preview_enabled() - or not hasattr(cls.dataset, "preview") - or not cls.is_all_previews_enabled - ): - return None - - try: - preview_args = ( - cls.data_node.get_preview_args() if cls.data_node.viz_metadata else None - ) - if preview_args is None: - return cls.dataset.preview() - return cls.dataset.preview(**preview_args) - - except Exception as exc: # noqa: BLE001 - logger.warning( - "'%s' could not be previewed. Full exception: %s: %s", - cls.data_node.name, - type(exc).__name__, - exc, - ) - return None - - @field_validator("preview_type") - @classmethod - def set_preview_type(cls, _): - if ( - not cls.data_node.is_preview_enabled() - or not hasattr(cls.dataset, "preview") - or not cls.is_all_previews_enabled - ): - return None - - try: - preview_type_annotation = inspect.signature( - cls.dataset.preview - ).return_annotation - # Attempt to get the name attribute, if it exists. - # Otherwise, use str to handle the annotation directly. - preview_type_name = getattr( - preview_type_annotation, "__name__", str(preview_type_annotation) - ) - return preview_type_name - - except Exception as exc: # noqa: BLE001 # pragma: no cover - logger.warning( - "'%s' did not have preview type. Full exception: %s: %s", - cls.data_node.name, - type(exc).__name__, - exc, - ) - return None - - @field_validator("stats") - @classmethod - def set_stats(cls, _): - return cls.data_node.stats - - -class TranscodedDataNodeMetadata(GraphNodeMetadata): - """Represent the metadata of a TranscodedDataNode - Args: - transcoded_data_node (TranscodedDataNode): The underlying transcoded - data node to which this metadata belongs to. - - Raises: - AssertionError: If transcoded_data_node is not supplied during instantiation - """ - - transcoded_data_node: TranscodedDataNode = Field(..., exclude=True) - - # Only available if the dataset has filepath set. - filepath: Optional[str] = Field( - default=None, - validate_default=True, - description="The path to the actual data file for the underlying dataset", - ) - - run_command: Optional[str] = Field( - default=None, - validate_default=True, - description="Command to run the pipeline to this node", - ) - original_type: Optional[str] = Field( - default=None, - validate_default=True, - description="The dataset type of the underlying transcoded data node original version", - ) - transcoded_types: Optional[List[str]] = Field( - default=None, - validate_default=True, - description="The list of all dataset types for the transcoded versions", - ) - - # Statistics for the underlying data node - stats: Optional[Dict] = Field( - default=None, - validate_default=True, - description="The statistics for the transcoded data node metadata.", - ) - - @model_validator(mode="before") - @classmethod - def check_transcoded_data_node_exists(cls, values): - assert "transcoded_data_node" in values - cls.transcoded_data_node = values["transcoded_data_node"] - return values - - @field_validator("filepath") - @classmethod - def set_filepath(cls, _): - dataset_description = cls.transcoded_data_node.original_version._describe() - return _parse_filepath(dataset_description) - - @field_validator("run_command") - @classmethod - def set_run_command(cls, _): - if not cls.transcoded_data_node.is_free_input: - return f"kedro run --to-outputs={cls.transcoded_data_node.original_name}" - return None - - @field_validator("original_type") - @classmethod - def set_original_type(cls, _): - return get_dataset_type(cls.transcoded_data_node.original_version) - - @field_validator("transcoded_types") - @classmethod - def set_transcoded_types(cls, _): - return [ - get_dataset_type(transcoded_version) - for transcoded_version in cls.transcoded_data_node.transcoded_versions - ] - - @field_validator("stats") - @classmethod - def set_stats(cls, _): - return cls.transcoded_data_node.stats - - class ParametersNode(GraphNode): """Represent a graph node of type parameters Args: @@ -882,48 +440,31 @@ def parameter_value(self) -> Any: return None -class ParametersNodeMetadata(GraphNodeMetadata): - """Represent the metadata of a ParametersNode - - Args: - parameters_node (ParametersNode): The underlying parameters node - for the parameters metadata node. +class ModularPipelineNode(GraphNode): + """Represent a modular pipeline node in the graph""" - Raises: - AssertionError: If parameters_node is not supplied during instantiation - """ + # A modular pipeline doesn't belong to any other modular pipeline, + # in the same sense as other types of GraphNode do. + # Therefore, it's default to None. + # The parent-child relationship between modular pipeline themselves is modelled explicitly. + modular_pipelines: Optional[Set[str]] = None - parameters_node: ParametersNode = Field(..., exclude=True) - parameters: Optional[Dict] = Field( - default=None, - validate_default=True, - description="The parameters dictionary for the parameters metadata node", + # Model the modular pipelines tree using a child-references representation of a tree. + # See: https://docs.mongodb.com/manual/tutorial/model-tree-structures-with-child-references/ + # for more details. + # For example, if a node namespace is "uk.data_science", + # the "uk" modular pipeline node's children are ["uk.data_science"] + children: Set[ModularPipelineChild] = Field( + set(), description="The children for the modular pipeline node" ) - @model_validator(mode="before") - @classmethod - def check_parameters_node_exists(cls, values): - assert "parameters_node" in values - cls.parameters_node = values["parameters_node"] - return values - - @field_validator("parameters") - @classmethod - def set_parameters(cls, _): - if cls.parameters_node.is_single_parameter(): - return { - cls.parameters_node.parameter_name: cls.parameters_node.parameter_value - } - return cls.parameters_node.parameter_value - - -class GraphEdge(BaseModel, frozen=True): - """Represent an edge in the graph + inputs: Set[str] = Field( + set(), description="The input datasets to the modular pipeline node" + ) - Args: - source (str): The id of the source node. - target (str): The id of the target node. - """ + outputs: Set[str] = Field( + set(), description="The output datasets from the modular pipeline node" + ) - source: str - target: str + # The type for Modular Pipeline Node + type: str = GraphNodeType.MODULAR_PIPELINE.value diff --git a/package/kedro_viz/services/layers.py b/package/kedro_viz/services/layers.py index f8840534fc..7cba369aa1 100644 --- a/package/kedro_viz/services/layers.py +++ b/package/kedro_viz/services/layers.py @@ -5,7 +5,7 @@ from graphlib import CycleError, TopologicalSorter from typing import Dict, List, Set -from kedro_viz.models.flowchart import GraphNode +from kedro_viz.models.flowchart.nodes import GraphNode logger = logging.getLogger(__name__) diff --git a/package/tests/conftest.py b/package/tests/conftest.py index 7c66051328..c6b802974a 100644 --- a/package/tests/conftest.py +++ b/package/tests/conftest.py @@ -21,7 +21,8 @@ ) from kedro_viz.integrations.kedro.hooks import DatasetStatsHook from kedro_viz.integrations.kedro.sqlite_store import SQLiteStore -from kedro_viz.models.flowchart import DataNodeMetadata, GraphNode +from kedro_viz.models.flowchart.node_metadata import DataNodeMetadata +from kedro_viz.models.flowchart.nodes import GraphNode from kedro_viz.server import populate_data diff --git a/package/tests/test_api/test_rest/test_responses.py b/package/tests/test_api/test_rest/test_responses.py index 6f4581d3a3..8dbf549416 100644 --- a/package/tests/test_api/test_rest/test_responses.py +++ b/package/tests/test_api/test_rest/test_responses.py @@ -19,7 +19,7 @@ save_api_responses_to_fs, write_api_response_to_fs, ) -from kedro_viz.models.flowchart import TaskNode +from kedro_viz.models.flowchart.nodes import TaskNode from kedro_viz.models.metadata import Metadata diff --git a/package/tests/test_data_access/test_managers.py b/package/tests/test_data_access/test_managers.py index 66bd08f1e9..abb8df9be5 100644 --- a/package/tests/test_data_access/test_managers.py +++ b/package/tests/test_data_access/test_managers.py @@ -15,11 +15,11 @@ ModularPipelinesRepository, ) from kedro_viz.integrations.utils import UnavailableDataset -from kedro_viz.models.flowchart import ( +from kedro_viz.models.flowchart.edge import GraphEdge +from kedro_viz.models.flowchart.named_entities import Tag +from kedro_viz.models.flowchart.nodes import ( DataNode, - GraphEdge, ParametersNode, - Tag, TaskNode, TranscodedDataNode, ) diff --git a/package/tests/test_data_access/test_repositories/test_graph.py b/package/tests/test_data_access/test_repositories/test_graph.py index c45232ebd1..51f8684368 100644 --- a/package/tests/test_data_access/test_repositories/test_graph.py +++ b/package/tests/test_data_access/test_repositories/test_graph.py @@ -4,7 +4,8 @@ GraphEdgesRepository, GraphNodesRepository, ) -from kedro_viz.models.flowchart import GraphEdge, GraphNode +from kedro_viz.models.flowchart.edge import GraphEdge +from kedro_viz.models.flowchart.nodes import GraphNode class TestGraphNodeRepository: diff --git a/package/tests/test_data_access/test_repositories/test_modular_pipelines.py b/package/tests/test_data_access/test_repositories/test_modular_pipelines.py index 5b5a5e783b..ef6058ca8b 100644 --- a/package/tests/test_data_access/test_repositories/test_modular_pipelines.py +++ b/package/tests/test_data_access/test_repositories/test_modular_pipelines.py @@ -6,11 +6,8 @@ from kedro_viz.constants import ROOT_MODULAR_PIPELINE_ID from kedro_viz.data_access.repositories import ModularPipelinesRepository -from kedro_viz.models.flowchart import ( - GraphNodeType, - ModularPipelineChild, - ModularPipelineNode, -) +from kedro_viz.models.flowchart.model_utils import GraphNodeType +from kedro_viz.models.flowchart.nodes import ModularPipelineChild, ModularPipelineNode @pytest.fixture diff --git a/package/tests/test_models/test_flowchart/__init__.py b/package/tests/test_models/test_flowchart/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/package/tests/test_models/test_flowchart.py b/package/tests/test_models/test_flowchart/test_node_metadata.py similarity index 55% rename from package/tests/test_models/test_flowchart.py rename to package/tests/test_models/test_flowchart/test_node_metadata.py index 01238f286d..f8ebd4f8ec 100644 --- a/package/tests/test_models/test_flowchart.py +++ b/package/tests/test_models/test_flowchart/test_node_metadata.py @@ -1,7 +1,6 @@ from functools import partial from pathlib import Path from textwrap import dedent -from unittest.mock import call, patch import pytest from kedro.io import MemoryDataset @@ -9,18 +8,13 @@ from kedro_datasets.pandas import CSVDataset, ParquetDataset from kedro_datasets.partitions.partitioned_dataset import PartitionedDataset -from kedro_viz.models.flowchart import ( - DataNode, +from kedro_viz.models.flowchart.node_metadata import ( DataNodeMetadata, - GraphNode, - ParametersNode, ParametersNodeMetadata, - RegisteredPipeline, - TaskNode, TaskNodeMetadata, - TranscodedDataNode, TranscodedDataNodeMetadata, ) +from kedro_viz.models.flowchart.nodes import GraphNode def identity(x): @@ -56,264 +50,6 @@ def full_func(a, b, c, x): partial_func = partial(full_func, 3, 1, 4) -class TestGraphNodeCreation: - @pytest.mark.parametrize( - "namespace,expected_modular_pipelines", - [ - (None, set()), - ( - "uk.data_science.model_training", - set( - [ - "uk", - "uk.data_science", - "uk.data_science.model_training", - ] - ), - ), - ], - ) - def test_create_task_node(self, namespace, expected_modular_pipelines): - kedro_node = node( - identity, - inputs="x", - outputs="y", - name="identity_node", - tags={"tag"}, - namespace=namespace, - ) - task_node = GraphNode.create_task_node( - kedro_node, "identity_node", expected_modular_pipelines - ) - assert isinstance(task_node, TaskNode) - assert task_node.kedro_obj is kedro_node - assert task_node.name == "identity_node" - assert task_node.tags == {"tag"} - assert task_node.pipelines == set() - assert task_node.modular_pipelines == expected_modular_pipelines - assert task_node.namespace == namespace - - @pytest.mark.parametrize( - "dataset_name, expected_modular_pipelines", - [ - ("dataset", set()), - ( - "uk.data_science.model_training.dataset", - set( - [ - "uk", - "uk.data_science", - "uk.data_science.model_training", - ] - ), - ), - ], - ) - def test_create_data_node(self, dataset_name, expected_modular_pipelines): - kedro_dataset = CSVDataset(filepath="foo.csv") - data_node = GraphNode.create_data_node( - dataset_id=dataset_name, - dataset_name=dataset_name, - layer="raw", - tags=set(), - dataset=kedro_dataset, - stats={"rows": 10, "columns": 5, "file_size": 1024}, - modular_pipelines=set(expected_modular_pipelines), - ) - assert isinstance(data_node, DataNode) - assert data_node.kedro_obj is kedro_dataset - assert data_node.id == dataset_name - assert data_node.name == dataset_name - assert data_node.layer == "raw" - assert data_node.tags == set() - assert data_node.pipelines == set() - assert data_node.modular_pipelines == expected_modular_pipelines - assert data_node.stats["rows"] == 10 - assert data_node.stats["columns"] == 5 - assert data_node.stats["file_size"] == 1024 - - @pytest.mark.parametrize( - "transcoded_dataset_name, original_name", - [ - ("dataset@pandas2", "dataset"), - ( - "uk.data_science.model_training.dataset@pandas2", - "uk.data_science.model_training.dataset", - ), - ], - ) - def test_create_transcoded_data_node(self, transcoded_dataset_name, original_name): - kedro_dataset = CSVDataset(filepath="foo.csv") - data_node = GraphNode.create_data_node( - dataset_id=original_name, - dataset_name=transcoded_dataset_name, - layer="raw", - tags=set(), - dataset=kedro_dataset, - stats={"rows": 10, "columns": 2, "file_size": 1048}, - modular_pipelines=set(), - ) - assert isinstance(data_node, TranscodedDataNode) - assert data_node.id == original_name - assert data_node.name == original_name - assert data_node.layer == "raw" - assert data_node.tags == set() - assert data_node.pipelines == set() - assert data_node.stats["rows"] == 10 - assert data_node.stats["columns"] == 2 - assert data_node.stats["file_size"] == 1048 - - def test_create_parameters_all_parameters(self): - parameters_dataset = MemoryDataset( - data={"test_split_ratio": 0.3, "num_epochs": 1000} - ) - parameters_node = GraphNode.create_parameters_node( - dataset_id="parameters", - dataset_name="parameters", - layer=None, - tags=set(), - parameters=parameters_dataset, - modular_pipelines=set(), - ) - assert isinstance(parameters_node, ParametersNode) - assert parameters_node.kedro_obj is parameters_dataset - assert parameters_node.id == "parameters" - assert parameters_node.is_all_parameters() - assert not parameters_node.is_single_parameter() - assert parameters_node.parameter_value == { - "test_split_ratio": 0.3, - "num_epochs": 1000, - } - assert not parameters_node.modular_pipelines - - @pytest.mark.parametrize( - "dataset_name,expected_modular_pipelines", - [ - ("params:test_split_ratio", set()), - ( - "params:uk.data_science.model_training.test_split_ratio", - set(["uk", "uk.data_science", "uk.data_science.model_training"]), - ), - ], - ) - def test_create_parameters_node_single_parameter( - self, dataset_name, expected_modular_pipelines - ): - parameters_dataset = MemoryDataset(data=0.3) - parameters_node = GraphNode.create_parameters_node( - dataset_id=dataset_name, - dataset_name=dataset_name, - layer=None, - tags=set(), - parameters=parameters_dataset, - modular_pipelines=expected_modular_pipelines, - ) - assert isinstance(parameters_node, ParametersNode) - assert parameters_node.kedro_obj is parameters_dataset - assert not parameters_node.is_all_parameters() - assert parameters_node.is_single_parameter() - assert parameters_node.parameter_value == 0.3 - assert parameters_node.modular_pipelines == expected_modular_pipelines - - def test_create_single_parameter_with_complex_type(self): - parameters_dataset = MemoryDataset(data=object()) - parameters_node = GraphNode.create_parameters_node( - dataset_id="params:test_split_ratio", - dataset_name="params:test_split_ratio", - layer=None, - tags=set(), - parameters=parameters_dataset, - modular_pipelines=set(), - ) - assert isinstance(parameters_node, ParametersNode) - assert parameters_node.kedro_obj is parameters_dataset - assert not parameters_node.is_all_parameters() - assert parameters_node.is_single_parameter() - assert isinstance(parameters_node.parameter_value, str) - - def test_create_all_parameters_with_complex_type(self): - mock_object = object() - parameters_dataset = MemoryDataset( - data={ - "test_split_ratio": 0.3, - "num_epochs": 1000, - "complex_param": mock_object, - } - ) - parameters_node = GraphNode.create_parameters_node( - dataset_id="parameters", - dataset_name="parameters", - layer=None, - tags=set(), - parameters=parameters_dataset, - modular_pipelines=set(), - ) - assert isinstance(parameters_node, ParametersNode) - assert parameters_node.kedro_obj is parameters_dataset - assert parameters_node.id == "parameters" - assert parameters_node.is_all_parameters() - assert not parameters_node.is_single_parameter() - assert isinstance(parameters_node.parameter_value, str) - - def test_create_non_existing_parameter_node(self): - """Test the case where ``parameters`` is equal to None""" - parameters_node = GraphNode.create_parameters_node( - dataset_id="non_existing", - dataset_name="non_existing", - layer=None, - tags=set(), - parameters=None, - modular_pipelines=set(), - ) - assert isinstance(parameters_node, ParametersNode) - assert parameters_node.parameter_value is None - - @patch("logging.Logger.warning") - def test_create_non_existing_parameter_node_empty_dataset(self, patched_warning): - """Test the case where ``parameters`` is equal to a MemoryDataset with no data""" - parameters_dataset = MemoryDataset() - parameters_node = GraphNode.create_parameters_node( - dataset_id="non_existing", - dataset_name="non_existing", - layer=None, - tags=set(), - parameters=parameters_dataset, - modular_pipelines=set(), - ) - assert parameters_node.parameter_value is None - patched_warning.assert_has_calls( - [call("Cannot find parameter `%s` in the catalog.", "non_existing")] - ) - - -class TestGraphNodePipelines: - def test_registered_pipeline_name(self): - pipeline = RegisteredPipeline(id="__default__") - assert pipeline.name == "__default__" - - def test_modular_pipeline_name(self): - pipeline = GraphNode.create_modular_pipeline_node("data_engineering") - assert pipeline.name == "data_engineering" - - def test_add_node_to_pipeline(self): - default_pipeline = RegisteredPipeline(id="__default__") - another_pipeline = RegisteredPipeline(id="testing") - kedro_dataset = CSVDataset(filepath="foo.csv") - data_node = GraphNode.create_data_node( - dataset_id="dataset@transcoded", - dataset_name="dataset@transcoded", - layer="raw", - tags=set(), - dataset=kedro_dataset, - stats={"rows": 10, "columns": 2, "file_size": 1048}, - modular_pipelines=set(), - ) - assert data_node.pipelines == set() - data_node.add_pipeline(default_pipeline.id) - assert data_node.belongs_to_pipeline(default_pipeline.id) - assert not data_node.belongs_to_pipeline(another_pipeline.id) - - class TestGraphNodeMetadata: @pytest.mark.parametrize( "dataset,has_metadata", [(MemoryDataset(data=1), True), (None, False)] diff --git a/package/tests/test_models/test_flowchart/test_nodes.py b/package/tests/test_models/test_flowchart/test_nodes.py new file mode 100644 index 0000000000..2d7a59d338 --- /dev/null +++ b/package/tests/test_models/test_flowchart/test_nodes.py @@ -0,0 +1,248 @@ +from unittest.mock import call, patch + +import pytest +from kedro.io import MemoryDataset +from kedro.pipeline.node import node +from kedro_datasets.pandas import CSVDataset + +from kedro_viz.models.flowchart.nodes import ( + DataNode, + GraphNode, + ParametersNode, + TaskNode, + TranscodedDataNode, +) + + +def identity(x): + return x + + +class TestGraphNodeCreation: + @pytest.mark.parametrize( + "namespace,expected_modular_pipelines", + [ + (None, set()), + ( + "uk.data_science.model_training", + set( + [ + "uk", + "uk.data_science", + "uk.data_science.model_training", + ] + ), + ), + ], + ) + def test_create_task_node(self, namespace, expected_modular_pipelines): + kedro_node = node( + identity, + inputs="x", + outputs="y", + name="identity_node", + tags={"tag"}, + namespace=namespace, + ) + task_node = GraphNode.create_task_node( + kedro_node, "identity_node", expected_modular_pipelines + ) + assert isinstance(task_node, TaskNode) + assert task_node.kedro_obj is kedro_node + assert task_node.name == "identity_node" + assert task_node.tags == {"tag"} + assert task_node.pipelines == set() + assert task_node.modular_pipelines == expected_modular_pipelines + assert task_node.namespace == namespace + + @pytest.mark.parametrize( + "dataset_name, expected_modular_pipelines", + [ + ("dataset", set()), + ( + "uk.data_science.model_training.dataset", + set( + [ + "uk", + "uk.data_science", + "uk.data_science.model_training", + ] + ), + ), + ], + ) + def test_create_data_node(self, dataset_name, expected_modular_pipelines): + kedro_dataset = CSVDataset(filepath="foo.csv") + data_node = GraphNode.create_data_node( + dataset_id=dataset_name, + dataset_name=dataset_name, + layer="raw", + tags=set(), + dataset=kedro_dataset, + stats={"rows": 10, "columns": 5, "file_size": 1024}, + modular_pipelines=set(expected_modular_pipelines), + ) + assert isinstance(data_node, DataNode) + assert data_node.kedro_obj is kedro_dataset + assert data_node.id == dataset_name + assert data_node.name == dataset_name + assert data_node.layer == "raw" + assert data_node.tags == set() + assert data_node.pipelines == set() + assert data_node.modular_pipelines == expected_modular_pipelines + assert data_node.stats["rows"] == 10 + assert data_node.stats["columns"] == 5 + assert data_node.stats["file_size"] == 1024 + + @pytest.mark.parametrize( + "transcoded_dataset_name, original_name", + [ + ("dataset@pandas2", "dataset"), + ( + "uk.data_science.model_training.dataset@pandas2", + "uk.data_science.model_training.dataset", + ), + ], + ) + def test_create_transcoded_data_node(self, transcoded_dataset_name, original_name): + kedro_dataset = CSVDataset(filepath="foo.csv") + data_node = GraphNode.create_data_node( + dataset_id=original_name, + dataset_name=transcoded_dataset_name, + layer="raw", + tags=set(), + dataset=kedro_dataset, + stats={"rows": 10, "columns": 2, "file_size": 1048}, + modular_pipelines=set(), + ) + assert isinstance(data_node, TranscodedDataNode) + assert data_node.id == original_name + assert data_node.name == original_name + assert data_node.layer == "raw" + assert data_node.tags == set() + assert data_node.pipelines == set() + assert data_node.stats["rows"] == 10 + assert data_node.stats["columns"] == 2 + assert data_node.stats["file_size"] == 1048 + + def test_create_parameters_all_parameters(self): + parameters_dataset = MemoryDataset( + data={"test_split_ratio": 0.3, "num_epochs": 1000} + ) + parameters_node = GraphNode.create_parameters_node( + dataset_id="parameters", + dataset_name="parameters", + layer=None, + tags=set(), + parameters=parameters_dataset, + modular_pipelines=set(), + ) + assert isinstance(parameters_node, ParametersNode) + assert parameters_node.kedro_obj is parameters_dataset + assert parameters_node.id == "parameters" + assert parameters_node.is_all_parameters() + assert not parameters_node.is_single_parameter() + assert parameters_node.parameter_value == { + "test_split_ratio": 0.3, + "num_epochs": 1000, + } + assert not parameters_node.modular_pipelines + + @pytest.mark.parametrize( + "dataset_name,expected_modular_pipelines", + [ + ("params:test_split_ratio", set()), + ( + "params:uk.data_science.model_training.test_split_ratio", + set(["uk", "uk.data_science", "uk.data_science.model_training"]), + ), + ], + ) + def test_create_parameters_node_single_parameter( + self, dataset_name, expected_modular_pipelines + ): + parameters_dataset = MemoryDataset(data=0.3) + parameters_node = GraphNode.create_parameters_node( + dataset_id=dataset_name, + dataset_name=dataset_name, + layer=None, + tags=set(), + parameters=parameters_dataset, + modular_pipelines=expected_modular_pipelines, + ) + assert isinstance(parameters_node, ParametersNode) + assert parameters_node.kedro_obj is parameters_dataset + assert not parameters_node.is_all_parameters() + assert parameters_node.is_single_parameter() + assert parameters_node.parameter_value == 0.3 + assert parameters_node.modular_pipelines == expected_modular_pipelines + + def test_create_single_parameter_with_complex_type(self): + parameters_dataset = MemoryDataset(data=object()) + parameters_node = GraphNode.create_parameters_node( + dataset_id="params:test_split_ratio", + dataset_name="params:test_split_ratio", + layer=None, + tags=set(), + parameters=parameters_dataset, + modular_pipelines=set(), + ) + assert isinstance(parameters_node, ParametersNode) + assert parameters_node.kedro_obj is parameters_dataset + assert not parameters_node.is_all_parameters() + assert parameters_node.is_single_parameter() + assert isinstance(parameters_node.parameter_value, str) + + def test_create_all_parameters_with_complex_type(self): + mock_object = object() + parameters_dataset = MemoryDataset( + data={ + "test_split_ratio": 0.3, + "num_epochs": 1000, + "complex_param": mock_object, + } + ) + parameters_node = GraphNode.create_parameters_node( + dataset_id="parameters", + dataset_name="parameters", + layer=None, + tags=set(), + parameters=parameters_dataset, + modular_pipelines=set(), + ) + assert isinstance(parameters_node, ParametersNode) + assert parameters_node.kedro_obj is parameters_dataset + assert parameters_node.id == "parameters" + assert parameters_node.is_all_parameters() + assert not parameters_node.is_single_parameter() + assert isinstance(parameters_node.parameter_value, str) + + def test_create_non_existing_parameter_node(self): + """Test the case where ``parameters`` is equal to None""" + parameters_node = GraphNode.create_parameters_node( + dataset_id="non_existing", + dataset_name="non_existing", + layer=None, + tags=set(), + parameters=None, + modular_pipelines=set(), + ) + assert isinstance(parameters_node, ParametersNode) + assert parameters_node.parameter_value is None + + @patch("logging.Logger.warning") + def test_create_non_existing_parameter_node_empty_dataset(self, patched_warning): + """Test the case where ``parameters`` is equal to a MemoryDataset with no data""" + parameters_dataset = MemoryDataset() + parameters_node = GraphNode.create_parameters_node( + dataset_id="non_existing", + dataset_name="non_existing", + layer=None, + tags=set(), + parameters=parameters_dataset, + modular_pipelines=set(), + ) + assert parameters_node.parameter_value is None + patched_warning.assert_has_calls( + [call("Cannot find parameter `%s` in the catalog.", "non_existing")] + ) diff --git a/package/tests/test_models/test_flowchart/test_pipeline.py b/package/tests/test_models/test_flowchart/test_pipeline.py new file mode 100644 index 0000000000..520aff01d9 --- /dev/null +++ b/package/tests/test_models/test_flowchart/test_pipeline.py @@ -0,0 +1,32 @@ +from kedro_datasets.pandas import CSVDataset + +from kedro_viz.models.flowchart.named_entities import RegisteredPipeline +from kedro_viz.models.flowchart.nodes import GraphNode + + +class TestGraphNodePipelines: + def test_registered_pipeline_name(self): + pipeline = RegisteredPipeline(id="__default__") + assert pipeline.name == "__default__" + + def test_modular_pipeline_name(self): + pipeline = GraphNode.create_modular_pipeline_node("data_engineering") + assert pipeline.name == "data_engineering" + + def test_add_node_to_pipeline(self): + default_pipeline = RegisteredPipeline(id="__default__") + another_pipeline = RegisteredPipeline(id="testing") + kedro_dataset = CSVDataset(filepath="foo.csv") + data_node = GraphNode.create_data_node( + dataset_id="dataset@transcoded", + dataset_name="dataset@transcoded", + layer="raw", + tags=set(), + dataset=kedro_dataset, + stats={"rows": 10, "columns": 2, "file_size": 1048}, + modular_pipelines=set(), + ) + assert data_node.pipelines == set() + data_node.add_pipeline(default_pipeline.id) + assert data_node.belongs_to_pipeline(default_pipeline.id) + assert not data_node.belongs_to_pipeline(another_pipeline.id) diff --git a/package/tests/test_services/test_layers.py b/package/tests/test_services/test_layers.py index 80d76fae5a..c949a9f98b 100644 --- a/package/tests/test_services/test_layers.py +++ b/package/tests/test_services/test_layers.py @@ -1,6 +1,6 @@ import pytest -from kedro_viz.models.flowchart import GraphNode +from kedro_viz.models.flowchart.nodes import GraphNode from kedro_viz.services.layers import sort_layers diff --git a/ruff.toml b/ruff.toml index 52a1d6c8f3..166d54a4a7 100644 --- a/ruff.toml +++ b/ruff.toml @@ -45,7 +45,8 @@ ignore = [ "package/features/steps/sh_run.py" = ["PLW1510"] # `subprocess.run` without explicit `check` argument "*/tests/*.py" = ["SLF", "D", "ARG"] "package/kedro_viz/models/experiment_tracking.py" = ["SLF"] -"package/kedro_viz/models/flowchart.py" = ["SLF"] +"package/kedro_viz/models/flowchart/nodes.py" = ["SLF"] +"package/kedro_viz/models/flowchart/node_metadata.py" = ["SLF"] "package/kedro_viz/integrations/kedro/hooks.py" = ["SLF", "BLE"] "package/kedro_viz/integrations/kedro/sqlite_store.py" = ["BLE"] "package/kedro_viz/integrations/kedro/data_loader.py" = ["SLF"] From 31b0492d7161914252fcbb894e0eea82ec5037c6 Mon Sep 17 00:00:00 2001 From: Ravi Kumar Pilla Date: Wed, 30 Oct 2024 14:33:31 -0500 Subject: [PATCH 16/35] Refactor response classes (#2113) * sync remote * refactor response classes * adjust permissions * adjuste permissions * move from multiprocessing to threading * fix file perm * revert threading * revert except block * adjust lint and tests * fix lint * fix perm * update comments * changes based on PR comments * changes based on PR comments * update file comments * remove pylint * adjust attributes * test assert helper * test assert helper * test assert helper * test assert helper --- package/kedro_viz/api/apps.py | 2 +- package/kedro_viz/api/rest/responses.py | 492 --------------- .../kedro_viz/api/rest/responses/__init__.py | 0 package/kedro_viz/api/rest/responses/base.py | 28 + .../kedro_viz/api/rest/responses/metadata.py | 47 ++ package/kedro_viz/api/rest/responses/nodes.py | 162 +++++ .../kedro_viz/api/rest/responses/pipelines.py | 256 ++++++++ .../api/rest/responses/save_responses.py | 97 +++ package/kedro_viz/api/rest/responses/utils.py | 44 ++ package/kedro_viz/api/rest/router.py | 35 +- package/kedro_viz/data_access/managers.py | 3 +- .../integrations/deployment/base_deployer.py | 2 +- package/kedro_viz/launchers/cli/deploy.py | 6 +- package/kedro_viz/launchers/cli/run.py | 2 +- package/kedro_viz/launchers/cli/utils.py | 13 +- package/kedro_viz/launchers/utils.py | 11 + package/kedro_viz/server.py | 14 +- package/tests/conftest.py | 28 +- .../test_rest/test_responses/__init__.py | 0 .../assert_helpers.py} | 570 +----------------- .../test_rest/test_responses/test_base.py | 10 + .../test_rest/test_responses/test_metadata.py | 24 + .../test_rest/test_responses/test_nodes.py | 91 +++ .../test_responses/test_pipelines.py | 241 ++++++++ .../test_responses/test_save_responses.py | 168 ++++++ .../test_rest/test_responses/test_utils.py | 43 ++ .../tests/test_api/test_rest/test_router.py | 2 +- package/tests/test_server.py | 2 +- 28 files changed, 1310 insertions(+), 1083 deletions(-) delete mode 100644 package/kedro_viz/api/rest/responses.py create mode 100644 package/kedro_viz/api/rest/responses/__init__.py create mode 100755 package/kedro_viz/api/rest/responses/base.py create mode 100755 package/kedro_viz/api/rest/responses/metadata.py create mode 100644 package/kedro_viz/api/rest/responses/nodes.py create mode 100644 package/kedro_viz/api/rest/responses/pipelines.py create mode 100644 package/kedro_viz/api/rest/responses/save_responses.py create mode 100644 package/kedro_viz/api/rest/responses/utils.py create mode 100755 package/tests/test_api/test_rest/test_responses/__init__.py rename package/tests/test_api/test_rest/{test_responses.py => test_responses/assert_helpers.py} (50%) create mode 100755 package/tests/test_api/test_rest/test_responses/test_base.py create mode 100755 package/tests/test_api/test_rest/test_responses/test_metadata.py create mode 100644 package/tests/test_api/test_rest/test_responses/test_nodes.py create mode 100755 package/tests/test_api/test_rest/test_responses/test_pipelines.py create mode 100644 package/tests/test_api/test_rest/test_responses/test_save_responses.py create mode 100644 package/tests/test_api/test_rest/test_responses/test_utils.py diff --git a/package/kedro_viz/api/apps.py b/package/kedro_viz/api/apps.py index d5b5c535ca..e188ab1911 100644 --- a/package/kedro_viz/api/apps.py +++ b/package/kedro_viz/api/apps.py @@ -15,7 +15,7 @@ from jinja2 import Environment, FileSystemLoader from kedro_viz import __version__ -from kedro_viz.api.rest.responses import EnhancedORJSONResponse +from kedro_viz.api.rest.responses.utils import EnhancedORJSONResponse from kedro_viz.integrations.kedro import telemetry as kedro_telemetry from .graphql.router import router as graphql_router diff --git a/package/kedro_viz/api/rest/responses.py b/package/kedro_viz/api/rest/responses.py deleted file mode 100644 index 1e885eced1..0000000000 --- a/package/kedro_viz/api/rest/responses.py +++ /dev/null @@ -1,492 +0,0 @@ -"""`kedro_viz.api.rest.responses` defines REST response types.""" - -import abc -import json -import logging -from typing import Any, Dict, List, Optional, Union - -import orjson -from fastapi.encoders import jsonable_encoder -from fastapi.responses import JSONResponse, ORJSONResponse -from pydantic import BaseModel, ConfigDict - -from kedro_viz.api.rest.utils import get_package_compatibilities -from kedro_viz.data_access import data_access_manager -from kedro_viz.models.flowchart.node_metadata import ( - DataNodeMetadata, - ParametersNodeMetadata, - TaskNodeMetadata, - TranscodedDataNodeMetadata, -) -from kedro_viz.models.flowchart.nodes import DataNode, TaskNode, TranscodedDataNode -from kedro_viz.models.metadata import Metadata, PackageCompatibility - -logger = logging.getLogger(__name__) - - -class APIErrorMessage(BaseModel): - message: str - - -class BaseAPIResponse(BaseModel, abc.ABC): - model_config = ConfigDict(from_attributes=True) - - -class BaseGraphNodeAPIResponse(BaseAPIResponse): - id: str - name: str - tags: List[str] - pipelines: List[str] - type: str - - # If a node is a ModularPipeline node, this value will be None, hence Optional. - modular_pipelines: Optional[List[str]] = None - - -class TaskNodeAPIResponse(BaseGraphNodeAPIResponse): - parameters: Dict - model_config = ConfigDict( - json_schema_extra={ - "example": { - "id": "6ab908b8", - "name": "split_data_node", - "tags": [], - "pipelines": ["__default__", "ds"], - "modular_pipelines": [], - "type": "task", - "parameters": { - "test_size": 0.2, - "random_state": 3, - "features": [ - "engines", - "passenger_capacity", - "crew", - "d_check_complete", - "moon_clearance_complete", - "iata_approved", - "company_rating", - "review_scores_rating", - ], - }, - } - } - ) - - -class DataNodeAPIResponse(BaseGraphNodeAPIResponse): - layer: Optional[str] = None - dataset_type: Optional[str] = None - stats: Optional[Dict] = None - model_config = ConfigDict( - json_schema_extra={ - "example": { - "id": "d7b83b05", - "name": "master_table", - "tags": [], - "pipelines": ["__default__", "dp", "ds"], - "modular_pipelines": [], - "type": "data", - "layer": "primary", - "dataset_type": "kedro_datasets.pandas.csv_dataset.CSVDataset", - "stats": {"rows": 10, "columns": 2, "file_size": 2300}, - } - } - ) - - -NodeAPIResponse = Union[ - TaskNodeAPIResponse, - DataNodeAPIResponse, -] - - -class TaskNodeMetadataAPIResponse(BaseAPIResponse): - code: Optional[str] = None - filepath: Optional[str] = None - parameters: Optional[Dict] = None - inputs: List[str] - outputs: List[str] - run_command: Optional[str] = None - model_config = ConfigDict( - json_schema_extra={ - "example": { - "code": "def split_data(data: pd.DataFrame, parameters: Dict) -> Tuple:", - "filepath": "proj/src/new_kedro_project/pipelines/data_science/nodes.py", - "parameters": {"test_size": 0.2}, - "inputs": ["params:input1", "input2"], - "outputs": ["output1"], - "run_command": "kedro run --to-nodes=split_data", - } - } - ) - - -class DataNodeMetadataAPIResponse(BaseAPIResponse): - filepath: Optional[str] = None - type: str - run_command: Optional[str] = None - preview: Optional[Union[Dict, str]] = None - preview_type: Optional[str] = None - stats: Optional[Dict] = None - model_config = ConfigDict( - json_schema_extra={ - "example": { - "filepath": "/my-kedro-project/data/03_primary/master_table.csv", - "type": "kedro_datasets.pandas.csv_dataset.CSVDataset", - "run_command": "kedro run --to-outputs=master_table", - } - } - ) - - -class TranscodedDataNodeMetadataAPIReponse(BaseAPIResponse): - filepath: Optional[str] = None - original_type: str - transcoded_types: List[str] - run_command: Optional[str] = None - stats: Optional[Dict] = None - - -class ParametersNodeMetadataAPIResponse(BaseAPIResponse): - parameters: Dict - model_config = ConfigDict( - json_schema_extra={ - "example": { - "parameters": { - "test_size": 0.2, - "random_state": 3, - "features": [ - "engines", - "passenger_capacity", - "crew", - "d_check_complete", - "moon_clearance_complete", - "iata_approved", - "company_rating", - "review_scores_rating", - ], - } - } - } - ) - - -NodeMetadataAPIResponse = Union[ - TaskNodeMetadataAPIResponse, - DataNodeMetadataAPIResponse, - TranscodedDataNodeMetadataAPIReponse, - ParametersNodeMetadataAPIResponse, -] - - -class GraphEdgeAPIResponse(BaseAPIResponse): - source: str - target: str - - -class NamedEntityAPIResponse(BaseAPIResponse): - """Model an API field that has an ID and a name. - For example, used for representing modular pipelines and pipelines in the API response. - """ - - id: str - name: Optional[str] = None - - -class ModularPipelineChildAPIResponse(BaseAPIResponse): - """Model a child in a modular pipeline's children field in the API response.""" - - id: str - type: str - - -class ModularPipelinesTreeNodeAPIResponse(BaseAPIResponse): - """Model a node in the tree representation of modular pipelines in the API response.""" - - id: str - name: str - inputs: List[str] - outputs: List[str] - children: List[ModularPipelineChildAPIResponse] - - -# Represent the modular pipelines in the API response as a tree. -# The root node is always designated with the __root__ key. -# Example: -# { -# "__root__": { -# "id": "__root__", -# "name": "Root", -# "inputs": [], -# "outputs": [], -# "children": [ -# {"id": "d577578a", "type": "parameters"}, -# {"id": "data_science", "type": "modularPipeline"}, -# {"id": "f1f1425b", "type": "parameters"}, -# {"id": "data_engineering", "type": "modularPipeline"}, -# ], -# }, -# "data_engineering": { -# "id": "data_engineering", -# "name": "Data Engineering", -# "inputs": ["d577578a"], -# "outputs": [], -# "children": [], -# }, -# "data_science": { -# "id": "data_science", -# "name": "Data Science", -# "inputs": ["f1f1425b"], -# "outputs": [], -# "children": [], -# }, -# } -# } -ModularPipelinesTreeAPIResponse = Dict[str, ModularPipelinesTreeNodeAPIResponse] - - -class GraphAPIResponse(BaseAPIResponse): - nodes: List[NodeAPIResponse] - edges: List[GraphEdgeAPIResponse] - layers: List[str] - tags: List[NamedEntityAPIResponse] - pipelines: List[NamedEntityAPIResponse] - modular_pipelines: ModularPipelinesTreeAPIResponse - selected_pipeline: str - - -class MetadataAPIResponse(BaseAPIResponse): - has_missing_dependencies: bool = False - package_compatibilities: List[PackageCompatibility] = [] - model_config = ConfigDict( - json_schema_extra={ - "has_missing_dependencies": False, - "package_compatibilities": [ - { - "package_name": "fsspec", - "package_version": "2024.6.1", - "is_compatible": True, - }, - { - "package_name": "kedro-datasets", - "package_version": "4.0.0", - "is_compatible": True, - }, - ], - } - ) - - -class EnhancedORJSONResponse(ORJSONResponse): - @staticmethod - def encode_to_human_readable(content: Any) -> bytes: - """A method to encode the given content to JSON, with the - proper formatting to write a human-readable file. - - Returns: - A bytes object containing the JSON to write. - - """ - return orjson.dumps( - content, - option=orjson.OPT_INDENT_2 - | orjson.OPT_NON_STR_KEYS - | orjson.OPT_SERIALIZE_NUMPY, - ) - - -def get_default_response() -> GraphAPIResponse: - """Default response for `/api/main`.""" - default_selected_pipeline_id = ( - data_access_manager.get_default_selected_pipeline().id - ) - - modular_pipelines_tree = ( - data_access_manager.create_modular_pipelines_tree_for_registered_pipeline( - default_selected_pipeline_id - ) - ) - - return GraphAPIResponse( - nodes=data_access_manager.get_nodes_for_registered_pipeline( - default_selected_pipeline_id - ), - edges=data_access_manager.get_edges_for_registered_pipeline( - default_selected_pipeline_id - ), - tags=data_access_manager.tags.as_list(), - layers=data_access_manager.get_sorted_layers_for_registered_pipeline( - default_selected_pipeline_id - ), - pipelines=data_access_manager.registered_pipelines.as_list(), - modular_pipelines=modular_pipelines_tree, - selected_pipeline=default_selected_pipeline_id, - ) - - -def get_node_metadata_response(node_id: str): - """API response for `/api/nodes/node_id`.""" - node = data_access_manager.nodes.get_node_by_id(node_id) - if not node: - return JSONResponse(status_code=404, content={"message": "Invalid node ID"}) - - if not node.has_metadata(): - return JSONResponse(content={}) - - if isinstance(node, TaskNode): - return TaskNodeMetadata(task_node=node) - - if isinstance(node, DataNode): - return DataNodeMetadata(data_node=node) - - if isinstance(node, TranscodedDataNode): - return TranscodedDataNodeMetadata(transcoded_data_node=node) - - return ParametersNodeMetadata(parameters_node=node) - - -def get_selected_pipeline_response(registered_pipeline_id: str): - """API response for `/api/pipeline/pipeline_id`.""" - if not data_access_manager.registered_pipelines.has_pipeline( - registered_pipeline_id - ): - return JSONResponse(status_code=404, content={"message": "Invalid pipeline ID"}) - - modular_pipelines_tree = ( - data_access_manager.create_modular_pipelines_tree_for_registered_pipeline( - registered_pipeline_id - ) - ) - - return GraphAPIResponse( - nodes=data_access_manager.get_nodes_for_registered_pipeline( - registered_pipeline_id - ), - edges=data_access_manager.get_edges_for_registered_pipeline( - registered_pipeline_id - ), - tags=data_access_manager.tags.as_list(), - layers=data_access_manager.get_sorted_layers_for_registered_pipeline( - registered_pipeline_id - ), - pipelines=data_access_manager.registered_pipelines.as_list(), - selected_pipeline=registered_pipeline_id, - modular_pipelines=modular_pipelines_tree, - ) - - -def get_metadata_response(): - """API response for `/api/metadata`.""" - package_compatibilities = get_package_compatibilities() - Metadata.set_package_compatibilities(package_compatibilities) - return Metadata() - - -def get_encoded_response(response: Any) -> bytes: - """Encodes and enhances the default response using human-readable format.""" - jsonable_response = jsonable_encoder(response) - encoded_response = EnhancedORJSONResponse.encode_to_human_readable( - jsonable_response - ) - - return encoded_response - - -def write_api_response_to_fs(file_path: str, response: Any, remote_fs: Any): - """Get encoded responses and writes it to a file""" - encoded_response = get_encoded_response(response) - - with remote_fs.open(file_path, "wb") as file: - file.write(encoded_response) - - -def get_kedro_project_json_data(): - """Decodes the default response and returns the Kedro project JSON data. - This will be used in VSCode extension to get current Kedro project data.""" - encoded_response = get_encoded_response(get_default_response()) - - try: - response_str = encoded_response.decode("utf-8") - json_data = json.loads(response_str) - except UnicodeDecodeError as exc: # pragma: no cover - json_data = None - logger.error("Failed to decode response string. Error: %s", str(exc)) - except json.JSONDecodeError as exc: # pragma: no cover - json_data = None - logger.error("Failed to parse JSON data. Error: %s", str(exc)) - - return json_data - - -def save_api_main_response_to_fs(main_path: str, remote_fs: Any): - """Saves API /main response to a directory.""" - try: - write_api_response_to_fs(main_path, get_default_response(), remote_fs) - except Exception as exc: # pragma: no cover - logger.exception("Failed to save default response. Error: %s", str(exc)) - raise exc - - -def save_api_node_response_to_fs( - nodes_path: str, remote_fs: Any, is_all_previews_enabled: bool -): - """Saves API /nodes/{node} response to a directory.""" - # Set if preview is enabled/disabled for all data nodes - DataNodeMetadata.set_is_all_previews_enabled(is_all_previews_enabled) - - for nodeId in data_access_manager.nodes.get_node_ids(): - try: - write_api_response_to_fs( - f"{nodes_path}/{nodeId}", get_node_metadata_response(nodeId), remote_fs - ) - except Exception as exc: # pragma: no cover - logger.exception( - "Failed to save node data for node ID %s. Error: %s", nodeId, str(exc) - ) - raise exc - - -def save_api_pipeline_response_to_fs(pipelines_path: str, remote_fs: Any): - """Saves API /pipelines/{pipeline} response to a directory.""" - for pipelineId in data_access_manager.registered_pipelines.get_pipeline_ids(): - try: - write_api_response_to_fs( - f"{pipelines_path}/{pipelineId}", - get_selected_pipeline_response(pipelineId), - remote_fs, - ) - except Exception as exc: # pragma: no cover - logger.exception( - "Failed to save pipeline data for pipeline ID %s. Error: %s", - pipelineId, - str(exc), - ) - raise exc - - -def save_api_responses_to_fs(path: str, remote_fs: Any, is_all_previews_enabled: bool): - """Saves all Kedro Viz API responses to a directory.""" - try: - logger.debug( - """Saving/Uploading api files to %s""", - path, - ) - - main_path = f"{path}/api/main" - nodes_path = f"{path}/api/nodes" - pipelines_path = f"{path}/api/pipelines" - - if "file" in remote_fs.protocol: - remote_fs.makedirs(path, exist_ok=True) - remote_fs.makedirs(nodes_path, exist_ok=True) - remote_fs.makedirs(pipelines_path, exist_ok=True) - - save_api_main_response_to_fs(main_path, remote_fs) - save_api_node_response_to_fs(nodes_path, remote_fs, is_all_previews_enabled) - save_api_pipeline_response_to_fs(pipelines_path, remote_fs) - - except Exception as exc: # pragma: no cover - logger.exception( - "An error occurred while preparing data for saving. Error: %s", str(exc) - ) - raise exc diff --git a/package/kedro_viz/api/rest/responses/__init__.py b/package/kedro_viz/api/rest/responses/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/package/kedro_viz/api/rest/responses/base.py b/package/kedro_viz/api/rest/responses/base.py new file mode 100755 index 0000000000..99fe66e85c --- /dev/null +++ b/package/kedro_viz/api/rest/responses/base.py @@ -0,0 +1,28 @@ +"""`kedro_viz.api.rest.responses.base` contains base +response classes and utility functions for the REST endpoints""" + +import abc +import logging + +from pydantic import BaseModel, ConfigDict + +logger = logging.getLogger(__name__) + + +class APINotFoundResponse(BaseModel): + """ + APINotFoundResponse is a Pydantic model representing a response for an API not found error. + + Attributes: + message (str): A message describing the error. + """ + + message: str + + +class BaseAPIResponse(BaseModel, abc.ABC): + """ + BaseAPIResponse is an abstract base class for API responses. + """ + + model_config = ConfigDict(from_attributes=True) diff --git a/package/kedro_viz/api/rest/responses/metadata.py b/package/kedro_viz/api/rest/responses/metadata.py new file mode 100755 index 0000000000..0222d261a1 --- /dev/null +++ b/package/kedro_viz/api/rest/responses/metadata.py @@ -0,0 +1,47 @@ +"""`kedro_viz.api.rest.responses.metadata` contains response classes +and utility functions for the `/metadata` REST endpoint""" + +from typing import List + +from pydantic import ConfigDict + +from kedro_viz.api.rest.responses.base import BaseAPIResponse +from kedro_viz.api.rest.utils import get_package_compatibilities +from kedro_viz.models.metadata import Metadata, PackageCompatibility + + +class MetadataAPIResponse(BaseAPIResponse): + """ + MetadataAPIResponse is a subclass of BaseAPIResponse that represents the response structure for metadata API. + + Attributes: + has_missing_dependencies (bool): Indicates if there are any missing dependencies. Defaults to False. + package_compatibilities (List[PackageCompatibility]): A list of package compatibility information. Defaults to an empty list. + """ + + has_missing_dependencies: bool = False + package_compatibilities: List[PackageCompatibility] = [] + model_config = ConfigDict( + json_schema_extra={ + "has_missing_dependencies": False, + "package_compatibilities": [ + { + "package_name": "fsspec", + "package_version": "2024.6.1", + "is_compatible": True, + }, + { + "package_name": "kedro-datasets", + "package_version": "4.0.0", + "is_compatible": True, + }, + ], + } + ) + + +def get_metadata_response(): + """API response for `/api/metadata`.""" + package_compatibilities = get_package_compatibilities() + Metadata.set_package_compatibilities(package_compatibilities) + return Metadata() diff --git a/package/kedro_viz/api/rest/responses/nodes.py b/package/kedro_viz/api/rest/responses/nodes.py new file mode 100644 index 0000000000..f6df0c53ce --- /dev/null +++ b/package/kedro_viz/api/rest/responses/nodes.py @@ -0,0 +1,162 @@ +"""`kedro_viz.api.rest.responses.nodes` contains response classes +and utility functions for the `/nodes/*` REST endpoints""" + +import logging +from typing import Dict, List, Optional, Union + +from fastapi.responses import JSONResponse +from pydantic import ConfigDict + +from kedro_viz.api.rest.responses.base import BaseAPIResponse +from kedro_viz.data_access import data_access_manager +from kedro_viz.models.flowchart.node_metadata import ( + DataNodeMetadata, + ParametersNodeMetadata, + TaskNodeMetadata, + TranscodedDataNodeMetadata, +) +from kedro_viz.models.flowchart.nodes import DataNode, TaskNode, TranscodedDataNode + +logger = logging.getLogger(__name__) + + +class TaskNodeMetadataAPIResponse(BaseAPIResponse): + """ + TaskNodeMetadataAPIResponse is a data model for representing the metadata of a task node in the Kedro visualization API. + + Attributes: + code (Optional[str]): The code snippet of the task node. + filepath (Optional[str]): The file path where the task node is defined. + parameters (Optional[Dict]): The parameters used by the task node. + inputs (List[str]): The list of input data for the task node. + outputs (List[str]): The list of output data from the task node. + run_command (Optional[str]): The command to run the task node. + """ + + code: Optional[str] = None + filepath: Optional[str] = None + parameters: Optional[Dict] = None + inputs: List[str] + outputs: List[str] + run_command: Optional[str] = None + model_config = ConfigDict( + json_schema_extra={ + "example": { + "code": "def split_data(data: pd.DataFrame, parameters: Dict) -> Tuple:", + "filepath": "proj/src/new_kedro_project/pipelines/data_science/nodes.py", + "parameters": {"test_size": 0.2}, + "inputs": ["params:input1", "input2"], + "outputs": ["output1"], + "run_command": "kedro run --to-nodes=split_data", + } + } + ) + + +class DataNodeMetadataAPIResponse(BaseAPIResponse): + """ + DataNodeMetadataAPIResponse is a class that represents the metadata response for a data node in the Kedro visualization API. + + Attributes: + filepath (Optional[str]): The file path of the data node. + type (str): The type of the data node. + run_command (Optional[str]): The command to run the data node. + preview (Optional[Union[Dict, str]]): A preview of the data node's content. + preview_type (Optional[str]): The type of the preview. + stats (Optional[Dict]): Statistics related to the data node. + """ + + filepath: Optional[str] = None + type: str + run_command: Optional[str] = None + preview: Optional[Union[Dict, str]] = None + preview_type: Optional[str] = None + stats: Optional[Dict] = None + model_config = ConfigDict( + json_schema_extra={ + "example": { + "filepath": "/my-kedro-project/data/03_primary/master_table.csv", + "type": "kedro_datasets.pandas.csv_dataset.CSVDataset", + "run_command": "kedro run --to-outputs=master_table", + } + } + ) + + +class TranscodedDataNodeMetadataAPIReponse(BaseAPIResponse): + """ + TranscodedDataNodeMetadataAPIReponse represents the metadata response for a transcoded data node. + + Attributes: + filepath (Optional[str]): The file path of the transcoded data node. + original_type (str): The original type of the data node. + transcoded_types (List[str]): A list of types to which the data node has been transcoded. + run_command (Optional[str]): The command used to run the transcoding process. + stats (Optional[Dict]): Statistics related to the transcoded data node. + """ + + filepath: Optional[str] = None + original_type: str + transcoded_types: List[str] + run_command: Optional[str] = None + stats: Optional[Dict] = None + + +class ParametersNodeMetadataAPIResponse(BaseAPIResponse): + """ + ParametersNodeMetadataAPIResponse is a subclass of BaseAPIResponse that represents the metadata response for parameters nodes. + + Attributes: + parameters (Dict): A dictionary containing the parameters. + """ + + parameters: Dict + model_config = ConfigDict( + json_schema_extra={ + "example": { + "parameters": { + "test_size": 0.2, + "random_state": 3, + "features": [ + "engines", + "passenger_capacity", + "crew", + "d_check_complete", + "moon_clearance_complete", + "iata_approved", + "company_rating", + "review_scores_rating", + ], + } + } + } + ) + + +NodeMetadataAPIResponse = Union[ + TaskNodeMetadataAPIResponse, + DataNodeMetadataAPIResponse, + TranscodedDataNodeMetadataAPIReponse, + ParametersNodeMetadataAPIResponse, +] + + +def get_node_metadata_response(node_id: str): + """API response for `/api/nodes/node_id`.""" + node = data_access_manager.nodes.get_node_by_id(node_id) + if not node: + return JSONResponse(status_code=404, content={"message": "Invalid node ID"}) + + if not node.has_metadata(): + return JSONResponse(content={}) + + if isinstance(node, TaskNode): + return TaskNodeMetadata(task_node=node) + + if isinstance(node, DataNode): + return DataNodeMetadata(data_node=node) + + if isinstance(node, TranscodedDataNode): + return TranscodedDataNodeMetadata(transcoded_data_node=node) + + return ParametersNodeMetadata(parameters_node=node) diff --git a/package/kedro_viz/api/rest/responses/pipelines.py b/package/kedro_viz/api/rest/responses/pipelines.py new file mode 100644 index 0000000000..c5c096b8e5 --- /dev/null +++ b/package/kedro_viz/api/rest/responses/pipelines.py @@ -0,0 +1,256 @@ +"""`kedro_viz.api.rest.responses.pipelines` contains response classes +and utility functions for the `/main` and `/pipelines/* REST endpoints""" + +import json +import logging +from typing import Dict, List, Optional, Union + +from fastapi.responses import JSONResponse +from pydantic import ConfigDict + +from kedro_viz.api.rest.responses.base import BaseAPIResponse +from kedro_viz.api.rest.responses.utils import get_encoded_response +from kedro_viz.data_access import data_access_manager + +logger = logging.getLogger(__name__) + + +class BaseGraphNodeAPIResponse(BaseAPIResponse): + """ + BaseGraphNodeAPIResponse is a data model for representing the response of a graph node in the API. + + Attributes: + id (str): The unique identifier of the graph node. + name (str): The name of the graph node. + tags (List[str]): A list of tags associated with the graph node. + pipelines (List[str]): A list of pipelines that the graph node belongs to. + type (str): The type of the graph node. + modular_pipelines (Optional[List[str]]): A list of modular pipelines associated with the graph node. + This value will be None if the node is a ModularPipeline node. + """ + + id: str + name: str + tags: List[str] + pipelines: List[str] + type: str + + # If a node is a ModularPipeline node, this value will be None, hence Optional. + modular_pipelines: Optional[List[str]] = None + + +class TaskNodeAPIResponse(BaseGraphNodeAPIResponse): + """ + TaskNodeAPIResponse is a subclass of BaseGraphNodeAPIResponse that represents the response for a task node in the API. + + Attributes: + parameters (Dict): A dictionary containing the parameters for the task node. + """ + + parameters: Dict + model_config = ConfigDict( + json_schema_extra={ + "example": { + "id": "6ab908b8", + "name": "split_data_node", + "tags": [], + "pipelines": ["__default__", "ds"], + "modular_pipelines": [], + "type": "task", + "parameters": { + "test_size": 0.2, + "random_state": 3, + "features": [ + "engines", + "passenger_capacity", + "crew", + "d_check_complete", + "moon_clearance_complete", + "iata_approved", + "company_rating", + "review_scores_rating", + ], + }, + } + } + ) + + +class DataNodeAPIResponse(BaseGraphNodeAPIResponse): + """ + DataNodeAPIResponse is a subclass of BaseGraphNodeAPIResponse that represents the response model for a data node in the API. + + Attributes: + layer (Optional[str]): The layer to which the data node belongs. Default is None. + dataset_type (Optional[str]): The type of dataset. Default is None. + stats (Optional[Dict]): Statistics related to the dataset, such as number of rows, columns, and file size. Default is None. + """ + + layer: Optional[str] = None + dataset_type: Optional[str] = None + stats: Optional[Dict] = None + model_config = ConfigDict( + json_schema_extra={ + "example": { + "id": "d7b83b05", + "name": "master_table", + "tags": [], + "pipelines": ["__default__", "dp", "ds"], + "modular_pipelines": [], + "type": "data", + "layer": "primary", + "dataset_type": "kedro_datasets.pandas.csv_dataset.CSVDataset", + "stats": {"rows": 10, "columns": 2, "file_size": 2300}, + } + } + ) + + +NodeAPIResponse = Union[ + TaskNodeAPIResponse, + DataNodeAPIResponse, +] + + +class GraphEdgeAPIResponse(BaseAPIResponse): + """ + GraphEdgeAPIResponse represents the response model for an edge in the graph. + + Attributes: + source (str): The source node id for the edge. + target (str): The target node id for the edge. + """ + + source: str + target: str + + +class NamedEntityAPIResponse(BaseAPIResponse): + """Model an API field that has an ID and a name. + For example, used for representing modular pipelines and pipelines in the API response. + """ + + id: str + name: Optional[str] = None + + +class ModularPipelineChildAPIResponse(BaseAPIResponse): + """Model a child in a modular pipeline's children field in the API response.""" + + id: str + type: str + + +class ModularPipelinesTreeNodeAPIResponse(BaseAPIResponse): + """Model a node in the tree representation of modular pipelines in the API response.""" + + id: str + name: str + inputs: List[str] + outputs: List[str] + children: List[ModularPipelineChildAPIResponse] + + +# Represent the modular pipelines in the API response as a tree. +# The root node is always designated with the __root__ key. +# Example: +# { +# "__root__": { +# "id": "__root__", +# "name": "Root", +# "inputs": [], +# "outputs": [], +# "children": [ +# {"id": "d577578a", "type": "parameters"}, +# {"id": "data_science", "type": "modularPipeline"}, +# {"id": "f1f1425b", "type": "parameters"}, +# {"id": "data_engineering", "type": "modularPipeline"}, +# ], +# }, +# "data_engineering": { +# "id": "data_engineering", +# "name": "Data Engineering", +# "inputs": ["d577578a"], +# "outputs": [], +# "children": [], +# }, +# "data_science": { +# "id": "data_science", +# "name": "Data Science", +# "inputs": ["f1f1425b"], +# "outputs": [], +# "children": [], +# }, +# } +# } +ModularPipelinesTreeAPIResponse = Dict[str, ModularPipelinesTreeNodeAPIResponse] + + +class GraphAPIResponse(BaseAPIResponse): + """ + GraphAPIResponse is a data model for the response of the graph API. + + Attributes: + nodes (List[NodeAPIResponse]): A list of nodes in the graph. + edges (List[GraphEdgeAPIResponse]): A list of edges connecting the nodes in the graph. + layers (List[str]): A list of layers in the graph. + tags (List[NamedEntityAPIResponse]): A list of tags associated with the graph entities. + pipelines (List[NamedEntityAPIResponse]): A list of pipelines in the graph. + modular_pipelines (ModularPipelinesTreeAPIResponse): A tree structure representing modular pipelines. + selected_pipeline (str): The identifier of the selected pipeline. + """ + + nodes: List[NodeAPIResponse] + edges: List[GraphEdgeAPIResponse] + layers: List[str] + tags: List[NamedEntityAPIResponse] + pipelines: List[NamedEntityAPIResponse] + modular_pipelines: ModularPipelinesTreeAPIResponse + selected_pipeline: str + + +def get_pipeline_response( + pipeline_id: Union[str, None] = None, +) -> Union[GraphAPIResponse, JSONResponse]: + """API response for `/api/pipelines/pipeline_id`.""" + if pipeline_id is None: + pipeline_id = data_access_manager.get_default_selected_pipeline().id + + if not data_access_manager.registered_pipelines.has_pipeline(pipeline_id): + return JSONResponse(status_code=404, content={"message": "Invalid pipeline ID"}) + + modular_pipelines_tree = ( + data_access_manager.create_modular_pipelines_tree_for_registered_pipeline( + pipeline_id + ) + ) + + return GraphAPIResponse( + nodes=data_access_manager.get_nodes_for_registered_pipeline(pipeline_id), + edges=data_access_manager.get_edges_for_registered_pipeline(pipeline_id), + tags=data_access_manager.tags.as_list(), + layers=data_access_manager.get_sorted_layers_for_registered_pipeline( + pipeline_id + ), + pipelines=data_access_manager.registered_pipelines.as_list(), + modular_pipelines=modular_pipelines_tree, + selected_pipeline=pipeline_id, + ) + + +def get_kedro_project_json_data(): + """Decodes the default response and returns the Kedro project JSON data. + This will be used in VSCode extension to get current Kedro project data.""" + encoded_response = get_encoded_response(get_pipeline_response()) + + try: + response_str = encoded_response.decode("utf-8") + json_data = json.loads(response_str) + except UnicodeDecodeError as exc: # pragma: no cover + json_data = None + logger.error("Failed to decode response string. Error: %s", str(exc)) + except json.JSONDecodeError as exc: # pragma: no cover + json_data = None + logger.error("Failed to parse JSON data. Error: %s", str(exc)) + + return json_data diff --git a/package/kedro_viz/api/rest/responses/save_responses.py b/package/kedro_viz/api/rest/responses/save_responses.py new file mode 100644 index 0000000000..bcdd335534 --- /dev/null +++ b/package/kedro_viz/api/rest/responses/save_responses.py @@ -0,0 +1,97 @@ +"""`kedro_viz.api.rest.responses.save_responses` contains response classes +and utility functions for writing and saving REST endpoint responses to file system""" + +import logging +from typing import Any + +from kedro_viz.api.rest.responses.nodes import get_node_metadata_response +from kedro_viz.api.rest.responses.pipelines import get_pipeline_response +from kedro_viz.api.rest.responses.utils import get_encoded_response +from kedro_viz.data_access import data_access_manager +from kedro_viz.models.flowchart.node_metadata import DataNodeMetadata + +logger = logging.getLogger(__name__) + + +def save_api_responses_to_fs(path: str, remote_fs: Any, is_all_previews_enabled: bool): + """Saves all Kedro Viz API responses to a directory.""" + try: + logger.debug( + """Saving/Uploading api files to %s""", + path, + ) + + main_path = f"{path}/api/main" + nodes_path = f"{path}/api/nodes" + pipelines_path = f"{path}/api/pipelines" + + if "file" in remote_fs.protocol: + remote_fs.makedirs(path, exist_ok=True) + remote_fs.makedirs(nodes_path, exist_ok=True) + remote_fs.makedirs(pipelines_path, exist_ok=True) + + save_api_main_response_to_fs(main_path, remote_fs) + save_api_node_response_to_fs(nodes_path, remote_fs, is_all_previews_enabled) + save_api_pipeline_response_to_fs(pipelines_path, remote_fs) + + except Exception as exc: # pragma: no cover + logger.exception( + "An error occurred while preparing data for saving. Error: %s", str(exc) + ) + raise exc + + +def save_api_main_response_to_fs(main_path: str, remote_fs: Any): + """Saves API /main response to a directory.""" + try: + write_api_response_to_fs(main_path, get_pipeline_response(), remote_fs) + except Exception as exc: # pragma: no cover + logger.exception("Failed to save default response. Error: %s", str(exc)) + raise exc + + +def save_api_pipeline_response_to_fs(pipelines_path: str, remote_fs: Any): + """Saves API /pipelines/{pipeline} response to a directory.""" + for pipeline_id in data_access_manager.registered_pipelines.get_pipeline_ids(): + try: + write_api_response_to_fs( + f"{pipelines_path}/{pipeline_id}", + get_pipeline_response(pipeline_id), + remote_fs, + ) + except Exception as exc: # pragma: no cover + logger.exception( + "Failed to save pipeline data for pipeline ID %s. Error: %s", + pipeline_id, + str(exc), + ) + raise exc + + +def save_api_node_response_to_fs( + nodes_path: str, remote_fs: Any, is_all_previews_enabled: bool +): + """Saves API /nodes/{node} response to a directory.""" + # Set if preview is enabled/disabled for all data nodes + DataNodeMetadata.set_is_all_previews_enabled(is_all_previews_enabled) + + for node_id in data_access_manager.nodes.get_node_ids(): + try: + write_api_response_to_fs( + f"{nodes_path}/{node_id}", + get_node_metadata_response(node_id), + remote_fs, + ) + except Exception as exc: # pragma: no cover + logger.exception( + "Failed to save node data for node ID %s. Error: %s", node_id, str(exc) + ) + raise exc + + +def write_api_response_to_fs(file_path: str, response: Any, remote_fs: Any): + """Get encoded responses and writes it to a file""" + encoded_response = get_encoded_response(response) + + with remote_fs.open(file_path, "wb") as file: + file.write(encoded_response) diff --git a/package/kedro_viz/api/rest/responses/utils.py b/package/kedro_viz/api/rest/responses/utils.py new file mode 100644 index 0000000000..38bae09460 --- /dev/null +++ b/package/kedro_viz/api/rest/responses/utils.py @@ -0,0 +1,44 @@ +"""`kedro_viz.api.rest.responses.utils` contains utility +response classes and functions for the REST endpoints""" + +import logging +from typing import Any + +import orjson +from fastapi.encoders import jsonable_encoder +from fastapi.responses import ORJSONResponse + +logger = logging.getLogger(__name__) + + +class EnhancedORJSONResponse(ORJSONResponse): + """ + EnhancedORJSONResponse is a subclass of ORJSONResponse that provides + additional functionality for encoding content to a human-readable JSON format. + """ + + @staticmethod + def encode_to_human_readable(content: Any) -> bytes: + """A method to encode the given content to JSON, with the + proper formatting to write a human-readable file. + + Returns: + A bytes object containing the JSON to write. + + """ + return orjson.dumps( + content, + option=orjson.OPT_INDENT_2 + | orjson.OPT_NON_STR_KEYS + | orjson.OPT_SERIALIZE_NUMPY, + ) + + +def get_encoded_response(response: Any) -> bytes: + """Encodes and enhances the default response using human-readable format.""" + jsonable_response = jsonable_encoder(response) + encoded_response = EnhancedORJSONResponse.encode_to_human_readable( + jsonable_response + ) + + return encoded_response diff --git a/package/kedro_viz/api/rest/router.py b/package/kedro_viz/api/rest/router.py index a32e204281..2a743239fb 100644 --- a/package/kedro_viz/api/rest/router.py +++ b/package/kedro_viz/api/rest/router.py @@ -6,35 +6,31 @@ from fastapi.responses import JSONResponse from kedro_viz.api.rest.requests import DeployerConfiguration -from kedro_viz.integrations.deployment.deployer_factory import DeployerFactory - -from .responses import ( - APIErrorMessage, - GraphAPIResponse, +from kedro_viz.api.rest.responses.base import APINotFoundResponse +from kedro_viz.api.rest.responses.metadata import ( MetadataAPIResponse, - NodeMetadataAPIResponse, - get_default_response, get_metadata_response, +) +from kedro_viz.api.rest.responses.nodes import ( + NodeMetadataAPIResponse, get_node_metadata_response, - get_selected_pipeline_response, ) - -try: - from azure.core.exceptions import ServiceRequestError -except ImportError: # pragma: no cover - ServiceRequestError = None # type: ignore +from kedro_viz.api.rest.responses.pipelines import ( + GraphAPIResponse, + get_pipeline_response, +) logger = logging.getLogger(__name__) router = APIRouter( prefix="/api", - responses={404: {"model": APIErrorMessage}}, + responses={404: {"model": APINotFoundResponse}}, ) @router.get("/main", response_model=GraphAPIResponse) async def main(): - return get_default_response() + return get_pipeline_response() @router.get( @@ -51,11 +47,18 @@ async def get_single_node_metadata(node_id: str): response_model=GraphAPIResponse, ) async def get_single_pipeline_data(registered_pipeline_id: str): - return get_selected_pipeline_response(registered_pipeline_id) + return get_pipeline_response(registered_pipeline_id) @router.post("/deploy") async def deploy_kedro_viz(input_values: DeployerConfiguration): + from kedro_viz.integrations.deployment.deployer_factory import DeployerFactory + + try: + from azure.core.exceptions import ServiceRequestError + except ImportError: # pragma: no cover + ServiceRequestError = None # type: ignore + try: deployer = DeployerFactory.create_deployer( input_values.platform, input_values.endpoint, input_values.bucket_name diff --git a/package/kedro_viz/data_access/managers.py b/package/kedro_viz/data_access/managers.py index 4468804c77..f7e572a497 100644 --- a/package/kedro_viz/data_access/managers.py +++ b/package/kedro_viz/data_access/managers.py @@ -4,7 +4,6 @@ from collections import defaultdict from typing import Dict, List, Set, Union -import networkx as nx from kedro.io import DataCatalog try: @@ -549,6 +548,8 @@ def create_modular_pipelines_tree_for_registered_pipeline( # noqa: PLR0912 # so no need to check non modular pipeline nodes. # # We leverage networkx to help with graph traversal + import networkx as nx + digraph = nx.DiGraph() for edge in edges: digraph.add_edge(edge.source, edge.target) diff --git a/package/kedro_viz/integrations/deployment/base_deployer.py b/package/kedro_viz/integrations/deployment/base_deployer.py index 35b7fc1818..d0f0b2a7bf 100644 --- a/package/kedro_viz/integrations/deployment/base_deployer.py +++ b/package/kedro_viz/integrations/deployment/base_deployer.py @@ -12,7 +12,7 @@ from packaging.version import parse from kedro_viz import __version__ -from kedro_viz.api.rest.responses import save_api_responses_to_fs +from kedro_viz.api.rest.responses.save_responses import save_api_responses_to_fs from kedro_viz.integrations.kedro import telemetry as kedro_telemetry _HTML_DIR = Path(__file__).parent.parent.parent.absolute() / "html" diff --git a/package/kedro_viz/launchers/cli/deploy.py b/package/kedro_viz/launchers/cli/deploy.py index 75d0b8bb43..87e9157033 100644 --- a/package/kedro_viz/launchers/cli/deploy.py +++ b/package/kedro_viz/launchers/cli/deploy.py @@ -5,6 +5,7 @@ from kedro_viz.constants import SHAREABLEVIZ_SUPPORTED_PLATFORMS from kedro_viz.launchers.cli.main import viz +from kedro_viz.launchers.utils import display_cli_message @viz.command(context_settings={"help_option_names": ["-h", "--help"]}) @@ -39,10 +40,7 @@ ) def deploy(platform, endpoint, bucket_name, include_hooks, include_previews): """Deploy and host Kedro Viz on provided platform""" - from kedro_viz.launchers.cli.utils import ( - create_shareableviz_process, - display_cli_message, - ) + from kedro_viz.launchers.cli.utils import create_shareableviz_process if not platform or platform.lower() not in SHAREABLEVIZ_SUPPORTED_PLATFORMS: display_cli_message( diff --git a/package/kedro_viz/launchers/cli/run.py b/package/kedro_viz/launchers/cli/run.py index 4fab6c1869..e7dd08b408 100644 --- a/package/kedro_viz/launchers/cli/run.py +++ b/package/kedro_viz/launchers/cli/run.py @@ -111,13 +111,13 @@ def run( get_latest_version, is_running_outdated_version, ) - from kedro_viz.launchers.cli.utils import display_cli_message from kedro_viz.launchers.utils import ( _PYPROJECT, _check_viz_up, _find_kedro_project, _start_browser, _wait_for, + display_cli_message, ) from kedro_viz.server import run_server diff --git a/package/kedro_viz/launchers/cli/utils.py b/package/kedro_viz/launchers/cli/utils.py index b5a376022b..60e7403535 100644 --- a/package/kedro_viz/launchers/cli/utils.py +++ b/package/kedro_viz/launchers/cli/utils.py @@ -4,9 +4,8 @@ from time import sleep from typing import Union -import click - from kedro_viz.constants import VIZ_DEPLOY_TIME_LIMIT +from kedro_viz.launchers.utils import display_cli_message def create_shareableviz_process( @@ -103,16 +102,6 @@ def create_shareableviz_process( viz_deploy_process.terminate() -def display_cli_message(msg, msg_color=None): - """Displays message for Kedro Viz build and deploy commands""" - click.echo( - click.style( - msg, - fg=msg_color, - ) - ) - - def _load_and_deploy_viz( platform, is_all_previews_enabled, diff --git a/package/kedro_viz/launchers/utils.py b/package/kedro_viz/launchers/utils.py index 00fcde64eb..5c6bbae9e3 100644 --- a/package/kedro_viz/launchers/utils.py +++ b/package/kedro_viz/launchers/utils.py @@ -7,6 +7,7 @@ from time import sleep, time from typing import Any, Callable, Union +import click import requests logger = logging.getLogger(__name__) @@ -113,3 +114,13 @@ def _find_kedro_project(current_dir: Path) -> Any: if _is_project(project_dir): return project_dir return None + + +def display_cli_message(msg, msg_color=None): + """Displays message for Kedro Viz build and deploy commands""" + click.echo( + click.style( + msg, + fg=msg_color, + ) + ) diff --git a/package/kedro_viz/server.py b/package/kedro_viz/server.py index d9b8fbc2e6..251bb32b6b 100644 --- a/package/kedro_viz/server.py +++ b/package/kedro_viz/server.py @@ -8,13 +8,12 @@ from kedro.io import DataCatalog from kedro.pipeline import Pipeline -from kedro_viz.api.rest.responses import save_api_responses_to_fs from kedro_viz.constants import DEFAULT_HOST, DEFAULT_PORT from kedro_viz.data_access import DataAccessManager, data_access_manager from kedro_viz.database import make_db_session_factory from kedro_viz.integrations.kedro import data_loader as kedro_data_loader from kedro_viz.integrations.kedro.sqlite_store import SQLiteStore -from kedro_viz.launchers.utils import _check_viz_up, _wait_for +from kedro_viz.launchers.utils import _check_viz_up, _wait_for, display_cli_message DEV_PORT = 4142 @@ -124,6 +123,10 @@ def run_server( # [TODO: As we can do this with `kedro viz build`, # we need to shift this feature outside of kedro viz run] if save_file: + from kedro_viz.api.rest.responses.save_responses import ( + save_api_responses_to_fs, + ) + save_api_responses_to_fs(save_file, fsspec.filesystem("file"), True) app = apps.create_api_app_from_project(path, autoreload) @@ -170,13 +173,14 @@ def run_server( target=run_process, daemon=False, kwargs={**run_process_kwargs} ) - print("Starting Kedro Viz ...") + display_cli_message("Starting Kedro Viz ...", "green") viz_process.start() _wait_for(func=_check_viz_up, host=args.host, port=args.port) - print( + display_cli_message( "Kedro Viz started successfully. \n\n" - f"\u2728 Kedro Viz is running at \n http://{args.host}:{args.port}/" + f"\u2728 Kedro Viz is running at \n http://{args.host}:{args.port}/", + "green", ) diff --git a/package/tests/conftest.py b/package/tests/conftest.py index c6b802974a..5c1a300abb 100644 --- a/package/tests/conftest.py +++ b/package/tests/conftest.py @@ -485,7 +485,12 @@ def example_api( example_stats_dict, ) mocker.patch( - "kedro_viz.api.rest.responses.data_access_manager", new=data_access_manager + "kedro_viz.api.rest.responses.pipelines.data_access_manager", + new=data_access_manager, + ) + mocker.patch( + "kedro_viz.api.rest.responses.nodes.data_access_manager", + new=data_access_manager, ) yield api @@ -504,7 +509,12 @@ def example_api_no_default_pipeline( data_access_manager, example_catalog, example_pipelines, session_store, {} ) mocker.patch( - "kedro_viz.api.rest.responses.data_access_manager", new=data_access_manager + "kedro_viz.api.rest.responses.pipelines.data_access_manager", + new=data_access_manager, + ) + mocker.patch( + "kedro_viz.api.rest.responses.nodes.data_access_manager", + new=data_access_manager, ) yield api @@ -534,7 +544,12 @@ def example_api_for_edge_case_pipelines( {}, ) mocker.patch( - "kedro_viz.api.rest.responses.data_access_manager", new=data_access_manager + "kedro_viz.api.rest.responses.pipelines.data_access_manager", + new=data_access_manager, + ) + mocker.patch( + "kedro_viz.api.rest.responses.nodes.data_access_manager", + new=data_access_manager, ) yield api @@ -556,7 +571,12 @@ def example_transcoded_api( {}, ) mocker.patch( - "kedro_viz.api.rest.responses.data_access_manager", new=data_access_manager + "kedro_viz.api.rest.responses.pipelines.data_access_manager", + new=data_access_manager, + ) + mocker.patch( + "kedro_viz.api.rest.responses.nodes.data_access_manager", + new=data_access_manager, ) yield api diff --git a/package/tests/test_api/test_rest/test_responses/__init__.py b/package/tests/test_api/test_rest/test_responses/__init__.py new file mode 100755 index 0000000000..e69de29bb2 diff --git a/package/tests/test_api/test_rest/test_responses.py b/package/tests/test_api/test_rest/test_responses/assert_helpers.py similarity index 50% rename from package/tests/test_api/test_rest/test_responses.py rename to package/tests/test_api/test_rest/test_responses/assert_helpers.py index 8dbf549416..a55ecd9b81 100644 --- a/package/tests/test_api/test_rest/test_responses.py +++ b/package/tests/test_api/test_rest/test_responses/assert_helpers.py @@ -1,26 +1,5 @@ -import json import operator -from pathlib import Path from typing import Any, Dict, Iterable, List -from unittest import mock -from unittest.mock import Mock, call, patch - -import pytest -from fastapi.testclient import TestClient - -from kedro_viz.api import apps -from kedro_viz.api.rest.responses import ( - EnhancedORJSONResponse, - get_kedro_project_json_data, - get_metadata_response, - save_api_main_response_to_fs, - save_api_node_response_to_fs, - save_api_pipeline_response_to_fs, - save_api_responses_to_fs, - write_api_response_to_fs, -) -from kedro_viz.models.flowchart.nodes import TaskNode -from kedro_viz.models.metadata import Metadata def _is_dict_list(collection: Any) -> bool: @@ -29,19 +8,21 @@ def _is_dict_list(collection: Any) -> bool: return False -def assert_dict_list_equal( - response: List[Dict], expected: List[Dict], sort_keys: Iterable[str] -): - """Assert two list of dictionaries with undeterministic order - to be equal by sorting them first based on a sort key. - """ - if len(response) == 0: - assert len(expected) == 0 - return +def assert_modular_pipelines_tree_equal(response: Dict, expected: Dict): + """Assert if modular pipelines tree are equal.""" + # first assert that they have the same set of keys + assert sorted(response.keys()) == sorted(expected.keys()) - assert sorted(response, key=operator.itemgetter(*sort_keys)) == sorted( - expected, key=operator.itemgetter(*sort_keys) - ) + # then compare the dictionary at each key recursively + for key in response: + if isinstance(response[key], dict): + assert_modular_pipelines_tree_equal(response[key], expected[key]) + elif _is_dict_list(response[key]): + assert_dict_list_equal(response[key], expected[key], sort_keys=("id",)) + elif isinstance(response[key], list): + assert sorted(response[key]) == sorted(expected[key]) + else: + assert response[key] == expected[key] def assert_nodes_equal(response_nodes, expected_nodes): @@ -70,21 +51,19 @@ def assert_nodes_equal(response_nodes, expected_nodes): assert response_node == expected_node -def assert_modular_pipelines_tree_equal(response: Dict, expected: Dict): - """Assert if modular pipelines tree are equal.""" - # first assert that they have the same set of keys - assert sorted(response.keys()) == sorted(expected.keys()) +def assert_dict_list_equal( + response: List[Dict], expected: List[Dict], sort_keys: Iterable[str] +): + """Assert two list of dictionaries with undeterministic order + to be equal by sorting them first based on a sort key. + """ + if len(response) == 0: + assert len(expected) == 0 + return - # then compare the dictionary at each key recursively - for key in response: - if isinstance(response[key], dict): - assert_modular_pipelines_tree_equal(response[key], expected[key]) - elif _is_dict_list(response[key]): - assert_dict_list_equal(response[key], expected[key], sort_keys=("id",)) - elif isinstance(response[key], list): - assert sorted(response[key]) == sorted(expected[key]) - else: - assert response[key] == expected[key] + assert sorted(response, key=operator.itemgetter(*sort_keys)) == sorted( + expected, key=operator.itemgetter(*sort_keys) + ) def assert_example_data(response_data): @@ -563,500 +542,3 @@ def assert_example_transcoded_data(response_data): ] assert_nodes_equal(response_data.pop("nodes"), expected_nodes) - - -class TestMainEndpoint: - """Test a viz API created from a Kedro project.""" - - def test_endpoint_main(self, client): - response = client.get("/api/main") - assert_example_data(response.json()) - - def test_endpoint_main_no_default_pipeline(self, example_api_no_default_pipeline): - client = TestClient(example_api_no_default_pipeline) - response = client.get("/api/main") - assert len(response.json()["nodes"]) == 6 - assert len(response.json()["edges"]) == 9 - assert response.json()["pipelines"] == [ - {"id": "data_science", "name": "data_science"}, - {"id": "data_processing", "name": "data_processing"}, - ] - - def test_endpoint_main_for_edge_case_pipelines( - self, - example_api_for_edge_case_pipelines, - expected_modular_pipeline_tree_for_edge_cases, - ): - client = TestClient(example_api_for_edge_case_pipelines) - response = client.get("/api/main") - actual_modular_pipelines_tree = response.json()["modular_pipelines"] - assert_modular_pipelines_tree_equal( - actual_modular_pipelines_tree, expected_modular_pipeline_tree_for_edge_cases - ) - - -class TestTranscodedDataset: - """Test a viz API created from a Kedro project.""" - - def test_endpoint_main(self, example_transcoded_api): - client = TestClient(example_transcoded_api) - response = client.get("/api/main") - assert response.status_code == 200 - assert_example_transcoded_data(response.json()) - - def test_transcoded_data_node_metadata(self, example_transcoded_api): - client = TestClient(example_transcoded_api) - response = client.get("/api/nodes/0ecea0de") - assert response.json() == { - "filepath": "model_inputs.csv", - "original_type": "pandas.csv_dataset.CSVDataset", - "transcoded_types": [ - "pandas.parquet_dataset.ParquetDataset", - ], - "run_command": "kedro run --to-outputs=model_inputs@pandas2", - } - - -class TestNodeMetadataEndpoint: - def test_node_not_exist(self, client): - response = client.get("/api/nodes/foo") - assert response.status_code == 404 - - def test_task_node_metadata(self, client): - response = client.get("/api/nodes/782e4a43") - metadata = response.json() - assert ( - metadata["code"].replace(" ", "") - == "defprocess_data(raw_data,train_test_split):\npass\n" - ) - assert metadata["parameters"] == {"uk.data_processing.train_test_split": 0.1} - assert metadata["inputs"] == [ - "uk.data_processing.raw_data", - "params:uk.data_processing.train_test_split", - ] - assert metadata["outputs"] == ["model_inputs"] - assert ( - metadata["run_command"] - == "kedro run --to-nodes='uk.data_processing.process_data'" - ) - assert str(Path("package/tests/conftest.py")) in metadata["filepath"] - - def test_data_node_metadata(self, client): - response = client.get("/api/nodes/0ecea0de") - assert response.json() == { - "filepath": "model_inputs.csv", - "type": "pandas.csv_dataset.CSVDataset", - "preview_type": "TablePreview", - "run_command": "kedro run --to-outputs=model_inputs", - "stats": {"columns": 12, "rows": 29768}, - } - - def test_data_node_metadata_for_free_input(self, client): - response = client.get("/api/nodes/13399a82") - assert response.json() == { - "filepath": "raw_data.csv", - "preview_type": "TablePreview", - "type": "pandas.csv_dataset.CSVDataset", - } - - def test_parameters_node_metadata(self, client): - response = client.get("/api/nodes/f1f1425b") - assert response.json() == { - "parameters": {"train_test_split": 0.1, "num_epochs": 1000} - } - - def test_single_parameter_node_metadata(self, client): - response = client.get("/api/nodes/f0ebef01") - assert response.json() == { - "parameters": {"uk.data_processing.train_test_split": 0.1} - } - - def test_no_metadata(self, client): - with mock.patch.object(TaskNode, "has_metadata", return_value=False): - response = client.get("/api/nodes/782e4a43") - assert response.json() == {} - - -class TestSinglePipelineEndpoint: - def test_get_pipeline(self, client): - response = client.get("/api/pipelines/data_science") - assert response.status_code == 200 - response_data = response.json() - expected_edges = [ - {"source": "f2b25286", "target": "d5a8b994"}, - {"source": "f1f1425b", "target": "uk.data_science"}, - {"source": "f1f1425b", "target": "f2b25286"}, - {"source": "uk.data_science", "target": "d5a8b994"}, - {"source": "uk", "target": "d5a8b994"}, - {"source": "0ecea0de", "target": "uk"}, - {"source": "0ecea0de", "target": "uk.data_science"}, - {"source": "f1f1425b", "target": "uk"}, - {"source": "0ecea0de", "target": "f2b25286"}, - ] - assert_dict_list_equal( - response_data.pop("edges"), expected_edges, sort_keys=("source", "target") - ) - expected_nodes = [ - { - "id": "0ecea0de", - "name": "model_inputs", - "tags": ["train", "split"], - "pipelines": ["__default__", "data_science", "data_processing"], - "modular_pipelines": ["uk.data_science", "uk.data_processing"], - "type": "data", - "layer": "model_inputs", - "dataset_type": "pandas.csv_dataset.CSVDataset", - "stats": {"columns": 12, "rows": 29768}, - }, - { - "id": "f2b25286", - "name": "train_model", - "tags": ["train"], - "pipelines": ["__default__", "data_science"], - "modular_pipelines": ["uk.data_science"], - "type": "task", - "parameters": { - "train_test_split": 0.1, - "num_epochs": 1000, - }, - }, - { - "id": "f1f1425b", - "name": "parameters", - "tags": ["train"], - "pipelines": ["__default__", "data_science"], - "modular_pipelines": None, - "type": "parameters", - "layer": None, - "dataset_type": None, - "stats": None, - }, - { - "id": "d5a8b994", - "name": "uk.data_science.model", - "tags": ["train"], - "pipelines": ["__default__", "data_science"], - "modular_pipelines": ["uk", "uk.data_science"], - "type": "data", - "layer": None, - "dataset_type": "io.memory_dataset.MemoryDataset", - "stats": None, - }, - { - "id": "uk", - "name": "uk", - "tags": ["train"], - "pipelines": ["data_science"], - "type": "modularPipeline", - "modular_pipelines": None, - "layer": None, - "dataset_type": None, - "stats": None, - }, - { - "id": "uk.data_science", - "name": "uk.data_science", - "tags": ["train"], - "pipelines": ["data_science"], - "type": "modularPipeline", - "modular_pipelines": None, - "layer": None, - "dataset_type": None, - "stats": None, - }, - ] - assert_nodes_equal(response_data.pop("nodes"), expected_nodes) - - expected_modular_pipelines = { - "__root__": { - "children": [ - {"id": "f1f1425b", "type": "parameters"}, - {"id": "0ecea0de", "type": "data"}, - {"id": "uk", "type": "modularPipeline"}, - {"id": "d5a8b994", "type": "data"}, - ], - "id": "__root__", - "inputs": [], - "name": "__root__", - "outputs": [], - }, - "uk": { - "children": [ - {"id": "uk.data_science", "type": "modularPipeline"}, - ], - "id": "uk", - "inputs": ["0ecea0de", "f1f1425b"], - "name": "uk", - "outputs": ["d5a8b994"], - }, - "uk.data_science": { - "children": [ - {"id": "f2b25286", "type": "task"}, - ], - "id": "uk.data_science", - "inputs": ["0ecea0de", "f1f1425b"], - "name": "uk.data_science", - "outputs": ["d5a8b994"], - }, - } - - assert_modular_pipelines_tree_equal( - response_data.pop("modular_pipelines"), - expected_modular_pipelines, - ) - - # Extract and sort the layers field - response_data_layers_sorted = sorted(response_data["layers"]) - expected_layers_sorted = sorted(["model_inputs", "raw"]) - assert response_data_layers_sorted == expected_layers_sorted - - # Remove the layers field from response_data for further comparison - response_data.pop("layers") - - # Expected response without the layers field - expected_response_without_layers = { - "tags": [ - {"id": "split", "name": "split"}, - {"id": "train", "name": "train"}, - ], - "pipelines": [ - {"id": "__default__", "name": "__default__"}, - {"id": "data_science", "name": "data_science"}, - {"id": "data_processing", "name": "data_processing"}, - ], - "selected_pipeline": "data_science", - } - assert response_data == expected_response_without_layers - - def test_get_non_existing_pipeline(self, client): - response = client.get("/api/pipelines/foo") - assert response.status_code == 404 - - -class TestAppMetadata: - def test_get_metadata_response(self, mocker): - mock_get_compat = mocker.patch( - "kedro_viz.api.rest.responses.get_package_compatibilities", - return_value="mocked_compatibilities", - ) - mock_set_compat = mocker.patch( - "kedro_viz.api.rest.responses.Metadata.set_package_compatibilities" - ) - - response = get_metadata_response() - - # Assert get_package_compatibilities was called - mock_get_compat.assert_called_once() - - # Assert set_package_compatibilities was called with the mocked compatibilities - mock_set_compat.assert_called_once_with("mocked_compatibilities") - - # Assert the function returns the Metadata instance - assert isinstance(response, Metadata) - - -class TestAPIAppFromFile: - def test_api_app_from_json_file_main_api(self): - filepath = str(Path(__file__).parent.parent) - api_app = apps.create_api_app_from_file(filepath) - client = TestClient(api_app) - response = client.get("/api/main") - assert_example_data_from_file(response.json()) - - def test_api_app_from_json_file_index(self): - filepath = str(Path(__file__).parent.parent) - api_app = apps.create_api_app_from_file(filepath) - client = TestClient(api_app) - response = client.get("/") - assert response.status_code == 200 - - -class TestEnhancedORJSONResponse: - @pytest.mark.parametrize( - "content, expected", - [ - ( - {"key1": "value1", "key2": "value2"}, - b'{\n "key1": "value1",\n "key2": "value2"\n}', - ), - (["item1", "item2"], b'[\n "item1",\n "item2"\n]'), - ], - ) - def test_encode_to_human_readable(self, content, expected): - result = EnhancedORJSONResponse.encode_to_human_readable(content) - assert result == expected - - @pytest.mark.parametrize( - "file_path, response, encoded_response", - [ - ( - "test_output.json", - {"key1": "value1", "key2": "value2"}, - b'{"key1": "value1", "key2": "value2"}', - ), - ], - ) - def test_write_api_response_to_fs( - self, file_path, response, encoded_response, mocker - ): - mock_encode_to_human_readable = mocker.patch( - "kedro_viz.api.rest.responses.EnhancedORJSONResponse.encode_to_human_readable", - return_value=encoded_response, - ) - with patch("fsspec.filesystem") as mock_filesystem: - mockremote_fs = mock_filesystem.return_value - mockremote_fs.open.return_value.__enter__.return_value = Mock() - write_api_response_to_fs(file_path, response, mockremote_fs) - mockremote_fs.open.assert_called_once_with(file_path, "wb") - mock_encode_to_human_readable.assert_called_once() - - def test_get_kedro_project_json_data(self, mocker): - expected_json_data = {"key": "value"} - encoded_response = json.dumps(expected_json_data).encode("utf-8") - - mock_get_default_response = mocker.patch( - "kedro_viz.api.rest.responses.get_default_response", - return_value={"key": "value"}, - ) - mock_get_encoded_response = mocker.patch( - "kedro_viz.api.rest.responses.get_encoded_response", - return_value=encoded_response, - ) - - json_data = get_kedro_project_json_data() - - mock_get_default_response.assert_called_once() - mock_get_encoded_response.assert_called_once_with( - mock_get_default_response.return_value - ) - assert json_data == expected_json_data - - def test_save_api_main_response_to_fs(self, mocker): - expected_default_response = {"test": "json"} - main_path = "/main" - - mock_get_default_response = mocker.patch( - "kedro_viz.api.rest.responses.get_default_response", - return_value=expected_default_response, - ) - mock_write_api_response_to_fs = mocker.patch( - "kedro_viz.api.rest.responses.write_api_response_to_fs" - ) - - remote_fs = Mock() - - save_api_main_response_to_fs(main_path, remote_fs) - - mock_get_default_response.assert_called_once() - mock_write_api_response_to_fs.assert_called_once_with( - main_path, mock_get_default_response.return_value, remote_fs - ) - - def test_save_api_node_response_to_fs(self, mocker): - nodes_path = "/nodes" - nodeIds = ["01f456", "01f457"] - expected_metadata_response = {"test": "json"} - - mock_get_node_metadata_response = mocker.patch( - "kedro_viz.api.rest.responses.get_node_metadata_response", - return_value=expected_metadata_response, - ) - mock_write_api_response_to_fs = mocker.patch( - "kedro_viz.api.rest.responses.write_api_response_to_fs" - ) - mocker.patch( - "kedro_viz.api.rest.responses.data_access_manager.nodes.get_node_ids", - return_value=nodeIds, - ) - remote_fs = Mock() - - save_api_node_response_to_fs(nodes_path, remote_fs, False) - - assert mock_write_api_response_to_fs.call_count == len(nodeIds) - assert mock_get_node_metadata_response.call_count == len(nodeIds) - - expected_calls = [ - call( - f"{nodes_path}/{nodeId}", - mock_get_node_metadata_response.return_value, - remote_fs, - ) - for nodeId in nodeIds - ] - mock_write_api_response_to_fs.assert_has_calls(expected_calls, any_order=True) - - def test_save_api_pipeline_response_to_fs(self, mocker): - pipelines_path = "/pipelines" - pipelineIds = ["01f456", "01f457"] - expected_selected_pipeline_response = {"test": "json"} - - mock_get_selected_pipeline_response = mocker.patch( - "kedro_viz.api.rest.responses.get_selected_pipeline_response", - return_value=expected_selected_pipeline_response, - ) - mock_write_api_response_to_fs = mocker.patch( - "kedro_viz.api.rest.responses.write_api_response_to_fs" - ) - - mocker.patch( - "kedro_viz.api.rest.responses.data_access_manager." - "registered_pipelines.get_pipeline_ids", - return_value=pipelineIds, - ) - - remote_fs = Mock() - - save_api_pipeline_response_to_fs(pipelines_path, remote_fs) - - assert mock_write_api_response_to_fs.call_count == len(pipelineIds) - assert mock_get_selected_pipeline_response.call_count == len(pipelineIds) - - expected_calls = [ - call( - f"{pipelines_path}/{pipelineId}", - mock_get_selected_pipeline_response.return_value, - remote_fs, - ) - for pipelineId in pipelineIds - ] - mock_write_api_response_to_fs.assert_has_calls(expected_calls, any_order=True) - - @pytest.mark.parametrize( - "file_path, protocol, is_all_previews_enabled", - [ - ("s3://shareableviz", "s3", True), - ("abfs://shareableviz", "abfs", False), - ("shareableviz", "file", True), - ], - ) - def test_save_api_responses_to_fs( - self, file_path, protocol, is_all_previews_enabled, mocker - ): - mock_api_main_response_to_fs = mocker.patch( - "kedro_viz.api.rest.responses.save_api_main_response_to_fs" - ) - mock_api_node_response_to_fs = mocker.patch( - "kedro_viz.api.rest.responses.save_api_node_response_to_fs" - ) - mock_api_pipeline_response_to_fs = mocker.patch( - "kedro_viz.api.rest.responses.save_api_pipeline_response_to_fs" - ) - - mock_filesystem = mocker.patch("fsspec.filesystem") - mock_filesystem.return_value.protocol = protocol - - save_api_responses_to_fs( - file_path, mock_filesystem.return_value, is_all_previews_enabled - ) - - mock_api_main_response_to_fs.assert_called_once_with( - f"{file_path}/api/main", mock_filesystem.return_value - ) - mock_api_node_response_to_fs.assert_called_once_with( - f"{file_path}/api/nodes", - mock_filesystem.return_value, - is_all_previews_enabled, - ) - mock_api_pipeline_response_to_fs.assert_called_once_with( - f"{file_path}/api/pipelines", mock_filesystem.return_value - ) diff --git a/package/tests/test_api/test_rest/test_responses/test_base.py b/package/tests/test_api/test_rest/test_responses/test_base.py new file mode 100755 index 0000000000..d487fc542d --- /dev/null +++ b/package/tests/test_api/test_rest/test_responses/test_base.py @@ -0,0 +1,10 @@ +from kedro_viz.api.rest.responses.base import APINotFoundResponse + + +def test_api_not_found_response_valid_message(): + response = APINotFoundResponse(message="Resource not found") + assert response.message == "Resource not found" + + # Test that the model is serializable to a dictionary + serialized_response = response.model_dump() + assert serialized_response == {"message": "Resource not found"} diff --git a/package/tests/test_api/test_rest/test_responses/test_metadata.py b/package/tests/test_api/test_rest/test_responses/test_metadata.py new file mode 100755 index 0000000000..c6e8dd6d12 --- /dev/null +++ b/package/tests/test_api/test_rest/test_responses/test_metadata.py @@ -0,0 +1,24 @@ +from kedro_viz.api.rest.responses.metadata import get_metadata_response +from kedro_viz.models.metadata import Metadata + + +class TestAppMetadata: + def test_get_metadata_response(self, mocker): + mock_get_compat = mocker.patch( + "kedro_viz.api.rest.responses.metadata.get_package_compatibilities", + return_value="mocked_compatibilities", + ) + mock_set_compat = mocker.patch( + "kedro_viz.api.rest.responses.metadata.Metadata.set_package_compatibilities" + ) + + response = get_metadata_response() + + # Assert get_package_compatibilities was called + mock_get_compat.assert_called_once() + + # Assert set_package_compatibilities was called with the mocked compatibilities + mock_set_compat.assert_called_once_with("mocked_compatibilities") + + # Assert the function returns the Metadata instance + assert isinstance(response, Metadata) diff --git a/package/tests/test_api/test_rest/test_responses/test_nodes.py b/package/tests/test_api/test_rest/test_responses/test_nodes.py new file mode 100644 index 0000000000..6ee2008826 --- /dev/null +++ b/package/tests/test_api/test_rest/test_responses/test_nodes.py @@ -0,0 +1,91 @@ +from pathlib import Path +from unittest import mock + +from fastapi.testclient import TestClient + +from kedro_viz.models.flowchart.nodes import TaskNode +from tests.test_api.test_rest.test_responses.assert_helpers import ( + assert_example_transcoded_data, +) + + +class TestTranscodedDataset: + """Test a viz API created from a Kedro project.""" + + def test_endpoint_main(self, example_transcoded_api): + client = TestClient(example_transcoded_api) + response = client.get("/api/main") + assert response.status_code == 200 + assert_example_transcoded_data(response.json()) + + def test_transcoded_data_node_metadata(self, example_transcoded_api): + client = TestClient(example_transcoded_api) + response = client.get("/api/nodes/0ecea0de") + assert response.json() == { + "filepath": "model_inputs.csv", + "original_type": "pandas.csv_dataset.CSVDataset", + "transcoded_types": [ + "pandas.parquet_dataset.ParquetDataset", + ], + "run_command": "kedro run --to-outputs=model_inputs@pandas2", + } + + +class TestNodeMetadataEndpoint: + def test_node_not_exist(self, client): + response = client.get("/api/nodes/foo") + assert response.status_code == 404 + + def test_task_node_metadata(self, client): + response = client.get("/api/nodes/782e4a43") + metadata = response.json() + assert ( + metadata["code"].replace(" ", "") + == "defprocess_data(raw_data,train_test_split):\npass\n" + ) + assert metadata["parameters"] == {"uk.data_processing.train_test_split": 0.1} + assert metadata["inputs"] == [ + "uk.data_processing.raw_data", + "params:uk.data_processing.train_test_split", + ] + assert metadata["outputs"] == ["model_inputs"] + assert ( + metadata["run_command"] + == "kedro run --to-nodes='uk.data_processing.process_data'" + ) + assert str(Path("package/tests/conftest.py")) in metadata["filepath"] + + def test_data_node_metadata(self, client): + response = client.get("/api/nodes/0ecea0de") + assert response.json() == { + "filepath": "model_inputs.csv", + "type": "pandas.csv_dataset.CSVDataset", + "preview_type": "TablePreview", + "run_command": "kedro run --to-outputs=model_inputs", + "stats": {"columns": 12, "rows": 29768}, + } + + def test_data_node_metadata_for_free_input(self, client): + response = client.get("/api/nodes/13399a82") + assert response.json() == { + "filepath": "raw_data.csv", + "preview_type": "TablePreview", + "type": "pandas.csv_dataset.CSVDataset", + } + + def test_parameters_node_metadata(self, client): + response = client.get("/api/nodes/f1f1425b") + assert response.json() == { + "parameters": {"train_test_split": 0.1, "num_epochs": 1000} + } + + def test_single_parameter_node_metadata(self, client): + response = client.get("/api/nodes/f0ebef01") + assert response.json() == { + "parameters": {"uk.data_processing.train_test_split": 0.1} + } + + def test_no_metadata(self, client): + with mock.patch.object(TaskNode, "has_metadata", return_value=False): + response = client.get("/api/nodes/782e4a43") + assert response.json() == {} diff --git a/package/tests/test_api/test_rest/test_responses/test_pipelines.py b/package/tests/test_api/test_rest/test_responses/test_pipelines.py new file mode 100755 index 0000000000..4b933e33e2 --- /dev/null +++ b/package/tests/test_api/test_rest/test_responses/test_pipelines.py @@ -0,0 +1,241 @@ +import json +from pathlib import Path + +from fastapi.testclient import TestClient + +from kedro_viz.api import apps +from kedro_viz.api.rest.responses.pipelines import get_kedro_project_json_data +from tests.test_api.test_rest.test_responses.assert_helpers import ( + assert_dict_list_equal, + assert_example_data, + assert_example_data_from_file, + assert_modular_pipelines_tree_equal, + assert_nodes_equal, +) + + +class TestMainEndpoint: + """Test a viz API created from a Kedro project.""" + + def test_endpoint_main(self, client, mocker, data_access_manager): + mocker.patch( + "kedro_viz.api.rest.responses.nodes.data_access_manager", + new=data_access_manager, + ) + response = client.get("/api/main") + assert_example_data(response.json()) + + def test_endpoint_main_no_default_pipeline(self, example_api_no_default_pipeline): + client = TestClient(example_api_no_default_pipeline) + response = client.get("/api/main") + assert len(response.json()["nodes"]) == 6 + assert len(response.json()["edges"]) == 9 + assert response.json()["pipelines"] == [ + {"id": "data_science", "name": "data_science"}, + {"id": "data_processing", "name": "data_processing"}, + ] + + def test_endpoint_main_for_edge_case_pipelines( + self, + example_api_for_edge_case_pipelines, + expected_modular_pipeline_tree_for_edge_cases, + ): + client = TestClient(example_api_for_edge_case_pipelines) + response = client.get("/api/main") + actual_modular_pipelines_tree = response.json()["modular_pipelines"] + assert_modular_pipelines_tree_equal( + actual_modular_pipelines_tree, expected_modular_pipeline_tree_for_edge_cases + ) + + def test_get_kedro_project_json_data(self, mocker): + expected_json_data = {"key": "value"} + encoded_response = json.dumps(expected_json_data).encode("utf-8") + + mock_get_default_response = mocker.patch( + "kedro_viz.api.rest.responses.pipelines.get_pipeline_response", + return_value={"key": "value"}, + ) + mock_get_encoded_response = mocker.patch( + "kedro_viz.api.rest.responses.pipelines.get_encoded_response", + return_value=encoded_response, + ) + + json_data = get_kedro_project_json_data() + + mock_get_default_response.assert_called_once() + mock_get_encoded_response.assert_called_once_with( + mock_get_default_response.return_value + ) + assert json_data == expected_json_data + + +class TestSinglePipelineEndpoint: + def test_get_pipeline(self, client): + response = client.get("/api/pipelines/data_science") + assert response.status_code == 200 + response_data = response.json() + expected_edges = [ + {"source": "f2b25286", "target": "d5a8b994"}, + {"source": "f1f1425b", "target": "uk.data_science"}, + {"source": "f1f1425b", "target": "f2b25286"}, + {"source": "uk.data_science", "target": "d5a8b994"}, + {"source": "uk", "target": "d5a8b994"}, + {"source": "0ecea0de", "target": "uk"}, + {"source": "0ecea0de", "target": "uk.data_science"}, + {"source": "f1f1425b", "target": "uk"}, + {"source": "0ecea0de", "target": "f2b25286"}, + ] + assert_dict_list_equal( + response_data.pop("edges"), expected_edges, sort_keys=("source", "target") + ) + expected_nodes = [ + { + "id": "0ecea0de", + "name": "model_inputs", + "tags": ["train", "split"], + "pipelines": ["__default__", "data_science", "data_processing"], + "modular_pipelines": ["uk.data_science", "uk.data_processing"], + "type": "data", + "layer": "model_inputs", + "dataset_type": "pandas.csv_dataset.CSVDataset", + "stats": {"columns": 12, "rows": 29768}, + }, + { + "id": "f2b25286", + "name": "train_model", + "tags": ["train"], + "pipelines": ["__default__", "data_science"], + "modular_pipelines": ["uk.data_science"], + "type": "task", + "parameters": { + "train_test_split": 0.1, + "num_epochs": 1000, + }, + }, + { + "id": "f1f1425b", + "name": "parameters", + "tags": ["train"], + "pipelines": ["__default__", "data_science"], + "modular_pipelines": None, + "type": "parameters", + "layer": None, + "dataset_type": None, + "stats": None, + }, + { + "id": "d5a8b994", + "name": "uk.data_science.model", + "tags": ["train"], + "pipelines": ["__default__", "data_science"], + "modular_pipelines": ["uk", "uk.data_science"], + "type": "data", + "layer": None, + "dataset_type": "io.memory_dataset.MemoryDataset", + "stats": None, + }, + { + "id": "uk", + "name": "uk", + "tags": ["train"], + "pipelines": ["data_science"], + "type": "modularPipeline", + "modular_pipelines": None, + "layer": None, + "dataset_type": None, + "stats": None, + }, + { + "id": "uk.data_science", + "name": "uk.data_science", + "tags": ["train"], + "pipelines": ["data_science"], + "type": "modularPipeline", + "modular_pipelines": None, + "layer": None, + "dataset_type": None, + "stats": None, + }, + ] + assert_nodes_equal(response_data.pop("nodes"), expected_nodes) + + expected_modular_pipelines = { + "__root__": { + "children": [ + {"id": "f1f1425b", "type": "parameters"}, + {"id": "0ecea0de", "type": "data"}, + {"id": "uk", "type": "modularPipeline"}, + {"id": "d5a8b994", "type": "data"}, + ], + "id": "__root__", + "inputs": [], + "name": "__root__", + "outputs": [], + }, + "uk": { + "children": [ + {"id": "uk.data_science", "type": "modularPipeline"}, + ], + "id": "uk", + "inputs": ["0ecea0de", "f1f1425b"], + "name": "uk", + "outputs": ["d5a8b994"], + }, + "uk.data_science": { + "children": [ + {"id": "f2b25286", "type": "task"}, + ], + "id": "uk.data_science", + "inputs": ["0ecea0de", "f1f1425b"], + "name": "uk.data_science", + "outputs": ["d5a8b994"], + }, + } + + assert_modular_pipelines_tree_equal( + response_data.pop("modular_pipelines"), + expected_modular_pipelines, + ) + + # Extract and sort the layers field + response_data_layers_sorted = sorted(response_data["layers"]) + expected_layers_sorted = sorted(["model_inputs", "raw"]) + assert response_data_layers_sorted == expected_layers_sorted + + # Remove the layers field from response_data for further comparison + response_data.pop("layers") + + # Expected response without the layers field + expected_response_without_layers = { + "tags": [ + {"id": "split", "name": "split"}, + {"id": "train", "name": "train"}, + ], + "pipelines": [ + {"id": "__default__", "name": "__default__"}, + {"id": "data_science", "name": "data_science"}, + {"id": "data_processing", "name": "data_processing"}, + ], + "selected_pipeline": "data_science", + } + assert response_data == expected_response_without_layers + + def test_get_non_existing_pipeline(self, client): + response = client.get("/api/pipelines/foo") + assert response.status_code == 404 + + +class TestAPIAppFromFile: + def test_api_app_from_json_file_main_api(self): + filepath = str(Path(__file__).parent.parent.parent) + api_app = apps.create_api_app_from_file(filepath) + client = TestClient(api_app) + response = client.get("/api/main") + assert_example_data_from_file(response.json()) + + def test_api_app_from_json_file_index(self): + filepath = str(Path(__file__).parent.parent.parent) + api_app = apps.create_api_app_from_file(filepath) + client = TestClient(api_app) + response = client.get("/") + assert response.status_code == 200 diff --git a/package/tests/test_api/test_rest/test_responses/test_save_responses.py b/package/tests/test_api/test_rest/test_responses/test_save_responses.py new file mode 100644 index 0000000000..828fe26269 --- /dev/null +++ b/package/tests/test_api/test_rest/test_responses/test_save_responses.py @@ -0,0 +1,168 @@ +from unittest import mock +from unittest.mock import Mock, call, patch + +import pytest + +from kedro_viz.api.rest.responses.save_responses import ( + save_api_main_response_to_fs, + save_api_node_response_to_fs, + save_api_pipeline_response_to_fs, + save_api_responses_to_fs, + write_api_response_to_fs, +) + + +class TestSaveAPIResponse: + @pytest.mark.parametrize( + "file_path, protocol, is_all_previews_enabled", + [ + ("s3://shareableviz", "s3", True), + ("abfs://shareableviz", "abfs", False), + ("shareableviz", "file", True), + ], + ) + def test_save_api_responses_to_fs( + self, file_path, protocol, is_all_previews_enabled, mocker + ): + mock_api_main_response_to_fs = mocker.patch( + "kedro_viz.api.rest.responses.save_responses.save_api_main_response_to_fs" + ) + mock_api_node_response_to_fs = mocker.patch( + "kedro_viz.api.rest.responses.save_responses.save_api_node_response_to_fs" + ) + mock_api_pipeline_response_to_fs = mocker.patch( + "kedro_viz.api.rest.responses.save_responses.save_api_pipeline_response_to_fs" + ) + + mock_filesystem = mocker.patch("fsspec.filesystem") + mock_filesystem.return_value.protocol = protocol + + save_api_responses_to_fs( + file_path, mock_filesystem.return_value, is_all_previews_enabled + ) + + mock_api_main_response_to_fs.assert_called_once_with( + f"{file_path}/api/main", mock_filesystem.return_value + ) + mock_api_node_response_to_fs.assert_called_once_with( + f"{file_path}/api/nodes", + mock_filesystem.return_value, + is_all_previews_enabled, + ) + mock_api_pipeline_response_to_fs.assert_called_once_with( + f"{file_path}/api/pipelines", mock_filesystem.return_value + ) + + def test_save_api_main_response_to_fs(self, mocker): + expected_default_response = {"test": "json"} + main_path = "/main" + + mock_get_default_response = mocker.patch( + "kedro_viz.api.rest.responses.save_responses.get_pipeline_response", + return_value=expected_default_response, + ) + mock_write_api_response_to_fs = mocker.patch( + "kedro_viz.api.rest.responses.save_responses.write_api_response_to_fs" + ) + + remote_fs = Mock() + + save_api_main_response_to_fs(main_path, remote_fs) + + mock_get_default_response.assert_called_once() + mock_write_api_response_to_fs.assert_called_once_with( + main_path, mock_get_default_response.return_value, remote_fs + ) + + def test_save_api_pipeline_response_to_fs(self, mocker): + pipelines_path = "/pipelines" + pipelineIds = ["01f456", "01f457"] + expected_selected_pipeline_response = {"test": "json"} + + mock_get_selected_pipeline_response = mocker.patch( + "kedro_viz.api.rest.responses.save_responses.get_pipeline_response", + return_value=expected_selected_pipeline_response, + ) + mock_write_api_response_to_fs = mocker.patch( + "kedro_viz.api.rest.responses.save_responses.write_api_response_to_fs" + ) + + mocker.patch( + "kedro_viz.api.rest.responses.save_responses.data_access_manager." + "registered_pipelines.get_pipeline_ids", + return_value=pipelineIds, + ) + + remote_fs = Mock() + + save_api_pipeline_response_to_fs(pipelines_path, remote_fs) + + assert mock_write_api_response_to_fs.call_count == len(pipelineIds) + assert mock_get_selected_pipeline_response.call_count == len(pipelineIds) + + expected_calls = [ + call( + f"{pipelines_path}/{pipelineId}", + mock_get_selected_pipeline_response.return_value, + remote_fs, + ) + for pipelineId in pipelineIds + ] + mock_write_api_response_to_fs.assert_has_calls(expected_calls, any_order=True) + + def test_save_api_node_response_to_fs(self, mocker): + nodes_path = "/nodes" + nodeIds = ["01f456", "01f457"] + expected_metadata_response = {"test": "json"} + + mock_get_node_metadata_response = mocker.patch( + "kedro_viz.api.rest.responses.save_responses.get_node_metadata_response", + return_value=expected_metadata_response, + ) + mock_write_api_response_to_fs = mocker.patch( + "kedro_viz.api.rest.responses.save_responses.write_api_response_to_fs" + ) + mocker.patch( + "kedro_viz.api.rest.responses.save_responses.data_access_manager.nodes.get_node_ids", + return_value=nodeIds, + ) + remote_fs = mock.Mock() + + save_api_node_response_to_fs(nodes_path, remote_fs, False) + + assert mock_write_api_response_to_fs.call_count == len(nodeIds) + assert mock_get_node_metadata_response.call_count == len(nodeIds) + + expected_calls = [ + mock.call( + f"{nodes_path}/{nodeId}", + mock_get_node_metadata_response.return_value, + remote_fs, + ) + for nodeId in nodeIds + ] + mock_write_api_response_to_fs.assert_has_calls(expected_calls, any_order=True) + + @pytest.mark.parametrize( + "file_path, response, encoded_response", + [ + ( + "test_output.json", + {"key1": "value1", "key2": "value2"}, + b'{"key1": "value1", "key2": "value2"}', + ), + ], + ) + def test_write_api_response_to_fs( + self, file_path, response, encoded_response, mocker + ): + mock_encode_to_human_readable = mocker.patch( + "kedro_viz.api.rest.responses.utils.EnhancedORJSONResponse.encode_to_human_readable", + return_value=encoded_response, + ) + with patch("fsspec.filesystem") as mock_filesystem: + mockremote_fs = mock_filesystem.return_value + mockremote_fs.open.return_value.__enter__.return_value = Mock() + write_api_response_to_fs(file_path, response, mockremote_fs) + mockremote_fs.open.assert_called_once_with(file_path, "wb") + mock_encode_to_human_readable.assert_called_once() diff --git a/package/tests/test_api/test_rest/test_responses/test_utils.py b/package/tests/test_api/test_rest/test_responses/test_utils.py new file mode 100644 index 0000000000..cad8607e2b --- /dev/null +++ b/package/tests/test_api/test_rest/test_responses/test_utils.py @@ -0,0 +1,43 @@ +import pytest + +from kedro_viz.api.rest.responses.utils import ( + EnhancedORJSONResponse, + get_encoded_response, +) + + +class TestEnhancedORJSONResponse: + @pytest.mark.parametrize( + "content, expected", + [ + ( + {"key1": "value1", "key2": "value2"}, + b'{\n "key1": "value1",\n "key2": "value2"\n}', + ), + (["item1", "item2"], b'[\n "item1",\n "item2"\n]'), + ], + ) + def test_encode_to_human_readable(self, content, expected): + result = EnhancedORJSONResponse.encode_to_human_readable(content) + assert result == expected + + +def test_get_encoded_response(mocker): + mock_jsonable_encoder = mocker.patch( + "kedro_viz.api.rest.responses.utils.jsonable_encoder" + ) + mock_encode_to_human_readable = mocker.patch( + "kedro_viz.api.rest.responses.utils.EnhancedORJSONResponse.encode_to_human_readable" + ) + + mock_response = {"key": "value"} + mock_jsonable_encoder.return_value = mock_response + mock_encoded_response = b"encoded-response" + mock_encode_to_human_readable.return_value = mock_encoded_response + + result = get_encoded_response(mock_response) + + # Assertions + mock_jsonable_encoder.assert_called_once_with(mock_response) + mock_encode_to_human_readable.assert_called_once_with(mock_response) + assert result == mock_encoded_response diff --git a/package/tests/test_api/test_rest/test_router.py b/package/tests/test_api/test_rest/test_router.py index d84f1ce0f2..523043d96d 100644 --- a/package/tests/test_api/test_rest/test_router.py +++ b/package/tests/test_api/test_rest/test_router.py @@ -21,7 +21,7 @@ def test_deploy_kedro_viz( client, platform, endpoint, bucket_name, is_all_previews_enabled, mocker ): mocker.patch( - "kedro_viz.api.rest.router.DeployerFactory.create_deployer", + "kedro_viz.integrations.deployment.deployer_factory.DeployerFactory.create_deployer", return_value=MockDeployer(platform, endpoint, bucket_name), ) response = client.post( diff --git a/package/tests/test_server.py b/package/tests/test_server.py index 33fe6f2e1b..2169e9d4da 100644 --- a/package/tests/test_server.py +++ b/package/tests/test_server.py @@ -151,7 +151,7 @@ def test_load_file( def test_save_file(self, tmp_path, mocker): mock_filesystem = mocker.patch("fsspec.filesystem") save_api_responses_to_fs_mock = mocker.patch( - "kedro_viz.server.save_api_responses_to_fs" + "kedro_viz.api.rest.responses.save_responses.save_api_responses_to_fs" ) save_file = tmp_path / "save.json" run_server(save_file=save_file) From 4a4e22a59472a630e1f78842844d8c4268fd4262 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 1 Nov 2024 10:53:02 +0000 Subject: [PATCH 17/35] Bump kedro-sphinx-theme from 2024.4.0 to 2024.10.2 in /package (#2168) --- package/pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package/pyproject.toml b/package/pyproject.toml index 7c39412920..3b9c0bab49 100644 --- a/package/pyproject.toml +++ b/package/pyproject.toml @@ -29,7 +29,7 @@ Tracker = "https://github.com/kedro-org/kedro-viz/issues" [project.optional-dependencies] docs = [ - "kedro-sphinx-theme==2024.4.0", + "kedro-sphinx-theme==2024.10.2", ] aws = ["s3fs>=2021.4"] azure = ["adlfs>=2021.4"] From c7cdab95172fe6fe60c3e50b34389b81c46bd697 Mon Sep 17 00:00:00 2001 From: rashidakanchwala <37628668+rashidakanchwala@users.noreply.github.com> Date: Fri, 1 Nov 2024 12:40:02 +0000 Subject: [PATCH 18/35] Fix `tag` being undefined bug from the backend. (#2162) Resolves #2106 --- .../data_access/repositories/graph.py | 9 ++-- package/tests/conftest.py | 53 +++++++++++++++++++ .../test_responses/test_pipelines.py | 14 +++++ 3 files changed, 72 insertions(+), 4 deletions(-) diff --git a/package/kedro_viz/data_access/repositories/graph.py b/package/kedro_viz/data_access/repositories/graph.py index bea6095bc9..463012800b 100644 --- a/package/kedro_viz/data_access/repositories/graph.py +++ b/package/kedro_viz/data_access/repositories/graph.py @@ -12,11 +12,12 @@ def __init__(self): self.nodes_dict: Dict[str, GraphNode] = {} self.nodes_list: List[GraphNode] = [] - def has_node(self, node: GraphNode) -> bool: - return node.id in self.nodes_dict - def add_node(self, node: GraphNode) -> GraphNode: - if not self.has_node(node): + existing_node = self.nodes_dict.get(node.id) + if existing_node: + # Update tags or other attributes if the node already exists + existing_node.tags.update(node.tags) + else: self.nodes_dict[node.id] = node self.nodes_list.append(node) return self.nodes_dict[node.id] diff --git a/package/tests/conftest.py b/package/tests/conftest.py index 5c1a300abb..ea25e94f7c 100644 --- a/package/tests/conftest.py +++ b/package/tests/conftest.py @@ -222,6 +222,7 @@ def example_pipeline_with_node_namespaces(): inputs=["raw_transaction_data", "cleaned_transaction_data"], outputs="validated_transaction_data", name="validation_node", + tags=["validation"], ), node( func=lambda validated_data, enrichment_data: ( @@ -381,6 +382,23 @@ def edge_case_example_pipelines( } +@pytest.fixture +def example_pipelines_with_additional_tags(example_pipeline_with_node_namespaces): + """ + Fixture to mock the use cases mentioned in + https://github.com/kedro-org/kedro-viz/issues/2106 + """ + + pipelines_dict = { + "pipeline": example_pipeline_with_node_namespaces, + "pipeline_with_tags": pipeline( + example_pipeline_with_node_namespaces, tags=["tag1", "tag2"] + ), + } + + yield pipelines_dict + + @pytest.fixture def expected_modular_pipeline_tree_for_edge_cases(): expected_tree_for_edge_cases_file_path = ( @@ -554,6 +572,41 @@ def example_api_for_edge_case_pipelines( yield api +@pytest.fixture +def example_api_for_pipelines_with_additional_tags( + data_access_manager: DataAccessManager, + example_pipelines_with_additional_tags: Dict[str, Pipeline], + example_catalog: DataCatalog, + session_store: BaseSessionStore, + mocker, +): + api = apps.create_api_app_from_project(mock.MagicMock()) + + # For readability we are not hashing the node id + mocker.patch("kedro_viz.utils._hash", side_effect=lambda value: value) + mocker.patch( + "kedro_viz.data_access.repositories.modular_pipelines._hash", + side_effect=lambda value: value, + ) + + populate_data( + data_access_manager, + example_catalog, + example_pipelines_with_additional_tags, + session_store, + {}, + ) + mocker.patch( + "kedro_viz.api.rest.responses.pipelines.data_access_manager", + new=data_access_manager, + ) + mocker.patch( + "kedro_viz.api.rest.responses.nodes.data_access_manager", + new=data_access_manager, + ) + yield api + + @pytest.fixture def example_transcoded_api( data_access_manager: DataAccessManager, diff --git a/package/tests/test_api/test_rest/test_responses/test_pipelines.py b/package/tests/test_api/test_rest/test_responses/test_pipelines.py index 4b933e33e2..b1d14d8ca3 100755 --- a/package/tests/test_api/test_rest/test_responses/test_pipelines.py +++ b/package/tests/test_api/test_rest/test_responses/test_pipelines.py @@ -35,6 +35,20 @@ def test_endpoint_main_no_default_pipeline(self, example_api_no_default_pipeline {"id": "data_processing", "name": "data_processing"}, ] + def test_endpoint_main_for_pipelines_with_additional_tags( + self, + example_api_for_pipelines_with_additional_tags, + ): + expected_tags = [ + {"id": "tag1", "name": "tag1"}, + {"id": "tag2", "name": "tag2"}, + {"id": "validation", "name": "validation"}, + ] + client = TestClient(example_api_for_pipelines_with_additional_tags) + response = client.get("/api/main") + actual_tags = response.json()["tags"] + assert actual_tags == expected_tags + def test_endpoint_main_for_edge_case_pipelines( self, example_api_for_edge_case_pipelines, From 4ec409efe9ab8f4fa72bcd6fa9150c46c3114810 Mon Sep 17 00:00:00 2001 From: Jitendra Gundaniya <38945204+jitu5@users.noreply.github.com> Date: Mon, 4 Nov 2024 11:07:36 +0000 Subject: [PATCH 19/35] Introduce `behaviour` prop object with `reFocus` prop (#2161) * Introduce modeOptions prop object with reFocus prop Signed-off-by: Jitendra Gundaniya * test fix Signed-off-by: Jitendra Gundaniya * Release note added Signed-off-by: Jitendra Gundaniya * Documentation Signed-off-by: Jitendra Gundaniya * Code review change Signed-off-by: Jitendra Gundaniya * Prop renamed to behaviour from modeOptions Signed-off-by: Jitendra Gundaniya * Revert container changes Signed-off-by: Jitendra Gundaniya --------- Signed-off-by: Jitendra Gundaniya --- README.npm.md | 6 +++++ RELEASE.md | 1 + src/components/app/app.js | 6 +++++ src/components/flowchart/flowchart.js | 29 ++++++++++++++-------- src/components/flowchart/flowchart.test.js | 1 + src/reducers/index.js | 1 + src/store/initial-state.js | 3 +++ 7 files changed, 36 insertions(+), 11 deletions(-) diff --git a/README.npm.md b/README.npm.md index f4632b88de..6ef817bc4b 100644 --- a/README.npm.md +++ b/README.npm.md @@ -140,6 +140,9 @@ The example below demonstrates how to configure your kedro-viz using different ` tag: { enabled: {companies: true} }, + behaviour: { + reFocus: true, + }, theme: "dark" }} /> @@ -161,6 +164,9 @@ The example below demonstrates how to configure your kedro-viz using different ` | `sidebar` | boolean | true | Show/Hide Sidebar and action toolbar | | `zoomToolbar` | boolean | true | Show/Hide zoom-in, zoom-out and zoom reset buttons together | | options.expandAllPipelines | boolean | false | Expand/Collapse Modular pipelines on first load | +| options.behaviour | | | | +| `reFocus` | boolean | true | In the flowchart, enable or disable the node re-focus behavior when clicking on nodes. + | options.nodeType | `{disabled: {parameters: boolean,task: boolean,data: boolean}}` | `{disabled: {parameters: true,task: false,data: false}}` | Configuration for node type options | | options.tag | `{enabled: {: boolean}}` | - | Configuration for tag options | | options.theme | string | dark | select `Kedro-Viz` theme : dark/light | diff --git a/RELEASE.md b/RELEASE.md index aaed5e9a5f..bf9e4cff8b 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -11,6 +11,7 @@ Please follow the established format: ## Major features and improvements - Update Kedro-Viz telemetry for opt-out model (#2022) +- Introduce `behaviour` prop object with `reFocus` prop (#2161) ## Bug fixes and other changes diff --git a/src/components/app/app.js b/src/components/app/app.js index 340dc1e44e..b1469854b6 100644 --- a/src/components/app/app.js +++ b/src/components/app/app.js @@ -119,6 +119,12 @@ App.propTypes = { tag: PropTypes.shape({ enabled: PropTypes.objectOf(PropTypes.bool), }), + /** + * Whether to re-focus the graph when a node is clicked + */ + behaviour: PropTypes.shape({ + reFocus: PropTypes.bool, + }), /** * Override the default enabled/disabled node types */ diff --git a/src/components/flowchart/flowchart.js b/src/components/flowchart/flowchart.js index 1afc5c4b93..58d300c30d 100644 --- a/src/components/flowchart/flowchart.js +++ b/src/components/flowchart/flowchart.js @@ -220,22 +220,28 @@ export class FlowChart extends Component { if (changed('edges', 'nodes', 'layers', 'chartSize', 'clickedNode')) { // Don't zoom out when the metadata or code panels are opened or closed - if (prevProps.visibleMetaSidebar !== this.props.visibleMetaSidebar) { + const metaSidebarViewChanged = + prevProps.visibleMetaSidebar !== this.props.visibleMetaSidebar; + + const codeViewChangedWithoutMetaSidebar = + prevProps.visibleCode !== this.props.visibleCode && + !this.props.visibleMetaSidebar; + + // Don't zoom out when the clicked node changes and the nodeReFocus is disabled + const clickedNodeChangedWithoutReFocus = + prevProps.clickedNode !== this.props.clickedNode && + !this.props.nodeReFocus; + + if ( + metaSidebarViewChanged || + codeViewChangedWithoutMetaSidebar || + clickedNodeChangedWithoutReFocus + ) { drawNodes.call(this, changed); drawEdges.call(this, changed); - return; } - if (prevProps.visibleCode !== this.props.visibleCode) { - if (!this.props.visibleMetaSidebar) { - drawNodes.call(this, changed); - drawEdges.call(this, changed); - - return; - } - } - this.resetView(preventZoom); } else { this.onChartZoomChanged(chartZoom); @@ -1000,6 +1006,7 @@ export const mapStateToProps = (state, ownProps) => ({ slicedPipeline: getSlicedPipeline(state), isSlicingPipelineApplied: state.slice.apply, visibleSlicing: state.visible.slicing, + nodeReFocus: state.behaviour.reFocus, runCommand: getRunCommand(state), ...ownProps, }); diff --git a/src/components/flowchart/flowchart.test.js b/src/components/flowchart/flowchart.test.js index fa9812df13..d3d8719fd3 100644 --- a/src/components/flowchart/flowchart.test.js +++ b/src/components/flowchart/flowchart.test.js @@ -492,6 +492,7 @@ describe('FlowChart', () => { runCommand: expect.any(Object), modularPipelineIds: expect.any(Object), visibleSlicing: expect.any(Boolean), + nodeReFocus: expect.any(Boolean), }; expect(mapStateToProps(mockState.spaceflights)).toEqual(expectedResult); }); diff --git a/src/reducers/index.js b/src/reducers/index.js index d2608fa252..79af193f24 100644 --- a/src/reducers/index.js +++ b/src/reducers/index.js @@ -89,6 +89,7 @@ const combinedReducer = combineReducers({ // These props don't have any actions associated with them display: createReducer(null), dataSource: createReducer(null), + behaviour: createReducer({}), edge: createReducer({}), // These props have very simple non-nested actions chartSize: createReducer({}, UPDATE_CHART_SIZE, 'chartSize'), diff --git a/src/store/initial-state.js b/src/store/initial-state.js index 70e1915b17..60f2423310 100644 --- a/src/store/initial-state.js +++ b/src/store/initial-state.js @@ -58,6 +58,9 @@ export const createInitialState = () => ({ zoomToolbar: true, metadataPanel: true, }, + behaviour: { + reFocus: true, + }, zoom: {}, runsMetadata: {}, }); From 96cf45abf63cc040e0bbf7e5a3193eeec375dea6 Mon Sep 17 00:00:00 2001 From: Jitendra Gundaniya <38945204+jitu5@users.noreply.github.com> Date: Mon, 4 Nov 2024 11:52:08 +0000 Subject: [PATCH 20/35] Replace `watchgod` library with `watchfiles` (#2134) * Package update from watchgod to watchfiles Signed-off-by: Jitendra Gundaniya * lint fix Signed-off-by: Jitendra Gundaniya * Test fix Signed-off-by: Jitendra Gundaniya * Test fixes Signed-off-by: Jitendra Gundaniya * lint fixes Signed-off-by: Jitendra Gundaniya * test fix Signed-off-by: Jitendra Gundaniya * test added Signed-off-by: Jitendra Gundaniya * lint fix Signed-off-by: Jitendra Gundaniya * Release note added Signed-off-by: Jitendra Gundaniya * AutoreloadFileFilter added for accurate auto reload files Signed-off-by: Jitendra Gundaniya * Test fix Signed-off-by: Jitendra Gundaniya * Test fix Signed-off-by: Jitendra Gundaniya * lint and unit test fix Signed-off-by: Jitendra Gundaniya * Test fix Signed-off-by: Jitendra Gundaniya * Lint fix Signed-off-by: Jitendra Gundaniya * Lint fix Signed-off-by: Jitendra Gundaniya * Fix import issue Signed-off-by: Jitendra Gundaniya * Extra import removed Signed-off-by: Jitendra Gundaniya * tmp_path fix for tests Signed-off-by: Jitendra Gundaniya * Lint fix Signed-off-by: Jitendra Gundaniya * Tests with docstring Signed-off-by: Jitendra Gundaniya * Moved to GitIgnoreSpec class Signed-off-by: Jitendra Gundaniya --------- Signed-off-by: Jitendra Gundaniya --- RELEASE.md | 1 + package/features/steps/lower_requirements.txt | 3 +- package/kedro_viz/autoreload_file_filter.py | 88 +++++++++++ package/kedro_viz/launchers/cli/run.py | 19 ++- package/kedro_viz/launchers/jupyter.py | 13 +- package/kedro_viz/server.py | 17 ++- package/requirements.txt | 3 +- package/test_requirements.txt | 1 + package/tests/test_autoreload_file_filter.py | 141 ++++++++++++++++++ .../tests/test_launchers/test_cli/test_run.py | 36 +++-- package/tests/test_launchers/test_jupyter.py | 1 + 11 files changed, 291 insertions(+), 32 deletions(-) create mode 100644 package/kedro_viz/autoreload_file_filter.py create mode 100644 package/tests/test_autoreload_file_filter.py diff --git a/RELEASE.md b/RELEASE.md index bf9e4cff8b..3261588f52 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -17,6 +17,7 @@ Please follow the established format: - Improve `kedro viz build` usage documentation (#2126) - Fix unserializable parameters value (#2122) +- Replace `watchgod` library with `watchfiles` and improve autoreload file watching filter (#2134) - Display full dataset type with library prefix in metadata panel (#2136) - Enable SQLite WAL mode for Azure ML to fix database locking issues (#2131) - Replace `flake8`, `isort`, `pylint` and `black` by `ruff` (#2149) diff --git a/package/features/steps/lower_requirements.txt b/package/features/steps/lower_requirements.txt index 1a149e7ad9..ab38585acf 100644 --- a/package/features/steps/lower_requirements.txt +++ b/package/features/steps/lower_requirements.txt @@ -3,7 +3,7 @@ fastapi==0.100.0 fsspec==2021.4 aiofiles==22.1.0 uvicorn[standard]==0.22.0 -watchgod==0.8.2 +watchfiles==0.24.0 plotly==4.8 packaging==23.0 pandas==1.3; python_version < '3.10' @@ -16,3 +16,4 @@ secure==0.3.0 # numpy 2.0 breaks with old versions of pandas and this # could be removed when the lowest version supported is updated numpy==1.26.4 +pathspec==0.12.1 diff --git a/package/kedro_viz/autoreload_file_filter.py b/package/kedro_viz/autoreload_file_filter.py new file mode 100644 index 0000000000..f8b13c6237 --- /dev/null +++ b/package/kedro_viz/autoreload_file_filter.py @@ -0,0 +1,88 @@ +""" +This module provides a custom file filter for autoreloading that filters out files based on allowed +file extensions and patterns specified in a .gitignore file. +""" + +import logging +from pathlib import Path +from typing import Optional, Set + +from pathspec import GitIgnoreSpec +from watchfiles import Change, DefaultFilter + +logger = logging.getLogger(__name__) + + +class AutoreloadFileFilter(DefaultFilter): + """ + Custom file filter for autoreloading that extends DefaultFilter. + Filters out files based on allowed file extensions and patterns specified in a .gitignore file. + """ + + allowed_extensions: Set[str] = {".py", ".yml", ".yaml", ".json"} + + def __init__(self, base_path: Optional[Path] = None): + """ + Initialize the AutoreloadFileFilter. + + Args: + base_path (Optional[Path]): The base path to set as the current working directory + for the filter. + """ + self.cwd = base_path or Path.cwd() + + # Call the superclass constructor + super().__init__() + + # Load .gitignore patterns + gitignore_path = self.cwd / ".gitignore" + try: + with open(gitignore_path, "r", encoding="utf-8") as gitignore_file: + ignore_patterns = gitignore_file.read().splitlines() + self.gitignore_spec: Optional[GitIgnoreSpec] = GitIgnoreSpec.from_lines( + "gitwildmatch", ignore_patterns + ) + except FileNotFoundError: + self.gitignore_spec = None + + def __call__(self, change: Change, path: str) -> bool: + """ + Determine whether a file change should be processed. + + Args: + change (Change): The type of change detected. + path (str): The path to the file that changed. + + Returns: + bool: True if the file should be processed, False otherwise. + """ + if not super().__call__(change, path): + logger.debug("Filtered out by DefaultFilter: %s", path) + return False + + path_obj = Path(path) + + # Exclude files matching .gitignore patterns + try: + relative_path = path_obj.resolve().relative_to(self.cwd.resolve()) + except ValueError: + logger.debug("Path not relative to CWD: %s", path) + return False + + try: + if self.gitignore_spec and self.gitignore_spec.match_file( + str(relative_path) + ): + logger.debug("Filtered out by .gitignore: %s", relative_path) + return False + # ruff: noqa: BLE001 + except Exception as exc: + logger.debug("Exception during .gitignore matching: %s", exc) + return True # Pass the file if .gitignore matching fails + + # Include only files with allowed extensions + if path_obj.suffix in self.allowed_extensions: + logger.debug("Allowed file: %s", path) + return True + logger.debug("Filtered out by allowed_extensions: %s", path_obj.suffix) + return False diff --git a/package/kedro_viz/launchers/cli/run.py b/package/kedro_viz/launchers/cli/run.py index e7dd08b408..b2e74a48be 100644 --- a/package/kedro_viz/launchers/cli/run.py +++ b/package/kedro_viz/launchers/cli/run.py @@ -7,6 +7,7 @@ from kedro.framework.cli.project import PARAMS_ARG_HELP from kedro.framework.cli.utils import _split_params +from kedro_viz.autoreload_file_filter import AutoreloadFileFilter from kedro_viz.constants import DEFAULT_HOST, DEFAULT_PORT from kedro_viz.launchers.cli.main import viz @@ -162,21 +163,25 @@ def run( "extra_params": params, "is_lite": lite, } + + process_context = multiprocessing.get_context("spawn") if autoreload: - from watchgod import RegExpWatcher, run_process + from watchfiles import run_process + run_process_args = [str(kedro_project_path)] run_process_kwargs = { - "path": kedro_project_path, "target": run_server, "kwargs": run_server_kwargs, - "watcher_cls": RegExpWatcher, - "watcher_kwargs": {"re_files": r"^.*(\.yml|\.yaml|\.py|\.json)$"}, + "watch_filter": AutoreloadFileFilter(), } - viz_process = multiprocessing.Process( - target=run_process, daemon=False, kwargs={**run_process_kwargs} + viz_process = process_context.Process( + target=run_process, + daemon=False, + args=run_process_args, + kwargs={**run_process_kwargs}, ) else: - viz_process = multiprocessing.Process( + viz_process = process_context.Process( target=run_server, daemon=False, kwargs={**run_server_kwargs} ) diff --git a/package/kedro_viz/launchers/jupyter.py b/package/kedro_viz/launchers/jupyter.py index 22af9fb99a..cd39610ab3 100644 --- a/package/kedro_viz/launchers/jupyter.py +++ b/package/kedro_viz/launchers/jupyter.py @@ -14,8 +14,9 @@ import IPython from IPython.display import HTML, display from kedro.framework.project import PACKAGE_NAME -from watchgod import RegExpWatcher, run_process +from watchfiles import run_process +from kedro_viz.autoreload_file_filter import AutoreloadFileFilter from kedro_viz.launchers.utils import _check_viz_up, _wait_for from kedro_viz.server import DEFAULT_HOST, DEFAULT_PORT, run_server @@ -146,15 +147,17 @@ def run_viz(args: str = "", local_ns: Dict[str, Any] = None) -> None: } process_context = multiprocessing.get_context("spawn") if autoreload: + run_process_args = [str(project_path)] run_process_kwargs = { - "path": project_path, "target": run_server, "kwargs": run_server_kwargs, - "watcher_cls": RegExpWatcher, - "watcher_kwargs": {"re_files": r"^.*(\.yml|\.yaml|\.py|\.json)$"}, + "watch_filter": AutoreloadFileFilter(), } viz_process = process_context.Process( - target=run_process, daemon=False, kwargs={**run_process_kwargs} + target=run_process, + daemon=False, + args=run_process_args, + kwargs={**run_process_kwargs}, ) else: viz_process = process_context.Process( diff --git a/package/kedro_viz/server.py b/package/kedro_viz/server.py index 251bb32b6b..db95289b6d 100644 --- a/package/kedro_viz/server.py +++ b/package/kedro_viz/server.py @@ -8,6 +8,7 @@ from kedro.io import DataCatalog from kedro.pipeline import Pipeline +from kedro_viz.autoreload_file_filter import AutoreloadFileFilter from kedro_viz.constants import DEFAULT_HOST, DEFAULT_PORT from kedro_viz.data_access import DataAccessManager, data_access_manager from kedro_viz.database import make_db_session_factory @@ -143,7 +144,7 @@ def run_server( import argparse import multiprocessing - from watchgod import RegExpWatcher, run_process + from watchfiles import run_process parser = argparse.ArgumentParser(description="Launch a development viz server") parser.add_argument("project_path", help="Path to a Kedro project") @@ -157,20 +158,24 @@ def run_server( project_path = (Path.cwd() / args.project_path).absolute() + run_process_args = [str(project_path)] run_process_kwargs = { - "path": project_path, "target": run_server, "kwargs": { "host": args.host, "port": args.port, "project_path": str(project_path), }, - "watcher_cls": RegExpWatcher, - "watcher_kwargs": {"re_files": r"^.*(\.yml|\.yaml|\.py|\.json)$"}, + "watch_filter": AutoreloadFileFilter(), } - viz_process = multiprocessing.Process( - target=run_process, daemon=False, kwargs={**run_process_kwargs} + process_context = multiprocessing.get_context("spawn") + + viz_process = process_context.Process( + target=run_process, + daemon=False, + args=run_process_args, + kwargs={**run_process_kwargs}, ) display_cli_message("Starting Kedro Viz ...", "green") diff --git a/package/requirements.txt b/package/requirements.txt index caf3fa63ea..16c7890f5d 100644 --- a/package/requirements.txt +++ b/package/requirements.txt @@ -15,4 +15,5 @@ secure>=0.3.0 sqlalchemy>=1.4, <3 strawberry-graphql>=0.192.0, <1.0 uvicorn[standard]>=0.30.0, <1.0 -watchgod>=0.8.2, <1.0 +watchfiles>=0.24.0 +pathspec>=0.12.1 \ No newline at end of file diff --git a/package/test_requirements.txt b/package/test_requirements.txt index 3260a24806..c2ac8e7c78 100644 --- a/package/test_requirements.txt +++ b/package/test_requirements.txt @@ -19,6 +19,7 @@ sqlalchemy-stubs~=0.4 strawberry-graphql[cli]>=0.99.0, <1.0 trufflehog~=2.2 httpx~=0.27.0 +pathspec>=0.12.1 # mypy types-aiofiles==0.1.3 diff --git a/package/tests/test_autoreload_file_filter.py b/package/tests/test_autoreload_file_filter.py new file mode 100644 index 0000000000..d5c9fb2ff7 --- /dev/null +++ b/package/tests/test_autoreload_file_filter.py @@ -0,0 +1,141 @@ +import logging +import shutil +import tempfile +from pathlib import Path +from unittest.mock import patch + +import pytest +from watchfiles import Change, DefaultFilter + +from kedro_viz.autoreload_file_filter import AutoreloadFileFilter + +logger = logging.getLogger(__name__) + + +@pytest.fixture +def file_filter(tmp_path): + """ + Fixture to create a temporary .gitignore file and initialize the AutoreloadFileFilter + with the test directory as the base path. + """ + # Create a .gitignore file + gitignore_path = tmp_path / ".gitignore" + gitignore_path.write_text("ignored.py\n") + + # Initialize the filter with the test directory as base_path + return AutoreloadFileFilter(base_path=tmp_path) + + +def test_no_gitignore(tmp_path): + """ + Test that a file passes the filter when the .gitignore file is missing. + """ + gitignored_file = tmp_path / "ignored.py" + gitignored_file.touch() + + # Initialize the filter without a .gitignore file + gitignore_path = tmp_path / ".gitignore" + if gitignore_path.exists(): + gitignore_path.unlink() + file_filter = AutoreloadFileFilter(base_path=tmp_path) + + result = file_filter(Change.modified, str(gitignored_file)) + assert result, "File should pass the filter when .gitignore is missing" + + +def test_gitignore_exception(file_filter, tmp_path): + """ + Test that a file passes the filter if an exception occurs during .gitignore matching. + """ + allowed_file = tmp_path / "test.py" + allowed_file.touch() + + with patch( + "pathspec.PathSpec.match_file", side_effect=Exception("Mocked exception") + ): + result = file_filter(Change.modified, str(allowed_file)) + assert result, "Filter should pass the file if .gitignore matching fails" + + +def test_allowed_file(file_filter, tmp_path): + """ + Test that a file with an allowed extension passes the filter. + """ + allowed_file = tmp_path / "test.py" + allowed_file.touch() + + result = file_filter(Change.modified, str(allowed_file)) + assert result, "Allowed file should pass the filter" + + +def test_disallowed_file(file_filter, tmp_path): + """ + Test that a file with a disallowed extension does not pass the filter. + """ + disallowed_file = tmp_path / "test.txt" + disallowed_file.touch() + + result = file_filter(Change.modified, str(disallowed_file)) + assert not result, "Disallowed file should not pass the filter" + + +def test_gitignored_file(file_filter, tmp_path): + """ + Test that a file listed in the .gitignore file does not pass the filter. + """ + gitignored_file = tmp_path / "ignored.py" + gitignored_file.touch() + + result = file_filter(Change.modified, str(gitignored_file)) + assert not result, "Gitignored file should not pass the filter" + + +def test_non_relative_path(file_filter): + """ + Test that a file outside the current working directory does not pass the filter. + """ + original_cwd = Path.cwd().parent # Go up one directory + outside_file = original_cwd / "outside.py" + outside_file.touch() + + result = file_filter(Change.modified, str(outside_file)) + assert not result, "File outside the CWD should not pass the filter" + + # Cleanup + outside_file.unlink() + + +def test_no_allowed_extension(file_filter, tmp_path): + """ + Test that a file without an allowed extension does not pass the filter. + """ + no_extension_file = tmp_path / "no_extension" + no_extension_file.touch() + + result = file_filter(Change.modified, str(no_extension_file)) + assert not result, "File without allowed extension should not pass the filter" + + +def test_directory_path(file_filter, tmp_path): + """ + Test that a directory does not pass the filter. + """ + directory_path = tmp_path / "some_directory" + directory_path.mkdir() + + result = file_filter(Change.modified, str(directory_path)) + assert not result, "Directories should not pass the filter" + + +def test_filtered_out_by_default_filter(file_filter, tmp_path, mocker): + """ + Test that a file is filtered out by the DefaultFilter. + """ + filtered_file = tmp_path / "filtered.py" + filtered_file.touch() + + # Mock the super().__call__ method to return False + mocker.patch.object(DefaultFilter, "__call__", return_value=False) + + result = file_filter(Change.modified, str(filtered_file)) + assert not result, "File should be filtered out by DefaultFilter" diff --git a/package/tests/test_launchers/test_cli/test_run.py b/package/tests/test_launchers/test_cli/test_run.py index b2d5c59b39..86adae92f6 100644 --- a/package/tests/test_launchers/test_cli/test_run.py +++ b/package/tests/test_launchers/test_cli/test_run.py @@ -4,9 +4,10 @@ import requests from click.testing import CliRunner from packaging.version import parse -from watchgod import RegExpWatcher, run_process +from watchfiles import run_process from kedro_viz import __version__ +from kedro_viz.autoreload_file_filter import AutoreloadFileFilter from kedro_viz.launchers.cli import main from kedro_viz.launchers.cli.run import _VIZ_PROCESSES from kedro_viz.launchers.utils import _PYPROJECT @@ -205,7 +206,10 @@ def test_kedro_viz_command_run_server( patched_check_viz_up, patched_start_browser, ): - process_init = mocker.patch("multiprocessing.Process") + mock_process_context = mocker.patch("multiprocessing.get_context") + mock_context_instance = mocker.Mock() + mock_process_context.return_value = mock_context_instance + mock_process = mocker.patch.object(mock_context_instance, "Process") runner = CliRunner() # Reduce the timeout argument from 600 to 1 to make test run faster. @@ -222,7 +226,7 @@ def test_kedro_viz_command_run_server( with runner.isolated_filesystem(): runner.invoke(main.viz_cli, command_options) - process_init.assert_called_once_with( + mock_process.assert_called_once_with( target=run_server, daemon=False, kwargs={**run_server_args} ) @@ -340,9 +344,15 @@ def test_kedro_viz_command_should_not_log_if_pypi_is_down( mock_click_echo.assert_has_calls(mock_click_echo_calls) def test_kedro_viz_command_with_autoreload( - self, mocker, mock_project_path, patched_check_viz_up, patched_start_browser + self, mocker, tmp_path, patched_check_viz_up, patched_start_browser ): - process_init = mocker.patch("multiprocessing.Process") + mock_process_context = mocker.patch("multiprocessing.get_context") + mock_context_instance = mocker.Mock() + mock_process_context.return_value = mock_context_instance + mock_process = mocker.patch.object(mock_context_instance, "Process") + mock_tmp_path = tmp_path / "tmp" + mock_tmp_path.mkdir() + mock_path = mock_tmp_path / "project_path" # Reduce the timeout argument from 600 to 1 to make test run faster. mocker.patch( @@ -351,14 +361,14 @@ def test_kedro_viz_command_with_autoreload( # Mock finding kedro project mocker.patch( "kedro_viz.launchers.utils._find_kedro_project", - return_value=mock_project_path, + return_value=mock_path, ) runner = CliRunner() with runner.isolated_filesystem(): runner.invoke(main.viz_cli, ["viz", "run", "--autoreload"]) + run_process_args = [str(mock_path)] run_process_kwargs = { - "path": mock_project_path, "target": run_server, "kwargs": { "host": "127.0.0.1", @@ -367,18 +377,20 @@ def test_kedro_viz_command_with_autoreload( "save_file": None, "pipeline_name": None, "env": None, + "project_path": mock_path, "autoreload": True, - "project_path": mock_project_path, "include_hooks": False, "package_name": None, "extra_params": {}, "is_lite": False, }, - "watcher_cls": RegExpWatcher, - "watcher_kwargs": {"re_files": "^.*(\\.yml|\\.yaml|\\.py|\\.json)$"}, + "watch_filter": mocker.ANY, } - process_init.assert_called_once_with( - target=run_process, daemon=False, kwargs={**run_process_kwargs} + mock_process.assert_called_once_with( + target=run_process, + daemon=False, + args=run_process_args, + kwargs={**run_process_kwargs}, ) assert run_process_kwargs["kwargs"]["port"] in _VIZ_PROCESSES diff --git a/package/tests/test_launchers/test_jupyter.py b/package/tests/test_launchers/test_jupyter.py index dd489778ca..485e7ff890 100644 --- a/package/tests/test_launchers/test_jupyter.py +++ b/package/tests/test_launchers/test_jupyter.py @@ -140,6 +140,7 @@ def test_run_viz_with_autoreload(self, mocker, patched_check_viz_up): mock_process.assert_called_once_with( target=mocker.ANY, daemon=False, # No daemon for autoreload + args=mocker.ANY, kwargs=mocker.ANY, ) From 02331cc96e7e199e12a33f07da92e82b149d4120 Mon Sep 17 00:00:00 2001 From: rashidakanchwala <37628668+rashidakanchwala@users.noreply.github.com> Date: Tue, 5 Nov 2024 09:25:47 +0000 Subject: [PATCH 21/35] Docs Fix on `--save-file` functionality (#2173) Resolves #1681 --- docs/source/kedro-viz_visualisation.md | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/docs/source/kedro-viz_visualisation.md b/docs/source/kedro-viz_visualisation.md index db244697ea..04556d7e4f 100644 --- a/docs/source/kedro-viz_visualisation.md +++ b/docs/source/kedro-viz_visualisation.md @@ -196,21 +196,25 @@ The visualisation now includes the layers: ## Share a pipeline visualisation -You can share a pipeline structure within a Kedro-Viz visualisation as a JSON file from the terminal: +You can save a pipeline structure within a Kedro-Viz visualisation directly from the terminal as follows: ```bash kedro viz run --save-file=my_shareable_pipeline ``` -This command will save a visualisation of the `__default__` pipeline as a JSON file called `my_shareable_pipeline.json`. It doesn't share data, such as that in the code panel, nor can you share images or charts. +This command saves your visualisation in a `my_shareable_pipeline` folder, which contains all pipeline and node information from your Kedro project. -To visualise the shared file, type the following to load it from the terminal: +To visualise your saved Kedro-Viz, load the `my_shareable_pipeline` folder from the terminal with: ```bash kedro viz run --load-file=my_shareable_pipeline ``` -You can also share a complete project visualisation, described in more detail on [the following page](./share_kedro_viz). +```{note} +This way of sharing requires a Kedro environment setup. + +For users who prefer not to set up a Kedro environment, [Kedro-Viz visualisations can also be shared via multiple hosting solutions](./share_kedro_viz). +``` ## Running Kedro-viz in a notebook. From 2f46dc13204c09954f99500fdca18745a93278ba Mon Sep 17 00:00:00 2001 From: Sajid Alam <90610031+SajidAlamQB@users.noreply.github.com> Date: Fri, 8 Nov 2024 09:26:01 +0000 Subject: [PATCH 22/35] Fix `Unable to get file size` when dataset has no protocol attribute (#2174) * add protocol check Signed-off-by: Sajid Alam * add test Signed-off-by: Sajid Alam * lint Signed-off-by: Sajid Alam * Update hooks.py Signed-off-by: Sajid Alam * add fallback to private attritbute for known datasets Signed-off-by: Sajid Alam * coverage Signed-off-by: Sajid Alam * Update RELEASE.md Signed-off-by: Sajid Alam * Update hooks.py Signed-off-by: Sajid Alam * coverage Signed-off-by: Sajid Alam * Update RELEASE.md Signed-off-by: Sajid Alam --------- Signed-off-by: Sajid Alam --- RELEASE.md | 1 + package/kedro_viz/integrations/kedro/hooks.py | 28 ++++++++----- package/tests/test_integrations/test_hooks.py | 41 +++++++++++++++++++ 3 files changed, 60 insertions(+), 10 deletions(-) diff --git a/RELEASE.md b/RELEASE.md index 3261588f52..5be798e332 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -21,6 +21,7 @@ Please follow the established format: - Display full dataset type with library prefix in metadata panel (#2136) - Enable SQLite WAL mode for Azure ML to fix database locking issues (#2131) - Replace `flake8`, `isort`, `pylint` and `black` by `ruff` (#2149) +- Refactor `DatasetStatsHook` to avoid showing error when dataset doesn't have file size info (#2174) # Release 10.0.0 diff --git a/package/kedro_viz/integrations/kedro/hooks.py b/package/kedro_viz/integrations/kedro/hooks.py index 3089e61f50..88bc5be17a 100644 --- a/package/kedro_viz/integrations/kedro/hooks.py +++ b/package/kedro_viz/integrations/kedro/hooks.py @@ -7,6 +7,7 @@ from pathlib import Path, PurePosixPath from typing import Any, Union +import fsspec from kedro.framework.hooks import hook_impl from kedro.io import DataCatalog from kedro.io.core import get_filepath_str @@ -141,19 +142,26 @@ def get_file_size(self, dataset: Any) -> Union[int, None]: Args: dataset: A dataset instance for which we need the file size - Returns: file size for the dataset if file_path is valid, if not returns None + Returns: + File size for the dataset if available, otherwise None. """ - - if not (hasattr(dataset, "_filepath") and dataset._filepath): - return None - try: - file_path = get_filepath_str( - PurePosixPath(dataset._filepath), dataset._protocol - ) - return dataset._fs.size(file_path) + if hasattr(dataset, "filepath") and dataset.filepath: + filepath = dataset.filepath + # Fallback to private '_filepath' for known datasets + elif hasattr(dataset, "_filepath") and dataset._filepath: + filepath = dataset._filepath + else: + return None + + fs, path_in_fs = fsspec.core.url_to_fs(filepath) + if fs.exists(path_in_fs): + file_size = fs.size(path_in_fs) + return file_size + else: + return None - except Exception as exc: + except Exception as exc: # pragma: no cover logger.warning( "Unable to get file size for the dataset %s: %s", dataset, exc ) diff --git a/package/tests/test_integrations/test_hooks.py b/package/tests/test_integrations/test_hooks.py index 2f6d7dd132..600c594d15 100644 --- a/package/tests/test_integrations/test_hooks.py +++ b/package/tests/test_integrations/test_hooks.py @@ -137,3 +137,44 @@ def test_get_file_size(dataset, example_dataset_stats_hook_obj, example_csv_data assert example_dataset_stats_hook_obj.get_file_size( example_csv_dataset ) == example_csv_dataset._fs.size(file_path) + + +def test_get_file_size_file_does_not_exist(example_dataset_stats_hook_obj, mocker): + class MockDataset: + def __init__(self): + self._filepath = "/non/existent/path.csv" + + mock_dataset = MockDataset() + mock_fs = mocker.Mock() + mock_fs.exists.return_value = False + + mocker.patch( + "fsspec.core.url_to_fs", + return_value=(mock_fs, "/non/existent/path.csv"), + ) + + # Call get_file_size and expect it to return None + file_size = example_dataset_stats_hook_obj.get_file_size(mock_dataset) + assert file_size is None + + +def test_get_file_size_public_filepath(example_dataset_stats_hook_obj, mocker): + class MockDataset: + def __init__(self): + self.filepath = "/path/to/existing/file.csv" + + mock_dataset = MockDataset() + + # Mock fs.exists to return True + mock_fs = mocker.Mock() + mock_fs.exists.return_value = True + mock_fs.size.return_value = 456 + + mocker.patch( + "fsspec.core.url_to_fs", + return_value=(mock_fs, "/path/to/existing/file.csv"), + ) + + # Call get_file_size and expect it to return the mocked file size + file_size = example_dataset_stats_hook_obj.get_file_size(mock_dataset) + assert file_size == 456 From ccfd9cd1b69082b4299eed636f8e0b3ea64d34a1 Mon Sep 17 00:00:00 2001 From: Merel Theisen <49397448+merelcht@users.noreply.github.com> Date: Fri, 8 Nov 2024 11:25:27 +0100 Subject: [PATCH 23/35] Add Wizard GH actions to automate labelling and closing (#2180) * Add Wizard GH actions to automate labelling and closing Signed-off-by: Merel Theisen --- .github/workflows/label-community-issues.yml | 48 ++++++++++++++++++++ .github/workflows/no-response.yml | 20 ++++++++ 2 files changed, 68 insertions(+) create mode 100644 .github/workflows/label-community-issues.yml create mode 100644 .github/workflows/no-response.yml diff --git a/.github/workflows/label-community-issues.yml b/.github/workflows/label-community-issues.yml new file mode 100644 index 0000000000..e1c1ce1180 --- /dev/null +++ b/.github/workflows/label-community-issues.yml @@ -0,0 +1,48 @@ +name: Label Community Issues + +on: + issues: + types: + - opened + +jobs: + label: + runs-on: ubuntu-latest + steps: + - name: Check if issue author is a member of Kedro org + uses: actions/github-script@v6 + id: membership + with: + github-token: ${{ secrets.GH_TAGGING_TOKEN }} + result-encoding: string + script: | + + try { + const result = await github.rest.orgs.getMembershipForUser({ + org: "kedro-org", + username: '${{ github.actor }}' + }) + + console.log(result?.data?.state) + if (result?.data?.state == "active"){ + console.log("%s: detected as an active member of Kedro org", '${{ github.actor }}') + return "member"; + } else { + console.log("%s: not detected as active member of Kedro org", '${{ github.actor }}') + return "notMember"; + } + + } catch (error) { + console.log("%s: Error occured and marked user as notMember", '${{ github.actor }}') + console.log("Error", error.stack); + console.log("Error", error.name); + console.log("Error", error.message); + return "notMember"; + } + + - name: Label issue if author is from community + if: ${{ steps.membership.outputs.result == 'notMember' }} + uses: actions-ecosystem/action-add-labels@v1 + with: + github_token: ${{ secrets.GH_TAGGING_TOKEN }} + labels: 'Community' diff --git a/.github/workflows/no-response.yml b/.github/workflows/no-response.yml new file mode 100644 index 0000000000..b11c9be736 --- /dev/null +++ b/.github/workflows/no-response.yml @@ -0,0 +1,20 @@ +name: No Response + +on: + issue_comment: + types: [created] + schedule: + # Run every day at 9am (UTC time) + - cron: '0 9 * * *' + +jobs: + noResponse: + runs-on: ubuntu-latest + steps: + - uses: lee-dohm/no-response@v0.5.0 + with: + token: ${{ secrets.GITHUB_TOKEN }} + responseRequiredLabel: "support: needs more info" + daysUntilClose: 28 + closeComment: >- + This issue has been closed due to lack of information. Feel free to re-open this issue if you're facing a similar problem. Please provide as much information as possible so we can help resolve your issue. From 11f16087ebc6729e1112880975145f7f2c0fbade Mon Sep 17 00:00:00 2001 From: Sajid Alam <90610031+SajidAlamQB@users.noreply.github.com> Date: Fri, 8 Nov 2024 12:43:32 +0000 Subject: [PATCH 24/35] Add port in use check to prevent browser redirecting incorrectly for kedro viz (#2176) * add check if port is in use Signed-off-by: Sajid Alam * Update RELEASE.md Signed-off-by: Sajid Alam * automatically increment port Signed-off-by: Sajid Alam * fix unit tests Signed-off-by: Sajid Alam * add port occupied test Signed-off-by: Sajid Alam * coverage Signed-off-by: Sajid Alam * speed up test by mocking _is_port_in_use to return false Signed-off-by: Sajid Alam --------- Signed-off-by: Sajid Alam Signed-off-by: Sajid Alam <90610031+SajidAlamQB@users.noreply.github.com> --- RELEASE.md | 1 + package/kedro_viz/launchers/cli/run.py | 5 +++- package/kedro_viz/launchers/utils.py | 29 +++++++++++++++++++ .../tests/test_launchers/test_cli/test_run.py | 19 +++++++++++- package/tests/test_launchers/test_utils.py | 18 ++++++++++++ 5 files changed, 70 insertions(+), 2 deletions(-) diff --git a/RELEASE.md b/RELEASE.md index 5be798e332..e7e50b88dc 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -22,6 +22,7 @@ Please follow the established format: - Enable SQLite WAL mode for Azure ML to fix database locking issues (#2131) - Replace `flake8`, `isort`, `pylint` and `black` by `ruff` (#2149) - Refactor `DatasetStatsHook` to avoid showing error when dataset doesn't have file size info (#2174) +- Add check for port availability before starting Kedro Viz to prevent unintended browser redirects when the port is already in use (#2176) # Release 10.0.0 diff --git a/package/kedro_viz/launchers/cli/run.py b/package/kedro_viz/launchers/cli/run.py index b2e74a48be..e4093b940f 100644 --- a/package/kedro_viz/launchers/cli/run.py +++ b/package/kedro_viz/launchers/cli/run.py @@ -115,6 +115,7 @@ def run( from kedro_viz.launchers.utils import ( _PYPROJECT, _check_viz_up, + _find_available_port, _find_kedro_project, _start_browser, _wait_for, @@ -145,6 +146,9 @@ def run( "https://github.com/kedro-org/kedro-viz/releases.", "yellow", ) + + port = _find_available_port(host, port) + try: if port in _VIZ_PROCESSES and _VIZ_PROCESSES[port].is_alive(): _VIZ_PROCESSES[port].terminate() @@ -186,7 +190,6 @@ def run( ) display_cli_message("Starting Kedro Viz ...", "green") - viz_process.start() _VIZ_PROCESSES[port] = viz_process diff --git a/package/kedro_viz/launchers/utils.py b/package/kedro_viz/launchers/utils.py index 5c6bbae9e3..50f8e6e849 100644 --- a/package/kedro_viz/launchers/utils.py +++ b/package/kedro_viz/launchers/utils.py @@ -2,6 +2,8 @@ used in the `kedro_viz.launchers` package.""" import logging +import socket +import sys import webbrowser from pathlib import Path from time import sleep, time @@ -80,6 +82,33 @@ def _check_viz_up(host: str, port: int): return response.status_code == 200 +def _is_port_in_use(host: str, port: int): + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + return s.connect_ex((host, port)) == 0 + + +def _find_available_port(host: str, start_port: int, max_attempts: int = 5) -> int: + max_port = start_port + max_attempts - 1 + port = start_port + while port <= max_port: + if not _is_port_in_use(host, port): + return port + display_cli_message( + f"Port {port} is already in use. Trying the next port...", + "yellow", + ) + port += 1 + display_cli_message( + f"Error: All ports in the range {start_port}-{max_port} are in use.", + "red", + ) + display_cli_message( + "Please specify a different port using the '--port' option.", + "red", + ) + sys.exit(1) + + def _is_localhost(host: str) -> bool: """Check whether a host is a localhost""" return host in ("127.0.0.1", "localhost", "0.0.0.0") diff --git a/package/tests/test_launchers/test_cli/test_run.py b/package/tests/test_launchers/test_cli/test_run.py index 86adae92f6..95a809d2ed 100644 --- a/package/tests/test_launchers/test_cli/test_run.py +++ b/package/tests/test_launchers/test_cli/test_run.py @@ -10,7 +10,7 @@ from kedro_viz.autoreload_file_filter import AutoreloadFileFilter from kedro_viz.launchers.cli import main from kedro_viz.launchers.cli.run import _VIZ_PROCESSES -from kedro_viz.launchers.utils import _PYPROJECT +from kedro_viz.launchers.utils import _PYPROJECT, _find_available_port from kedro_viz.server import run_server @@ -217,6 +217,9 @@ def test_kedro_viz_command_run_server( "kedro_viz.launchers.utils._wait_for.__defaults__", (True, 1, True, 1) ) + # Mock _is_port_in_use to speed up test. + mocker.patch("kedro_viz.launchers.utils._is_port_in_use", return_value=False) + # Mock finding kedro project mocker.patch( "kedro_viz.launchers.utils._find_kedro_project", @@ -394,3 +397,17 @@ def test_kedro_viz_command_with_autoreload( kwargs={**run_process_kwargs}, ) assert run_process_kwargs["kwargs"]["port"] in _VIZ_PROCESSES + + # Test case to simulate port occupation and check available port selection + def test_find_available_port_with_occupied_ports(self, mocker): + mock_is_port_in_use = mocker.patch("kedro_viz.launchers.utils._is_port_in_use") + + # Mock ports 4141, 4142 being occupied and 4143 is free + mock_is_port_in_use.side_effect = [True, True, False] + + available_port = _find_available_port("127.0.0.1", 4141) + + # Assert that the function returns the first free port, 4143 + assert ( + available_port == 4143 + ), "Expected port 4143 to be returned as the available port" diff --git a/package/tests/test_launchers/test_utils.py b/package/tests/test_launchers/test_utils.py index 83e9203bd3..fd2043af75 100644 --- a/package/tests/test_launchers/test_utils.py +++ b/package/tests/test_launchers/test_utils.py @@ -7,6 +7,7 @@ from kedro_viz.launchers.utils import ( _check_viz_up, + _find_available_port, _find_kedro_project, _is_project, _start_browser, @@ -99,3 +100,20 @@ def test_toml_bad_encoding(self, mocker): def test_find_kedro_project(project_dir, is_project_found, expected, mocker): mocker.patch("kedro_viz.launchers.utils._is_project", return_value=is_project_found) assert _find_kedro_project(Path(project_dir)) == expected + + +def test_find_available_port_all_ports_occupied(mocker): + mocker.patch("kedro_viz.launchers.utils._is_port_in_use", return_value=True) + mock_display_message = mocker.patch("kedro_viz.launchers.utils.display_cli_message") + + # Check for SystemExit when all ports are occupied + with pytest.raises(SystemExit) as exit_exception: + _find_available_port("127.0.0.1", 4141, max_attempts=5) + assert exit_exception.value.code == 1 + + mock_display_message.assert_any_call( + "Error: All ports in the range 4141-4145 are in use.", "red" + ) + mock_display_message.assert_any_call( + "Please specify a different port using the '--port' option.", "red" + ) From 852e1e1f68655c114901daeb09d477774f0f9214 Mon Sep 17 00:00:00 2001 From: Jitendra Gundaniya <38945204+jitu5@users.noreply.github.com> Date: Mon, 11 Nov 2024 14:11:22 +0000 Subject: [PATCH 25/35] Handle Github pages 404 issue (#2179) * Handle Github pages 404 issue Signed-off-by: Jitendra Gundaniya * Release note added Signed-off-by: Jitendra Gundaniya * Handling other faulty urls Signed-off-by: Jitendra Gundaniya * Handle other faulty urls Signed-off-by: Jitendra Gundaniya * Lint fix Signed-off-by: Jitendra Gundaniya * Redirection logic moved from flowchart-wrapper to wrapper component Signed-off-by: Jitendra Gundaniya * 404.html remove and coping index.html to 404.html used Signed-off-by: Jitendra Gundaniya * Lint fix Signed-off-by: Jitendra Gundaniya --------- Signed-off-by: Jitendra Gundaniya --- RELEASE.md | 1 + package.json | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/RELEASE.md b/RELEASE.md index e7e50b88dc..61cb49bf9b 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -22,6 +22,7 @@ Please follow the established format: - Enable SQLite WAL mode for Azure ML to fix database locking issues (#2131) - Replace `flake8`, `isort`, `pylint` and `black` by `ruff` (#2149) - Refactor `DatasetStatsHook` to avoid showing error when dataset doesn't have file size info (#2174) +- Fix 404 error when accessing the experiment tracking page on the demo site (#2179) - Add check for port availability before starting Kedro Viz to prevent unintended browser redirects when the port is already in use (#2176) diff --git a/package.json b/package.json index 5883aa9594..6ad5e32dce 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ }, "proxy": "http://localhost:4142/", "scripts": { - "build": "cross-env GENERATE_SOURCEMAP=false react-scripts build", + "build": "cross-env GENERATE_SOURCEMAP=false react-scripts build && cp ./build/index.html ./build/404.html", "postbuild": "rm -rf build/api", "start": "REACT_APP_DATA_SOURCE=$DATA NODE_OPTIONS=\"--dns-result-order=ipv4first\" npm-run-all -p start:app start:lib", "start:dev": "rm -rf node_modules/.cache && npm start", From 49c91836166e60e5863cff1b5382f4b6ed1324c6 Mon Sep 17 00:00:00 2001 From: Sajid Alam <90610031+SajidAlamQB@users.noreply.github.com> Date: Tue, 12 Nov 2024 14:27:21 +0000 Subject: [PATCH 26/35] Update Kedro-viz architecture diagram (#2183) * update architecture diagram and add link to full diagram Signed-off-by: Sajid Alam * update based on reviews Signed-off-by: Sajid Alam * Update ARCHITECTURE.md Signed-off-by: Sajid Alam --------- Signed-off-by: Sajid Alam --- .github/img/backend-architecture.png | Bin 0 -> 141908 bytes .github/img/frontend-architecture.png | Bin 0 -> 67996 bytes ARCHITECTURE.md | 9 ++++++++- 3 files changed, 8 insertions(+), 1 deletion(-) create mode 100644 .github/img/backend-architecture.png create mode 100644 .github/img/frontend-architecture.png diff --git a/.github/img/backend-architecture.png b/.github/img/backend-architecture.png new file mode 100644 index 0000000000000000000000000000000000000000..3451638ab1f773f1086329f316dbc38678ed04ad GIT binary patch literal 141908 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 Date: Thu, 14 Nov 2024 05:43:16 -0500 Subject: [PATCH 27/35] Add a tip on YAML anchors to docs on dataset layers (#2181) --- docs/source/kedro-viz_visualisation.md | 37 ++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/docs/source/kedro-viz_visualisation.md b/docs/source/kedro-viz_visualisation.md index 04556d7e4f..de4509e5b7 100644 --- a/docs/source/kedro-viz_visualisation.md +++ b/docs/source/kedro-viz_visualisation.md @@ -194,6 +194,43 @@ The visualisation now includes the layers: ![](./images/pipeline_visualisation_with_layers.png) +Duplicated definitions like: + +```yaml +metadata: + kedro-viz: + layer: raw +``` + +can be avoided by leveraging YAML native syntax for anchors and aliases. + +Use an anchor (`&`) first, to create a reusable piece of configuration: + +```yaml +_raw_layer: &raw_layer + metadata: + kedro-viz: + layer: 01_raw +``` + +And then use aliases (`*`) to reference it: + +```yaml +companies: + type: pandas.CSVDataset + filepath: data/01_raw/companies.csv + <<: *raw_layer + +reviews: + type: pandas.CSVDataset + filepath: data/01_raw/reviews.csv + <<: *raw_layer + +# Same for other datasets of the raw layer... +``` + +See [this example from the Kedro docs](https://docs.kedro.org/en/stable/data/data_catalog_yaml_examples.html#load-multiple-datasets-with-similar-configuration-using-yaml-anchors) for more details. + ## Share a pipeline visualisation You can save a pipeline structure within a Kedro-Viz visualisation directly from the terminal as follows: From e8beb315f31a364cb6502da219d981ea06e1e864 Mon Sep 17 00:00:00 2001 From: rashidakanchwala <37628668+rashidakanchwala@users.noreply.github.com> Date: Mon, 18 Nov 2024 10:53:34 +0000 Subject: [PATCH 28/35] Ensure deterministic graph calculation with consistent layer, node, and edge ordering in Kedro-Viz (#2185) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves #2057. Based on @astrojuanlu's comment, I realized that Kedro-Viz might be causing the randomness. To investigate, I checked if the inputs to graph calculations—nodes, edges, and layout—were consistent. While the order of nodes and layout remained stable, the order of edges varied with each backend data request. To address this, we now sort the layers, the edges and nodes to ensure consistency. Update - As @ravi-kumar-pilla noted, there was an issue with layer ordering: changing a node name could alter the layer order, especially for layers with identical dependencies, like model_input and feature in the example he shared. This issue stemmed from the layer_dependencies dictionary in services/layers.py, where layers with the same dependencies weren’t consistently ordered. To fix this, I added alphabetical sorting for layers with identical dependencies to ensure stability in toposort. For nodes and edges, I now sort them immediately upon loading from the backend API, ensuring they are consistently ordered in the Redux state. For testing, I initially considered using Cypress/backend e2e testing with screenshot comparison of the flowchart, but it proved too complex. Instead, I created a new mock dataset, reordered_spaceflights, with reordered nodes and edges. I added tests in normalise-data-test and actions/graph-test. The first test verifies that nodes and edges are consistently sorted by their ids, regardless of backend order. The second test compares the x and y coordinates in the flowchart, confirming that the graph layout is the same between the two mocks. --- package/kedro_viz/services/layers.py | 5 + package/tests/test_services/test_layers.py | 28 +- src/actions/graph.test.js | 78 ++++- src/selectors/sliced-pipeline.test.js | 2 +- src/store/normalize-data.js | 10 + src/store/normalize-data.test.js | 17 + .../data/spaceflights_reordered.mock.json | 311 ++++++++++++++++++ src/utils/state.mock.js | 2 + 8 files changed, 434 insertions(+), 19 deletions(-) create mode 100644 src/utils/data/spaceflights_reordered.mock.json diff --git a/package/kedro_viz/services/layers.py b/package/kedro_viz/services/layers.py index 7cba369aa1..b7a46dd789 100644 --- a/package/kedro_viz/services/layers.py +++ b/package/kedro_viz/services/layers.py @@ -106,6 +106,11 @@ def find_child_layers(node_id: str) -> Set[str]: if layer not in layer_dependencies: layer_dependencies[layer] = set() + # Sort `layer_dependencies` keys for consistent ordering of layers with the same dependencies + layer_dependencies = defaultdict( + set, {k: layer_dependencies[k] for k in sorted(layer_dependencies)} + ) + # Use graphlib.TopologicalSorter to sort the layer dependencies. try: sorter = TopologicalSorter(layer_dependencies) diff --git a/package/tests/test_services/test_layers.py b/package/tests/test_services/test_layers.py index c949a9f98b..358cf8f505 100644 --- a/package/tests/test_services/test_layers.py +++ b/package/tests/test_services/test_layers.py @@ -154,6 +154,32 @@ {"node_1": {}, "node_2": {}}, ["a", "b"], ), + ( + # Case where if two layers e.g. `int` and `primary` layers share the same dependencies, they get sorted alphabetically. + """ + node_1(layer=raw) -> node_3(layer=int) + node_2(layer=raw) -> node_4(layer=primary) + node_3(layer=int) -> node_5(layer=feature) + node_4(layer=primary) -> node_6(layer=feature) + """, + { + "node_1": {"id": "node_1", "layer": "raw"}, + "node_2": {"id": "node_2", "layer": "raw"}, + "node_3": {"id": "node_3", "layer": "int"}, + "node_4": {"id": "node_4", "layer": "primary"}, + "node_5": {"id": "node_5", "layer": "feature"}, + "node_6": {"id": "node_6", "layer": "feature"}, + }, + { + "node_1": {"node_3"}, + "node_2": {"node_4"}, + "node_3": {"node_5"}, + "node_4": {"node_6"}, + "node_5": set(), + "node_6": set(), + }, + ["raw", "int", "primary", "feature"], + ), ], ) def test_sort_layers(graph_schema, nodes, node_dependencies, expected): @@ -170,7 +196,7 @@ def test_sort_layers(graph_schema, nodes, node_dependencies, expected): for node_id, node_dict in nodes.items() } sorted_layers = sort_layers(nodes, node_dependencies) - assert sorted(sorted_layers) == sorted(expected), graph_schema + assert sorted_layers == expected, graph_schema def test_sort_layers_should_return_empty_list_on_cyclic_layers(mocker): diff --git a/src/actions/graph.test.js b/src/actions/graph.test.js index 1ff8df3436..991eb5ad66 100644 --- a/src/actions/graph.test.js +++ b/src/actions/graph.test.js @@ -1,25 +1,37 @@ import { createStore } from 'redux'; import reducer from '../reducers'; -import { mockState } from '../utils/state.mock'; import { calculateGraph, updateGraph } from './graph'; import { getGraphInput } from '../selectors/layout'; +import { prepareState } from '../utils/state.mock'; +import spaceflights from '../utils/data/spaceflights.mock.json'; +import spaceflightsReordered from '../utils/data/spaceflights_reordered.mock.json'; +import { toggleModularPipelinesExpanded } from '../actions/modular-pipelines'; describe('graph actions', () => { + const getMockState = (data) => + prepareState({ + data, + beforeLayoutActions: [ + () => + toggleModularPipelinesExpanded(['data_science', 'data_processing']), + ], + }); + describe('calculateGraph', () => { it('returns updateGraph action if input is falsey', () => { expect(calculateGraph(null)).toEqual(updateGraph(null)); }); it('sets loading to true immediately', () => { - const store = createStore(reducer, mockState.spaceflights); + const store = createStore(reducer, getMockState(spaceflights)); expect(store.getState().loading.graph).not.toBe(true); - calculateGraph(getGraphInput(mockState.spaceflights))(store.dispatch); + calculateGraph(getGraphInput(getMockState(spaceflights)))(store.dispatch); expect(store.getState().loading.graph).toBe(true); }); it('sets loading to false and graph visibility to true after finishing calculation', () => { - const store = createStore(reducer, mockState.spaceflights); - return calculateGraph(getGraphInput(mockState.spaceflights))( + const store = createStore(reducer, getMockState(spaceflights)); + return calculateGraph(getGraphInput(getMockState(spaceflights)))( store.dispatch ).then(() => { const state = store.getState(); @@ -29,19 +41,51 @@ describe('graph actions', () => { }); it('calculates a graph', () => { - const state = Object.assign({}, mockState.spaceflights); - delete state.graph; - const store = createStore(reducer, state); + const initialState = { ...getMockState(spaceflights), graph: {} }; + const store = createStore(reducer, initialState); expect(store.getState().graph).toEqual({}); - return calculateGraph(getGraphInput(state))(store.dispatch).then(() => { - expect(store.getState().graph).toEqual( - expect.objectContaining({ - nodes: expect.any(Array), - edges: expect.any(Array), - size: expect.any(Object), - }) - ); - }); + return calculateGraph(getGraphInput(initialState))(store.dispatch).then( + () => { + expect(store.getState().graph).toEqual( + expect.objectContaining({ + nodes: expect.any(Array), + edges: expect.any(Array), + size: expect.any(Object), + }) + ); + } + ); + }); + + it('compares deterministic flowchart of two differently ordered same projects', () => { + const store1 = createStore(reducer, getMockState(spaceflights)); + const store2 = createStore(reducer, getMockState(spaceflightsReordered)); + + return calculateGraph(getGraphInput(getMockState(spaceflights)))( + store1.dispatch + ) + .then(() => + calculateGraph(getGraphInput(getMockState(spaceflightsReordered)))( + store2.dispatch + ) + ) + .then(() => { + // Get node coordinates for both graphs + const graph1Coords = store1.getState().graph.nodes.map((node) => ({ + id: node.id, + x: node.x, + y: node.y, + })); + + const graph2Coords = store2.getState().graph.nodes.map((node) => ({ + id: node.id, + x: node.x, + y: node.y, + })); + + // Verify coordinates consistency between both graphs + expect(graph1Coords).toEqual(expect.arrayContaining(graph2Coords)); + }); }); }); }); diff --git a/src/selectors/sliced-pipeline.test.js b/src/selectors/sliced-pipeline.test.js index 2a76049b2b..632eb63a9f 100644 --- a/src/selectors/sliced-pipeline.test.js +++ b/src/selectors/sliced-pipeline.test.js @@ -12,13 +12,13 @@ describe('Selectors', () => { const expected = [ '23c94afb', '47b81aa6', + '90ebe5f3', 'daf35ba0', 'c09084f2', '0abef172', 'e5a9ec27', 'b7bb7198', 'f192326a', - '90ebe5f3', 'data_processing', ]; const newState = reducer(mockState.spaceflights, { diff --git a/src/store/normalize-data.js b/src/store/normalize-data.js index 15aaacaa94..b38d4a2166 100644 --- a/src/store/normalize-data.js +++ b/src/store/normalize-data.js @@ -250,6 +250,15 @@ const getNodeTypesFromUrl = (state, typeQueryParams) => { return state; }; +/** + * Sort the edges, nodes in the state object to ensure deterministic graph layout + * @param {Object} state The state object to sort + */ +const sortNodesEdges = (state) => { + state.edge?.ids?.sort((a, b) => a.localeCompare(b)); + state.node?.ids?.sort((a, b) => a.localeCompare(b)); +}; + /** * Updates the state with filters from the URL. * @param {Object} state - State object @@ -331,6 +340,7 @@ const normalizeData = (data, expandAllPipelines) => { data.layers.forEach(addLayer(state)); } + sortNodesEdges(state); const updatedState = updateStateWithFilters(state, data.tags); return updatedState; }; diff --git a/src/store/normalize-data.test.js b/src/store/normalize-data.test.js index fbbc5dddd7..b02e7b95b4 100644 --- a/src/store/normalize-data.test.js +++ b/src/store/normalize-data.test.js @@ -1,5 +1,6 @@ import normalizeData, { createInitialPipelineState } from './normalize-data'; import spaceflights from '../utils/data/spaceflights.mock.json'; +import spaceflightsReordered from '../utils/data/spaceflights_reordered.mock.json'; const initialState = createInitialPipelineState(); @@ -90,4 +91,20 @@ describe('normalizeData', () => { expect(node).toHaveProperty('name'); }); }); + + it('should have identical nodes and edges, in the same order, regardless of the different ordering from the api', () => { + // Normalize both datasets + const initialState = normalizeData(spaceflights, true); + const reorderedState = normalizeData(spaceflightsReordered, true); + + // Compare nodes and edges by converting to JSON for deep equality + // Directly compare specific properties of nodes and edges, ensuring order and content + expect(initialState.node.ids).toEqual(reorderedState.node.ids); + expect(initialState.node.name).toEqual(reorderedState.node.name); + expect(initialState.node.type).toEqual(reorderedState.node.type); + + expect(initialState.edge.ids).toEqual(reorderedState.edge.ids); + expect(initialState.edge.sources).toEqual(reorderedState.edge.sources); + expect(initialState.edge.targets).toEqual(reorderedState.edge.targets); + }); }); diff --git a/src/utils/data/spaceflights_reordered.mock.json b/src/utils/data/spaceflights_reordered.mock.json new file mode 100644 index 0000000000..3dea125c60 --- /dev/null +++ b/src/utils/data/spaceflights_reordered.mock.json @@ -0,0 +1,311 @@ +{ + "nodes": [ + { + "id": "f192326a", + "name": "data_processing.shuttles", + "tags": ["preprocessing"], + "pipelines": ["dp", "__default__"], + "type": "data", + "modular_pipelines": ["data_processing"], + "layer": "raw", + "dataset_type": "pandas.excel_dataset.ExcelDataset" + }, + { + "id": "b7bb7198", + "name": "preprocess_shuttles_node", + "tags": ["preprocessing"], + "pipelines": ["dp", "__default__"], + "type": "task", + "modular_pipelines": ["data_processing"], + "parameters": {} + }, + { + "id": "e5a9ec27", + "name": "data_processing.preprocessed_shuttles", + "tags": ["features", "preprocessing"], + "pipelines": ["dp", "__default__"], + "type": "data", + "modular_pipelines": ["data_processing"], + "layer": "intermediate", + "dataset_type": "pandas.csv_dataset.CSVDataset" + }, + { + "id": "0abef172", + "name": "data_processing.companies", + "tags": ["preprocessing"], + "pipelines": ["dp", "__default__"], + "type": "data", + "modular_pipelines": ["data_processing"], + "layer": "raw", + "dataset_type": "pandas.csv_dataset.CSVDataset" + }, + { + "id": "daf35ba0", + "name": "data_processing.preprocessed_companies", + "tags": ["features", "preprocessing"], + "pipelines": ["dp", "__default__"], + "type": "data", + "modular_pipelines": ["data_processing"], + "layer": "intermediate", + "dataset_type": "pandas.csv_dataset.CSVDataset" + }, + { + "id": "c09084f2", + "name": "preprocess_companies_node", + "tags": ["preprocessing"], + "pipelines": ["dp", "__default__"], + "type": "task", + "modular_pipelines": ["data_processing"], + "parameters": {} + }, + { + "id": "47b81aa6", + "name": "create_model_input_table_node", + "tags": ["features"], + "pipelines": ["dp", "__default__"], + "type": "task", + "modular_pipelines": ["data_processing"], + "parameters": {} + }, + { + "id": "90ebe5f3", + "name": "data_processing.reviews", + "tags": ["features"], + "pipelines": ["dp", "__default__"], + "type": "data", + "modular_pipelines": ["data_processing"], + "layer": "raw", + "dataset_type": "pandas.csv_dataset.CSVDataset" + }, + { + "id": "65d0d789", + "name": "split_data_node", + "tags": ["split"], + "pipelines": ["__default__", "ds"], + "type": "task", + "modular_pipelines": ["data_science"], + "parameters": { + "test_size": 0.2, + "random_state": 3, + "features": [ + "engines", + "passenger_capacity", + "crew", + "d_check_complete", + "moon_clearance_complete", + "iata_approved", + "company_rating", + "review_scores_rating" + ] + } + }, + { + "id": "f1f1425b", + "name": "parameters", + "tags": ["split"], + "pipelines": ["__default__", "ds"], + "type": "parameters", + "modular_pipelines": [], + "layer": null, + "dataset_type": null + }, + { + "id": "23c94afb", + "name": "model_input_table", + "tags": ["features", "split"], + "pipelines": ["dp", "__default__", "ds"], + "type": "data", + "modular_pipelines": [], + "layer": "primary", + "dataset_type": "pandas.csv_dataset.CSVDataset" + }, + { + "id": "172a0602", + "name": "data_science.X_train", + "tags": ["split", "train"], + "pipelines": ["__default__", "ds"], + "type": "data", + "modular_pipelines": ["data_science"], + "layer": null, + "dataset_type": null + }, + { + "id": "9c2a8a5e", + "name": "data_science.X_test", + "tags": ["split"], + "pipelines": ["__default__", "ds"], + "type": "data", + "modular_pipelines": ["data_science"], + "layer": null, + "dataset_type": null + }, + { + "id": "e5cee9e2", + "name": "data_science.y_train", + "tags": ["split", "train"], + "pipelines": ["__default__", "ds"], + "type": "data", + "modular_pipelines": ["data_science"], + "layer": null, + "dataset_type": null + }, + { + "id": "ecc63a8c", + "name": "data_science.y_test", + "tags": ["split"], + "pipelines": ["__default__", "ds"], + "type": "data", + "modular_pipelines": ["data_science"], + "layer": null, + "dataset_type": null + }, + { + "id": "90f15f9d", + "name": "train_model_node", + "tags": ["train"], + "pipelines": ["__default__", "ds"], + "type": "task", + "modular_pipelines": ["data_science"], + "parameters": {} + }, + { + "id": "04424659", + "name": "data_science.regressor", + "tags": ["train"], + "pipelines": ["__default__", "ds"], + "type": "data", + "modular_pipelines": ["data_science"], + "layer": "models", + "dataset_type": "pickle.pickle_dataset.PickleDataset" + }, + { + "id": "f5e8d7df", + "name": "evaluate_model_node", + "tags": [], + "pipelines": ["__default__", "ds"], + "type": "task", + "modular_pipelines": ["data_science"], + "parameters": {} + }, + { + "id": "966b9734", + "name": "metrics", + "tags": [], + "pipelines": ["__default__", "ds"], + "type": "data", + "modular_pipelines": [], + "layer": null, + "dataset_type": "tracking.metrics_dataset.MetricsDataset" + }, + { + "id": "data_processing", + "name": "data_processing", + "tags": [], + "pipelines": ["dp", "__default__"], + "type": "modularPipeline", + "modular_pipelines": null, + "layer": null, + "dataset_type": null + }, + { + "id": "data_science", + "name": "data_science", + "tags": [], + "pipelines": ["__default__", "ds"], + "type": "modularPipeline", + "modular_pipelines": null, + "layer": null, + "dataset_type": null + } + ], + "edges": [ + { "source": "23c94afb", "target": "65d0d789" }, + { "source": "04424659", "target": "f5e8d7df" }, + { "source": "90ebe5f3", "target": "47b81aa6" }, + { "source": "23c94afb", "target": "data_science" }, + { "source": "daf35ba0", "target": "47b81aa6" }, + { "source": "data_science", "target": "966b9734" }, + { "source": "47b81aa6", "target": "23c94afb" }, + { "source": "e5cee9e2", "target": "90f15f9d" }, + { "source": "f192326a", "target": "b7bb7198" }, + { "source": "65d0d789", "target": "172a0602" }, + { "source": "f1f1425b", "target": "65d0d789" }, + { "source": "90ebe5f3", "target": "data_processing" }, + { "source": "f5e8d7df", "target": "966b9734" }, + { "source": "e5a9ec27", "target": "47b81aa6" }, + { "source": "65d0d789", "target": "9c2a8a5e" }, + { "source": "data_processing", "target": "23c94afb" }, + { "source": "f1f1425b", "target": "data_science" }, + { "source": "f192326a", "target": "data_processing" }, + { "source": "172a0602", "target": "90f15f9d" }, + { "source": "9c2a8a5e", "target": "f5e8d7df" }, + { "source": "ecc63a8c", "target": "f5e8d7df" }, + { "source": "b7bb7198", "target": "e5a9ec27" }, + { "source": "90f15f9d", "target": "04424659" }, + { "source": "65d0d789", "target": "e5cee9e2" }, + { "source": "0abef172", "target": "data_processing" }, + { "source": "65d0d789", "target": "ecc63a8c" }, + { "source": "0abef172", "target": "c09084f2" }, + { "source": "c09084f2", "target": "daf35ba0" } + ], + "layers": ["raw", "intermediate", "primary", "models"], + "tags": [ + { "id": "features", "name": "features" }, + { "id": "preprocessing", "name": "preprocessing" }, + { "id": "split", "name": "split" }, + { "id": "train", "name": "train" } + ], + "pipelines": [ + { "id": "__default__", "name": "__default__" }, + { "id": "dp", "name": "dp" }, + { "id": "ds", "name": "ds" } + ], + "modular_pipelines": { + "__root__": { + "id": "__root__", + "name": "__root__", + "inputs": [], + "outputs": [], + "children": [ + { "id": "data_processing", "type": "modularPipeline" }, + { "id": "23c94afb", "type": "data" }, + { "id": "966b9734", "type": "data" }, + { "id": "f1f1425b", "type": "parameters" }, + { "id": "data_science", "type": "modularPipeline" } + ] + }, + "data_processing": { + "id": "data_processing", + "name": "data_processing", + "inputs": ["90ebe5f3", "0abef172", "f192326a"], + "outputs": ["23c94afb"], + "children": [ + { "id": "e5a9ec27", "type": "data" }, + { "id": "f192326a", "type": "data" }, + { "id": "90ebe5f3", "type": "data" }, + { "id": "daf35ba0", "type": "data" }, + { "id": "47b81aa6", "type": "task" }, + { "id": "0abef172", "type": "data" }, + { "id": "c09084f2", "type": "task" }, + { "id": "b7bb7198", "type": "task" } + ] + }, + "data_science": { + "id": "data_science", + "name": "data_science", + "inputs": ["f1f1425b", "23c94afb"], + "outputs": ["966b9734"], + "children": [ + { "id": "ecc63a8c", "type": "data" }, + { "id": "172a0602", "type": "data" }, + { "id": "04424659", "type": "data" }, + { "id": "e5cee9e2", "type": "data" }, + { "id": "9c2a8a5e", "type": "data" }, + { "id": "f5e8d7df", "type": "task" }, + { "id": "65d0d789", "type": "task" }, + { "id": "90f15f9d", "type": "task" } + ] + } + }, + "selected_pipeline": "__default__" +} diff --git a/src/utils/state.mock.js b/src/utils/state.mock.js index 6b5d3268ac..e2141406bd 100644 --- a/src/utils/state.mock.js +++ b/src/utils/state.mock.js @@ -4,6 +4,7 @@ import { mount, shallow } from 'enzyme'; import configureStore from '../store'; import getInitialState from '../store/initial-state'; import spaceflights from './data/spaceflights.mock.json'; +import spaceflightsReordered from './data/spaceflights_reordered.mock.json'; import demo from './data/demo.mock.json'; import reducer from '../reducers'; import { getGraphInput } from '../selectors/layout'; @@ -53,6 +54,7 @@ export const mockState = { json: prepareState({ data: 'json' }), demo: prepareState({ data: demo }), spaceflights: prepareState({ data: spaceflights }), + spaceflightsReordered: prepareState({ data: spaceflightsReordered }), }; /** From ca734e43342fdad2374f1fadd0e416a1641bbf25 Mon Sep 17 00:00:00 2001 From: Huong Nguyen <32060364+Huongg@users.noreply.github.com> Date: Tue, 19 Nov 2024 14:10:49 +0000 Subject: [PATCH 29/35] telemetry/add kedro_Viz_version to heaps (#2194) * add kedro_Viz_version to heaps Signed-off-by: Huong Nguyen * update release note Signed-off-by: Huong Nguyen * shortern the syntax Signed-off-by: Huong Nguyen * update release note and lint error Signed-off-by: Huong Nguyen * Fix lint error Signed-off-by: Huong Nguyen * set version in base.py Signed-off-by: Huong Nguyen * revert back to the old syntax Signed-off-by: Huong Nguyen --------- Signed-off-by: Huong Nguyen Co-authored-by: Huong Nguyen --- RELEASE.md | 1 + package/kedro_viz/api/apps.py | 4 +++- package/kedro_viz/integrations/deployment/base_deployer.py | 4 +++- public/telemetry.html | 3 +++ 4 files changed, 10 insertions(+), 2 deletions(-) diff --git a/RELEASE.md b/RELEASE.md index 61cb49bf9b..8d913321f7 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -24,6 +24,7 @@ Please follow the established format: - Refactor `DatasetStatsHook` to avoid showing error when dataset doesn't have file size info (#2174) - Fix 404 error when accessing the experiment tracking page on the demo site (#2179) - Add check for port availability before starting Kedro Viz to prevent unintended browser redirects when the port is already in use (#2176) +- Include Kedro Viz version in telemetry.. (#2194) # Release 10.0.0 diff --git a/package/kedro_viz/api/apps.py b/package/kedro_viz/api/apps.py index e188ab1911..8c2b6b298c 100644 --- a/package/kedro_viz/api/apps.py +++ b/package/kedro_viz/api/apps.py @@ -92,7 +92,9 @@ async def index(): env = Environment(loader=FileSystemLoader(_HTML_DIR)) if should_add_telemetry: telemetry_content = env.get_template("telemetry.html").render( - heap_app_id=heap_app_id, heap_user_identity=heap_user_identity + heap_app_id=heap_app_id, + heap_user_identity=heap_user_identity, + kedro_viz_version=__version__, ) injected_head_content.append(telemetry_content) diff --git a/package/kedro_viz/integrations/deployment/base_deployer.py b/package/kedro_viz/integrations/deployment/base_deployer.py index d0f0b2a7bf..31c0adea54 100644 --- a/package/kedro_viz/integrations/deployment/base_deployer.py +++ b/package/kedro_viz/integrations/deployment/base_deployer.py @@ -51,7 +51,9 @@ def _ingest_heap_analytics(self): if should_add_telemetry: logger.debug("Ingesting heap analytics.") telemetry_content = env.get_template("telemetry.html").render( - heap_app_id=heap_app_id, heap_user_identity=heap_user_identity + heap_app_id=heap_app_id, + heap_user_identity=heap_user_identity, + kedro_viz_version=__version__, ) injected_head_content.append(telemetry_content) diff --git a/public/telemetry.html b/public/telemetry.html index 9e392a72dc..a684a20dde 100644 --- a/public/telemetry.html +++ b/public/telemetry.html @@ -2,5 +2,8 @@ window.heap=window.heap||[],heap.load=function(e,t){window.heap.appid=e,window.heap.config=t=t||{};var r=document.createElement("script");r.type="text/javascript",r.async=!0,r.src="https://cdn.heapanalytics.com/js/heap-"+e+".js";var a=document.getElementsByTagName("script")[0];a.parentNode.insertBefore(r,a);for(var n=function(e){return function(){heap.push([e].concat(Array.prototype.slice.call(arguments,0)))}},p=["addEventProperties","addUserProperties","clearEventProperties","identify","resetIdentity","removeEventProperty","setEventProperties","track","unsetEventProperty"],o=0;o From a0931ee9c0790453130ee4d3b5ce208d74b65344 Mon Sep 17 00:00:00 2001 From: Huong Nguyen <32060364+Huongg@users.noreply.github.com> Date: Tue, 19 Nov 2024 19:01:47 +0000 Subject: [PATCH 30/35] QA: Feature branch for refactor node list (#2193) * Refactor / node list row (#2143) * update classnames to match the component name Signed-off-by: Huong Nguyen * update names in tests Signed-off-by: Huong Nguyen * update the rest of the classnames Signed-off-by: Huong Nguyen * abstract node-list-row-toggle component Signed-off-by: Huong Nguyen * tidy up code for toggle component Signed-off-by: Huong Nguyen * update classnames in tests Signed-off-by: Huong Nguyen * simplify the css Signed-off-by: Huong Nguyen * add tests for node-list-row-toggle Signed-off-by: Huong Nguyen * remove handleToggle on VisibilityIcon Signed-off-by: Huong Nguyen * remove redux from node-list-row Signed-off-by: Huong Nguyen * split node-list-row into row and filter-row Signed-off-by: Huong Nguyen * rename toggle icon component Signed-off-by: Huong Nguyen * move row and filter-row to components level Signed-off-by: Huong Nguyen * move css to row and filterRow Signed-off-by: Huong Nguyen * remove node-list-row Signed-off-by: Huong Nguyen * separate the row-text component Signed-off-by: Huong Nguyen * include parent classname Signed-off-by: Huong Nguyen * update name for toggle-icon, to visibility-control Signed-off-by: Huong Nguyen * fix css and move nodeListRowHeight to config Signed-off-by: Huong Nguyen * adding test for new component Signed-off-by: Huong Nguyen * update classname for tests Signed-off-by: Huong Nguyen * move row inside node-list Signed-off-by: Huong Nguyen * connect redux store to component Signed-off-by: Huong Nguyen * fix styling Signed-off-by: Huong Nguyen * update name to ToggleControl Signed-off-by: Huong Nguyen * remove disable props as no longer needed Signed-off-by: Huong Nguyen * replace js code with css to simplify the code Signed-off-by: Huong Nguyen * update classnames in cypress test Signed-off-by: Huong Nguyen * Styling for hovering and focus mode Signed-off-by: Huong Nguyen * fixing small styling Signed-off-by: Huong Nguyen * fix the disable styling for row Signed-off-by: Huong Nguyen * fix the disable styling on focus mode Signed-off-by: Huong Nguyen * remove one of the old test Signed-off-by: Huong Nguyen * update name for icons for FilterRow Signed-off-by: Huong Nguyen * fixing the icon highlighting issue Signed-off-by: Huong Nguyen * remove un-used li element Signed-off-by: Huong Nguyen * remove styling for pipeline-nodelist__placeholder-upper and lower class as nolonger used Signed-off-by: Huong Nguyen * update test in node-list Signed-off-by: Huong Nguyen * update cypress tests Signed-off-by: Huong Nguyen * moving .pipeline-nodelist__group--all-unchecked to the parent Signed-off-by: Huong Nguyen * prevent page reload on form submission Signed-off-by: Huong Nguyen * remove wrong classname in the test Signed-off-by: Huong Nguyen * remove unique ID Signed-off-by: Huong Nguyen * apply hovering styling on the parent instead of row Signed-off-by: Huong Nguyen * styling for selected element Signed-off-by: Huong Nguyen * fixing hover styling on the icon from MUI Signed-off-by: Huong Nguyen --------- Signed-off-by: Huong Nguyen Co-authored-by: Huong Nguyen * Refactor/node list groups (#2166) * Create new structure and its own folder for filters or groups Signed-off-by: Huong Nguyen * better names for component structure Signed-off-by: Huong Nguyen * FiltersSectionHeading Signed-off-by: Huong Nguyen * filters-section Signed-off-by: Huong Nguyen * filters component Signed-off-by: Huong Nguyen * filtersSectionHeading component Signed-off-by: Huong Nguyen * tidy up code Signed-off-by: Huong Nguyen * including new tests for new components Signed-off-by: Huong Nguyen * update and remove existing tests Signed-off-by: Huong Nguyen * remove un-used variables Signed-off-by: Huong Nguyen * remove components folder Signed-off-by: Huong Nguyen * update tests path Signed-off-by: Huong Nguyen --------- Signed-off-by: Huong Nguyen Co-authored-by: Huong Nguyen * Refactor/node list index (#2178) * foundation for FiltersContext Signed-off-by: Huong Nguyen * remove unused props Signed-off-by: Huong Nguyen * node-list-context Signed-off-by: Huong Nguyen * restructure node-list-item as a helper function Signed-off-by: Huong Nguyen * rename selectors Signed-off-by: Huong Nguyen * rename functions in FiltersContext Signed-off-by: Huong Nguyen * move redux selector to node-list-context Signed-off-by: Huong Nguyen * fixing the hovered node issue Signed-off-by: Huong Nguyen * move getFilteredItems to selector Signed-off-by: Huong Nguyen * fix the modularpipeline highlight issue Signed-off-by: Huong Nguyen * Adding test for selector Signed-off-by: Huong Nguyen * update tests Signed-off-by: Huong Nguyen * update names to be nodes-panel Signed-off-by: Huong Nguyen * Fixing the filters problem Signed-off-by: Huong Nguyen * update test Signed-off-by: Huong Nguyen * fixing the highlight issue through getNodesActive Signed-off-by: Huong Nguyen * move node-list-tree to its own component Signed-off-by: Huong Nguyen * update row to node-list-row Signed-off-by: Huong Nguyen * move style to be inside node-list-tree Signed-off-by: Huong Nguyen * fix the filters URL update Signed-off-by: Huong Nguyen * update name for nodes panel context Signed-off-by: Huong Nguyen --------- Signed-off-by: Huong Nguyen Co-authored-by: Huong Nguyen * Refactor on NodeListTree (#2177) This is a smaller refactor. Previously, the logic for determining which nodes were disabled due to modular pipelines was duplicated in both the NodeListTree component and the getNodeDisabled selector. To improve maintainability and reduce redundancy, the getnodesDisabledViaModularPipeline logic was extracted and made into it's own selector. Now, this logic is shared and reused by both the NodeListTree component and the getNodeDisabled selector. * fixed issue with nested focus modular pipeline Signed-off-by: rashidakanchwala * Update RELEASE.md Signed-off-by: rashidakanchwala <37628668+rashidakanchwala@users.noreply.github.com> * Update .telemetry Signed-off-by: rashidakanchwala <37628668+rashidakanchwala@users.noreply.github.com> * Update pyproject.toml Signed-off-by: rashidakanchwala <37628668+rashidakanchwala@users.noreply.github.com> * Update apps.py Signed-off-by: rashidakanchwala <37628668+rashidakanchwala@users.noreply.github.com> * Update telemetry.html Signed-off-by: rashidakanchwala <37628668+rashidakanchwala@users.noreply.github.com> * fix issue around parent pipelines disabled child pipelines Signed-off-by: rashidakanchwala * include in the release note Signed-off-by: Huong Nguyen * fixing the padding bottom gap for filters Signed-off-by: Huong Nguyen --------- Signed-off-by: Huong Nguyen Signed-off-by: rashidakanchwala Signed-off-by: rashidakanchwala <37628668+rashidakanchwala@users.noreply.github.com> Co-authored-by: Huong Nguyen Co-authored-by: rashidakanchwala <37628668+rashidakanchwala@users.noreply.github.com> Co-authored-by: rashidakanchwala --- RELEASE.md | 2 +- cypress/tests/ui/flowchart/flowchart.cy.js | 2 +- cypress/tests/ui/flowchart/menu.cy.js | 34 +- cypress/tests/ui/toolbar/global-toolbar.cy.js | 8 +- .../filters/filters-group/filters-group.js | 49 +++ .../filters/filters-group/filters-group.scss | 15 + .../filters-group/filters-group.test.js | 34 ++ .../filters/filters-row/filters-row.js | 68 ++++ .../filters/filters-row/filters-row.scss | 54 +++ .../filters/filters-row/filters-row.test.js | 24 ++ .../filters-section-heading.js | 48 +++ .../filters-section-heading.scss | 62 +++ .../filters-section-heading.test.js | 53 +++ .../filters-section/filters-section.js | 46 +++ .../filters-section/filters-section.scss | 6 + .../filters-section/filters-section.test.js | 26 ++ src/components/filters/filters.js | 51 +++ .../_section.scss => filters/filters.scss} | 18 +- src/components/filters/filters.test.js | 61 +++ .../node-list-row/node-list-row.js | 123 ++++++ .../node-list-row/node-list-row.scss | 87 ++++ .../node-list-row/node-list-row.test.js | 78 ++++ .../node-list-tree-item.js | 47 ++- .../node-list-tree.js | 76 ++-- .../styles/_panels.scss | 0 .../styles/_variables.scss | 0 .../styles/node-list.scss | 64 ++- src/components/node-list/index.js | 379 ------------------ src/components/node-list/node-list-group.js | 77 ---- .../node-list/node-list-group.test.js | 88 ---- src/components/node-list/node-list-groups.js | 60 --- .../node-list/node-list-groups.test.js | 49 --- .../node-list/node-list-row-list.js | 86 ---- src/components/node-list/node-list-row.js | 255 ------------ .../node-list/node-list-row.test.js | 191 --------- src/components/node-list/node-list.js | 116 ------ src/components/node-list/styles/_group.scss | 173 -------- .../node-list/styles/_row-label.scss | 102 ----- src/components/node-list/styles/_row.scss | 116 ------ src/components/nodes-panel/index.js | 18 + src/components/nodes-panel/nodes-panel.js | 140 +++++++ .../nodes-panel.test.js} | 189 +++------ .../nodes-panel/utils/filters-context.js | 251 ++++++++++++ .../nodes-panel/utils/node-list-context.js | 232 +++++++++++ .../nodes-panel/utils/nodes-panel-context.js | 11 + src/components/sidebar/sidebar.js | 4 +- .../sliced-pipeline-action-bar.test.js | 2 +- src/components/ui/row-text/row-text.js | 51 +++ src/components/ui/row-text/row-text.scss | 62 +++ .../ui/toggle-control/toggle-control.js | 75 ++++ .../toggle-control/toggle-control.scss} | 112 ++---- .../ui/toggle-control/toggle-control.test.js | 58 +++ src/config.js | 3 + src/selectors/disabled.js | 100 +++-- .../filtered-node-list-item.test.js} | 33 +- .../filtered-node-list-items.js} | 47 ++- src/selectors/nodes.js | 37 +- tools/test-lib/react-app/app.test.js | 5 +- 58 files changed, 2166 insertions(+), 2092 deletions(-) create mode 100644 src/components/filters/filters-group/filters-group.js create mode 100644 src/components/filters/filters-group/filters-group.scss create mode 100644 src/components/filters/filters-group/filters-group.test.js create mode 100755 src/components/filters/filters-row/filters-row.js create mode 100644 src/components/filters/filters-row/filters-row.scss create mode 100644 src/components/filters/filters-row/filters-row.test.js create mode 100644 src/components/filters/filters-section-heading/filters-section-heading.js create mode 100644 src/components/filters/filters-section-heading/filters-section-heading.scss create mode 100755 src/components/filters/filters-section-heading/filters-section-heading.test.js create mode 100644 src/components/filters/filters-section/filters-section.js create mode 100644 src/components/filters/filters-section/filters-section.scss create mode 100755 src/components/filters/filters-section/filters-section.test.js create mode 100644 src/components/filters/filters.js rename src/components/{node-list/styles/_section.scss => filters/filters.scss} (71%) create mode 100644 src/components/filters/filters.test.js create mode 100755 src/components/node-list-tree/node-list-row/node-list-row.js create mode 100755 src/components/node-list-tree/node-list-row/node-list-row.scss create mode 100644 src/components/node-list-tree/node-list-row/node-list-row.test.js rename src/components/{node-list => node-list-tree/node-list-tree-item}/node-list-tree-item.js (72%) rename src/components/{node-list => node-list-tree}/node-list-tree.js (77%) rename src/components/{node-list => node-list-tree}/styles/_panels.scss (100%) rename src/components/{node-list => node-list-tree}/styles/_variables.scss (100%) rename src/components/{node-list => node-list-tree}/styles/node-list.scss (58%) delete mode 100644 src/components/node-list/index.js delete mode 100644 src/components/node-list/node-list-group.js delete mode 100644 src/components/node-list/node-list-group.test.js delete mode 100644 src/components/node-list/node-list-groups.js delete mode 100644 src/components/node-list/node-list-groups.test.js delete mode 100644 src/components/node-list/node-list-row-list.js delete mode 100644 src/components/node-list/node-list-row.js delete mode 100644 src/components/node-list/node-list-row.test.js delete mode 100644 src/components/node-list/node-list.js delete mode 100644 src/components/node-list/styles/_group.scss delete mode 100644 src/components/node-list/styles/_row-label.scss delete mode 100644 src/components/node-list/styles/_row.scss create mode 100644 src/components/nodes-panel/index.js create mode 100644 src/components/nodes-panel/nodes-panel.js rename src/components/{node-list/node-list.test.js => nodes-panel/nodes-panel.test.js} (74%) create mode 100644 src/components/nodes-panel/utils/filters-context.js create mode 100644 src/components/nodes-panel/utils/node-list-context.js create mode 100644 src/components/nodes-panel/utils/nodes-panel-context.js create mode 100644 src/components/ui/row-text/row-text.js create mode 100644 src/components/ui/row-text/row-text.scss create mode 100755 src/components/ui/toggle-control/toggle-control.js rename src/components/{node-list/styles/_row-toggle.scss => ui/toggle-control/toggle-control.scss} (62%) create mode 100644 src/components/ui/toggle-control/toggle-control.test.js rename src/{components/node-list/node-list-items.test.js => selectors/filtered-node-list-item.test.js} (93%) rename src/{components/node-list/node-list-items.js => selectors/filtered-node-list-items.js} (85%) diff --git a/RELEASE.md b/RELEASE.md index 8d913321f7..0a15a496ff 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -14,7 +14,6 @@ Please follow the established format: - Introduce `behaviour` prop object with `reFocus` prop (#2161) ## Bug fixes and other changes - - Improve `kedro viz build` usage documentation (#2126) - Fix unserializable parameters value (#2122) - Replace `watchgod` library with `watchfiles` and improve autoreload file watching filter (#2134) @@ -22,6 +21,7 @@ Please follow the established format: - Enable SQLite WAL mode for Azure ML to fix database locking issues (#2131) - Replace `flake8`, `isort`, `pylint` and `black` by `ruff` (#2149) - Refactor `DatasetStatsHook` to avoid showing error when dataset doesn't have file size info (#2174) +- Refactor `node-list-tree` component. (#2193) - Fix 404 error when accessing the experiment tracking page on the demo site (#2179) - Add check for port availability before starting Kedro Viz to prevent unintended browser redirects when the port is already in use (#2176) - Include Kedro Viz version in telemetry.. (#2194) diff --git a/cypress/tests/ui/flowchart/flowchart.cy.js b/cypress/tests/ui/flowchart/flowchart.cy.js index a2eee6fbde..f3f241dcb3 100644 --- a/cypress/tests/ui/flowchart/flowchart.cy.js +++ b/cypress/tests/ui/flowchart/flowchart.cy.js @@ -70,7 +70,7 @@ describe('Flowchart DAG', () => { const nodeToToggleText = 'Parameters'; // Alias - cy.get(`.pipeline-nodelist__row__checkbox[name=${nodeToToggleText}]`).as( + cy.get(`.toggle-control__checkbox[name=${nodeToToggleText}]`).as( 'nodeToToggle' ); diff --git a/cypress/tests/ui/flowchart/menu.cy.js b/cypress/tests/ui/flowchart/menu.cy.js index deb6d38f81..5571528339 100644 --- a/cypress/tests/ui/flowchart/menu.cy.js +++ b/cypress/tests/ui/flowchart/menu.cy.js @@ -41,7 +41,7 @@ describe('Flowchart Menu', () => { }); // Pipeline Label in the Menu - cy.get('.pipeline-nodelist__row__label') + cy.get('.row-text__label') .first() .invoke('text') .should((pipelineLabel) => { @@ -57,7 +57,7 @@ describe('Flowchart Menu', () => { cy.get('.search-input__field').type(searchInput, { force: true }); // Pipeline Label in the Menu - cy.get('.pipeline-nodelist__row__label') + cy.get('.row-text__label') .first() .invoke('text') .should((pipelineLabel) => { @@ -72,7 +72,7 @@ describe('Flowchart Menu', () => { // Action cy.get( - `.MuiTreeItem-label > .pipeline-nodelist__row > [data-test=nodelist-data-${nodeToClickText}]` + `.MuiTreeItem-label > .node-list-tree-item-row > [data-test=node-list-tree-item--row--${nodeToClickText}]` ) .should('exist') .as('nodeToClick'); @@ -91,7 +91,7 @@ describe('Flowchart Menu', () => { // Action cy.get( - `.MuiTreeItem-label > .pipeline-nodelist__row > [data-test=nodelist-data-${nodeToHighlightText}]` + `.MuiTreeItem-label > .node-list-tree-item-row > [data-test=node-list-tree-item--row--${nodeToHighlightText}]` ) .should('exist') .as('nodeToHighlight'); @@ -108,7 +108,7 @@ describe('Flowchart Menu', () => { const nodeToToggleText = 'Companies'; // Alias - cy.get(`.pipeline-nodelist__row__checkbox[name=${nodeToToggleText}]`, { + cy.get(`.toggle-control__checkbox[name=${nodeToToggleText}]`, { timeout: 5000, }).as('nodeToToggle'); @@ -121,7 +121,7 @@ describe('Flowchart Menu', () => { // Assert after action cy.__checkForText__( - `[data-test=nodelist-data-${nodeToToggleText}] > .pipeline-nodelist__row__label--faded`, + `[data-test=node-list-tree-item--row--${nodeToToggleText}] > .row-text__label--faded`, nodeToToggleText ); cy.get('.pipeline-node__text').should('not.contain', nodeToToggleText); @@ -137,7 +137,7 @@ describe('Flowchart Menu', () => { // Action cy.get( - `[for=${nodeToFocusText}-focus] > .pipeline-nodelist__row__icon` + `[for=feature_engineering-focus]` ).click(); // Assert after action @@ -161,34 +161,34 @@ describe('Flowchart Menu', () => { const visibleRowLabel = 'Companies'; // Alias - cy.get(`.pipeline-nodelist__row__checkbox[name=${nodeToToggleText}]`).as( + cy.get(`.toggle-control__checkbox[name=${nodeToToggleText}]`).as( 'nodeToToggle' ); // Assert before action cy.get('@nodeToToggle').should('be.checked'); cy.get( - `[data-test=nodelist-data-${visibleRowLabel}] > .pipeline-nodelist__row__label` + `[data-test=node-list-tree-item--row--${visibleRowLabel}] > .row-text__label` ) - .should('not.have.class', 'pipeline-nodelist__row__label--faded') - .should('not.have.class', 'pipeline-nodelist__row__label--disabled'); + .should('not.have.class', 'row-text__label--faded') + .should('not.have.class', 'row-text__label--disabled'); // Action cy.get('@nodeToToggle').uncheck({ force: true }); // Assert after action cy.get( - `[data-test=nodelist-data-${visibleRowLabel}] > .pipeline-nodelist__row__label` + `[data-test=node-list-tree-item--row--${visibleRowLabel}] > .row-text__label` ) - .should('have.class', 'pipeline-nodelist__row__label--faded') - .should('have.class', 'pipeline-nodelist__row__label--disabled'); + .should('have.class', 'row-text__label--faded') + .should('have.class', 'row-text__label--disabled'); }); it('verifies that after checking node type URL should be updated with correct query params', () => { const nodeToToggleText = 'Parameters'; // Alias - cy.get(`.pipeline-nodelist__row__checkbox[name=${nodeToToggleText}]`).as( + cy.get(`.toggle-control__checkbox[name=${nodeToToggleText}]`).as( 'nodeToToggle' ); @@ -207,7 +207,7 @@ describe('Flowchart Menu', () => { cy.visit(`/?tags=${visibleRowLabel}`); // Alias - cy.get(`.pipeline-nodelist__row__checkbox[name=${visibleRowLabel}]`).as( + cy.get(`.toggle-control__checkbox[name=${visibleRowLabel}]`).as( 'nodeToToggle' ); @@ -220,7 +220,7 @@ describe('Flowchart Menu', () => { cy.visit('/?types=datasets'); // Alias - cy.get(`.pipeline-nodelist__row__checkbox[name=${visibleRowLabel}]`).as( + cy.get(`.toggle-control__checkbox[name=${visibleRowLabel}]`).as( 'nodeToToggle' ); diff --git a/cypress/tests/ui/toolbar/global-toolbar.cy.js b/cypress/tests/ui/toolbar/global-toolbar.cy.js index a8f6434968..64971aa1d7 100644 --- a/cypress/tests/ui/toolbar/global-toolbar.cy.js +++ b/cypress/tests/ui/toolbar/global-toolbar.cy.js @@ -81,14 +81,14 @@ describe('Global Toolbar', () => { cy.get('@isPrettyNameCheckbox').should('be.checked'); // Menu - cy.get(`[data-test="nodelist-modularPipeline-${prettifyName(modularPipelineText)}"]`).click(); - cy.get(`[data-test="nodelist-${nodeNameType}-${prettyNodeNameText}"]`).should('exist'); + cy.get(`[data-test="node-list-tree-item--row--${prettifyName(modularPipelineText)}"]`).click(); + cy.get(`[data-test="node-list-tree-item--row--${prettyNodeNameText}"]`).should('exist'); // Flowchart cy.get('.pipeline-node__text').should('contain', prettyNodeNameText); // Metadata - cy.get(`[data-test="nodelist-${nodeNameType}-${prettyNodeNameText}"]`).click({ force: true }); + cy.get(`[data-test="node-list-tree-item--row--${prettyNodeNameText}"]`).click({ force: true }); cy.get('.pipeline-metadata__title').should( 'have.text', prettyNodeNameText @@ -106,7 +106,7 @@ describe('Global Toolbar', () => { // Assert after action cy.__waitForPageLoad__(() => { // Menu - cy.get(`[data-test="nodelist-${nodeNameType}-${originalNodeNameText}"]`).should('exist'); + cy.get(`[data-test="node-list-tree-item--row--${originalNodeNameText}"]`).should('exist'); // Flowchart cy.get('.pipeline-node__text').should('contain', originalNodeNameText); diff --git a/src/components/filters/filters-group/filters-group.js b/src/components/filters/filters-group/filters-group.js new file mode 100644 index 0000000000..2edb9af1eb --- /dev/null +++ b/src/components/filters/filters-group/filters-group.js @@ -0,0 +1,49 @@ +import React from 'react'; +import classnames from 'classnames'; +import FiltersRow from '../filters-row/filters-row'; +import { nodeListRowHeight } from '../../../config'; +import LazyList from '../../lazy-list'; +import { getDataTestAttribute } from '../../../utils/get-data-test-attribute'; + +import './filters-group.scss'; + +/** A group collection of FiltersRow */ +const FiltersGroup = ({ items = [], group, collapsed, onItemChange }) => ( + (end - start) * nodeListRowHeight} + total={items.length} + > + {({ start, end, listRef, listStyle }) => ( +
    + {items.slice(start, end).map((item) => ( + onItemChange(e, item)} + onClick={(e) => onItemChange(e, item)} + parentClassName={'node-list-filter-row'} + visible={item.visible} + indicatorIcon={item.visibleIcon} + /> + ))} +
+ )} +
+); + +export default FiltersGroup; diff --git a/src/components/filters/filters-group/filters-group.scss b/src/components/filters/filters-group/filters-group.scss new file mode 100644 index 0000000000..c36a015442 --- /dev/null +++ b/src/components/filters/filters-group/filters-group.scss @@ -0,0 +1,15 @@ +@use '../../../styles/variables' as var; +@use '../../node-list-tree/styles/variables'; + +.filters-group { + list-style: none; + padding: 0; + margin: 0 0 1.2em; + + // Avoid placeholder fade leaking out for small lists + overflow: hidden; + + &--closed { + display: none; + } +} diff --git a/src/components/filters/filters-group/filters-group.test.js b/src/components/filters/filters-group/filters-group.test.js new file mode 100644 index 0000000000..7f91be5ca0 --- /dev/null +++ b/src/components/filters/filters-group/filters-group.test.js @@ -0,0 +1,34 @@ +import React from 'react'; +import FiltersGroup from './filters-group'; +import { mockState, setup } from '../../../utils/state.mock'; +import { getNodeTypes } from '../../../selectors/node-types'; +import { getGroupedNodes } from '../../../selectors/nodes'; +import { getGroups } from '../../../selectors/filtered-node-list-items'; + +describe('FiltersGroup Component', () => { + const mockProps = () => { + const items = getGroupedNodes(mockState.spaceflights); + const nodeTypes = getNodeTypes(mockState.spaceflights); + const groups = getGroups({ nodeTypes, items }); + return { group: groups['tags'], items: [] }; + }; + + it('renders without throwing', () => { + expect(() => setup.mount()).not.toThrow(); + }); + it('adds class when collapsed prop true', () => { + const wrapper = setup.mount( + + ); + const children = wrapper.find('.filters-group'); + expect(children.hasClass('filters-group--closed')).toBe(true); + }); + + it('removes class when collapsed prop false', () => { + const wrapper = setup.mount( + + ); + const children = wrapper.find('.filters-group'); + expect(children.hasClass('filters-group--closed')).toBe(false); + }); +}); diff --git a/src/components/filters/filters-row/filters-row.js b/src/components/filters/filters-row/filters-row.js new file mode 100755 index 0000000000..f854100608 --- /dev/null +++ b/src/components/filters/filters-row/filters-row.js @@ -0,0 +1,68 @@ +import React from 'react'; +import classnames from 'classnames'; +import IndicatorIcon from '../../icons/indicator'; +import OffIndicatorIcon from '../../icons/indicator-off'; +import { ToggleControl } from '../../ui/toggle-control/toggle-control'; +import { RowText } from '../../ui/row-text/row-text'; + +import './filters-row.scss'; + +const FiltersRow = ({ + allUnchecked, + checked, + children, + container: ContainerWrapper, + count, + dataTest, + id, + indicatorIcon = IndicatorIcon, + kind, + label, + name, + offIndicatorIcon = OffIndicatorIcon, + onChange, + onClick, + parentClassName, + visible, +}) => { + const Icon = checked ? indicatorIcon : offIndicatorIcon; + + return ( + + + + {count} + + + {children} + + ); +}; + +export default FiltersRow; diff --git a/src/components/filters/filters-row/filters-row.scss b/src/components/filters/filters-row/filters-row.scss new file mode 100644 index 0000000000..3f25875237 --- /dev/null +++ b/src/components/filters/filters-row/filters-row.scss @@ -0,0 +1,54 @@ +@use '../../../styles/variables' as var; +@use '../../node-list-tree/styles/variables'; + +.MuiTreeItem-iconContainer svg { + z-index: var.$zindex-MuiTreeItem-icon; +} + +.filter-row { + align-items: center; + background-color: initial; + cursor: default; + display: flex; + height: 32px; + position: relative; + + &--kind-filter { + padding: 0 variables.$row-offset-right 0 variables.$row-offset-left; + } + + &--visible:hover { + background-color: var(--color-nodelist-row-active); + } +} + +.filter-row__count { + display: inline-block; + flex-shrink: 0; + width: 2.2em; + margin: 0 0.7em 0.1em auto; + overflow: hidden; + font-size: 1.16em; + text-align: right; + text-overflow: ellipsis; + opacity: 0.75; + user-select: none; + + .filter-row--unchecked & { + opacity: 0.55; + } +} + +.filter-row--unchecked { + // Fade row text when unchecked + .row-text__label--kind-filter { + opacity: 0.55; + } + + // Brighter row text when unchecked and hovered + &:hover { + .row-text__label--kind-filter { + opacity: 0.8; + } + } +} diff --git a/src/components/filters/filters-row/filters-row.test.js b/src/components/filters/filters-row/filters-row.test.js new file mode 100644 index 0000000000..1660b20f14 --- /dev/null +++ b/src/components/filters/filters-row/filters-row.test.js @@ -0,0 +1,24 @@ +import React from 'react'; +import { mount } from 'enzyme'; +import FiltersRow from './filters-row'; + +describe('FiltersRow Component', () => { + it('renders without crashing', () => { + const wrapper = mount(); + expect(wrapper.exists()).toBe(true); + }); + + it('renders correct visible classnames', () => { + const wrapper = mount(); + expect(wrapper.find('.filter-row').hasClass('filter-row--visible')).toBe( + true + ); + }); + + it('renders correct unchecked classnames', () => { + const wrapper = mount(); + expect(wrapper.find('.filter-row').hasClass('filter-row--unchecked')).toBe( + true + ); + }); +}); diff --git a/src/components/filters/filters-section-heading/filters-section-heading.js b/src/components/filters/filters-section-heading/filters-section-heading.js new file mode 100644 index 0000000000..f63cba95d0 --- /dev/null +++ b/src/components/filters/filters-section-heading/filters-section-heading.js @@ -0,0 +1,48 @@ +import React from 'react'; +import classnames from 'classnames'; +import FiltersRow from '../filters-row/filters-row'; + +import './filters-section-heading.scss'; + +const FiltersSectionHeading = ({ + group, + collapsed, + groupItems, + onGroupToggleChanged, + onToggleGroupCollapsed, +}) => { + const { id, kind, name, allUnchecked, checked, invisibleIcon, visibleIcon } = + group; + const disabled = groupItems.length === 0; + + return ( +

+ { + onGroupToggleChanged(id, !e.target.checked); + }} + indicatorIcon={visibleIcon} + > +

+ ); +}; + +export default FiltersSectionHeading; diff --git a/src/components/filters/filters-section-heading/filters-section-heading.scss b/src/components/filters/filters-section-heading/filters-section-heading.scss new file mode 100644 index 0000000000..cdd1ea8dc1 --- /dev/null +++ b/src/components/filters/filters-section-heading/filters-section-heading.scss @@ -0,0 +1,62 @@ +@use '../../../styles/variables' as var; +@use '../../node-list-tree/styles/variables'; + +.filters-section-heading { + background: var(--color-nodelist-filter-panel); + margin: 0; + position: sticky; + top: 0; + + // Avoid pixel gap above when scrolling. + transform: translateY(-1px); + z-index: var.$zindex-nodelist-heading; + + .row-text .row-text__label { + font-size: 1.3em; + opacity: 0.65; + } +} + +.filters-section-heading__toggle-btn { + width: variables.$toggle-size; + height: variables.$toggle-size; + padding: 0; + color: var(--color-default-alt); + font-size: inherit; + font-family: inherit; + line-height: 1em; + text-align: center; + background: none; + border: none; + border-radius: 50%; + box-shadow: none; + cursor: pointer; + transition: transform ease 0.1s; + + &:focus { + outline: none; + + [data-whatintent='keyboard'] & { + box-shadow: 0 0 0 3px var.$blue-300 inset; + } + } + + &::before { + font-size: 1.8em; + opacity: 0.55; + content: '▾'; + } + + &:hover::before { + opacity: 1; + } + + &--alt { + transform: rotate(90deg); + } + + &--disabled { + color: var.$black-400; + transform: rotate(90deg); + } +} diff --git a/src/components/filters/filters-section-heading/filters-section-heading.test.js b/src/components/filters/filters-section-heading/filters-section-heading.test.js new file mode 100755 index 0000000000..84c57d603d --- /dev/null +++ b/src/components/filters/filters-section-heading/filters-section-heading.test.js @@ -0,0 +1,53 @@ +import React from 'react'; +import FiltersSectionHeading from './filters-section-heading'; +import { mockState, setup } from '../../../utils/state.mock'; +import { getNodeTypes } from '../../../selectors/node-types'; +import { getGroupedNodes } from '../../../selectors/nodes'; +import { getGroups } from '../../../selectors/filtered-node-list-items'; + +describe('FiltersSectionHeading', () => { + const mockProps = () => { + const items = getGroupedNodes(mockState.spaceflights); + const nodeTypes = getNodeTypes(mockState.spaceflights); + const groups = getGroups({ nodeTypes, items }); + return { group: groups['elementType'], groupItems: [] }; + }; + + it('renders without throwing', () => { + expect(() => + setup.mount() + ).not.toThrow(); + }); + + it('handles collapse button click events', () => { + const onToggleCollapsed = jest.fn(); + const wrapper = setup.mount( + + ); + wrapper.find('.filters-section-heading__toggle-btn').simulate('click'); + expect(() => onToggleCollapsed.mock.calls.length.toEqual(1)).toThrow(); + }); + + it('adds class when collapsed prop true', () => { + const wrapper = setup.mount( + + ); + const children = wrapper.find('.filters-section-heading__toggle-btn'); + expect(children.hasClass('filters-section-heading__toggle-btn--alt')).toBe( + true + ); + }); + + it('adds class when disabled prop true', () => { + const wrapper = setup.mount( + + ); + const children = wrapper.find('.filters-section-heading__toggle-btn'); + expect( + children.hasClass('filters-section-heading__toggle-btn--disabled') + ).toBe(true); + }); +}); diff --git a/src/components/filters/filters-section/filters-section.js b/src/components/filters/filters-section/filters-section.js new file mode 100644 index 0000000000..808aee952e --- /dev/null +++ b/src/components/filters/filters-section/filters-section.js @@ -0,0 +1,46 @@ +import React from 'react'; +import classnames from 'classnames'; +import FiltersSectionHeading from '../filters-section-heading/filters-section-heading'; +import FiltersGroup from '../filters-group/filters-group'; + +import './filters-section.scss'; + +/** Represents a section within the filters. */ +const FiltersSection = ({ + group, + groupCollapsed, + items, + onGroupToggleChanged, + onItemChange, + onToggleGroupCollapsed, + searchValue, +}) => { + const { id, allUnchecked } = group; + const collapsed = Boolean(searchValue) ? false : groupCollapsed[id]; + const groupItems = items[id] || []; + + return ( +
  • + + +
  • + ); +}; + +export default FiltersSection; diff --git a/src/components/filters/filters-section/filters-section.scss b/src/components/filters/filters-section/filters-section.scss new file mode 100644 index 0000000000..a0f90a9516 --- /dev/null +++ b/src/components/filters/filters-section/filters-section.scss @@ -0,0 +1,6 @@ +// Bright row text when the parent groups are all unchecked +.filters-section--all-unchecked { + .row-text__label--kind-filter { + opacity: 1; + } +} diff --git a/src/components/filters/filters-section/filters-section.test.js b/src/components/filters/filters-section/filters-section.test.js new file mode 100755 index 0000000000..6c476e32cd --- /dev/null +++ b/src/components/filters/filters-section/filters-section.test.js @@ -0,0 +1,26 @@ +import React from 'react'; +import FiltersSection from './filters-section'; +import { mockState, setup } from '../../../utils/state.mock'; +import { getNodeTypes } from '../../../selectors/node-types'; +import { getGroupedNodes } from '../../../selectors/nodes'; +import { getGroups } from '../../../selectors/filtered-node-list-items'; + +describe('FiltersSection Component', () => { + const mockProps = () => { + const items = getGroupedNodes(mockState.spaceflights); + const nodeTypes = getNodeTypes(mockState.spaceflights); + const groups = getGroups({ nodeTypes, items }); + return { items, group: groups['elementType'], groupCollapsed: {} }; + }; + + it('renders without throwing', () => { + expect(() => + setup.mount() + ).not.toThrow(); + }); + it('adds clas all-uncheckes when allUnchecked prop true', () => { + const wrapper = setup.mount(); + const children = wrapper.find('.filters-section'); + expect(children.hasClass('filters-section--all-unchecked')).toBe(true); + }); +}); diff --git a/src/components/filters/filters.js b/src/components/filters/filters.js new file mode 100644 index 0000000000..2797ebd33c --- /dev/null +++ b/src/components/filters/filters.js @@ -0,0 +1,51 @@ +import React from 'react'; +import FiltersSection from './filters-section/filters-section'; + +import './filters.scss'; + +const Filters = ({ + groupCollapsed, + groups, + isResetFilterActive, + items, + onGroupToggleChanged, + onItemChange, + onResetFilter, + onToggleGroupCollapsed, + searchValue, +}) => { + return ( + <> +
    +

    + Filters +

    + +
    +
      + {Object.values(groups).map((group) => { + return ( + + ); + })} +
    + + ); +}; + +export default Filters; diff --git a/src/components/node-list/styles/_section.scss b/src/components/filters/filters.scss similarity index 71% rename from src/components/node-list/styles/_section.scss rename to src/components/filters/filters.scss index a854ce8ee8..03e5922663 100644 --- a/src/components/node-list/styles/_section.scss +++ b/src/components/filters/filters.scss @@ -1,6 +1,6 @@ -@use './variables'; -@use '../../../styles/extends'; -@use '../../../styles/variables' as colors; +@use '../node-list-tree/styles/variables'; +@use '../../styles/extends'; +@use '../../styles/variables' as colors; .kui-theme--light { --color-text-reset: #{colors.$black-800}; @@ -10,14 +10,20 @@ --color-text-reset: #{colors.$white-600}; } -.pipeline-nodelist-section__filters { +.filters__section-wrapper { + margin: 0; + padding: 4px 0 28px; + list-style: none; +} + +.filters__header { display: flex; justify-content: space-between; align-items: center; margin: 6px (variables.$section-title-padding-x + 0.92) 12px (variables.$section-title-padding-x + 1.06); - .pipeline-nodelist-section__title { + .filters__title { font-weight: normal; font-size: 1.6em; opacity: 0.55; @@ -25,7 +31,7 @@ margin: 0; } - .pipeline-nodelist-section__reset-filter { + .filters__reset-button { @extend %button; font-size: 1.3em; diff --git a/src/components/filters/filters.test.js b/src/components/filters/filters.test.js new file mode 100644 index 0000000000..4b1ac0198b --- /dev/null +++ b/src/components/filters/filters.test.js @@ -0,0 +1,61 @@ +import React from 'react'; +import Filters from './filters'; +import { mockState, setup } from '../../utils/state.mock'; +import { getNodeTypes } from '../../selectors/node-types'; +import { getGroupedNodes } from '../../selectors/nodes'; +import { getGroups } from '../../selectors/filtered-node-list-items'; + +describe('Filters', () => { + const mockProps = () => { + const items = getGroupedNodes(mockState.spaceflights); + const nodeTypes = getNodeTypes(mockState.spaceflights); + const groups = getGroups({ nodeTypes, items }); + return { items, groups, groupCollapsed: {} }; + }; + + it('renders without throwing', () => { + expect(() => setup.mount()).not.toThrow(); + }); + + it('handles collapse button click events', () => { + const wrapper = setup.mount(); + const nodeList = () => wrapper.find('.filters-group').first(); + const toggle = () => + wrapper.find('.filters-section-heading__toggle-btn').first(); + expect(nodeList().length).toBe(1); + expect(toggle().hasClass('filters-section-heading__toggle-btn--alt')).toBe( + false + ); + expect(() => { + toggle() + .hasClass('filters-section-heading__toggle-btn--disabled') + .toBe(false); + toggle().simulate('click'); + expect(nodeList().length).toBe(1); + expect( + toggle().hasClass('filters-section-heading__toggle-btn--alt') + ).toBe(true); + }).toThrow(); + }); + + it('handles group checkbox change events', () => { + const onGroupToggleChanged = jest.fn(); + const wrapper = setup.mount( + + ); + const checkbox = () => wrapper.find('input').first(); + checkbox().simulate('change', { target: { checked: false } }); + expect(onGroupToggleChanged.mock.calls.length).toEqual(1); + }); + + it('calls onResetFilter when reset button is clicked', () => { + const onResetFilter = jest.fn(); + const wrapper = setup.mount( + + ); + const resetButton = wrapper.find('.filters__reset-button'); + expect(resetButton.exists()).toBe(true); + resetButton.simulate('click'); + expect(() => onResetFilter.mock.calls.length.toEqual(1)).toThrow(); + }); +}); diff --git a/src/components/node-list-tree/node-list-row/node-list-row.js b/src/components/node-list-tree/node-list-row/node-list-row.js new file mode 100755 index 0000000000..619bd301c4 --- /dev/null +++ b/src/components/node-list-tree/node-list-row/node-list-row.js @@ -0,0 +1,123 @@ +import React from 'react'; +import classnames from 'classnames'; +import NodeIcon from '../../icons/node-icon'; +import VisibleIcon from '../../icons/visible'; +import InvisibleIcon from '../../icons/invisible'; +import FocusModeIcon from '../../icons/focus-mode'; +import { ToggleControl } from '../../ui/toggle-control/toggle-control'; +import { RowText } from '../../ui/row-text/row-text'; + +import './node-list-row.scss'; + +const NodeListRow = ({ + active, + checked, + children, + dataTest, + disabled, + faded, + focused, + focusModeIcon = FocusModeIcon, + highlight, + icon, + id, + invisibleIcon = InvisibleIcon, + isSlicingPipelineApplied, + kind, + label, + name, + onChange, + onClick, + onMouseEnter, + onMouseLeave, + onToggleHoveredFocusMode, + parentClassName, + rowType, + selected, + type, + visibleIcon = VisibleIcon, +}) => { + const isModularPipeline = type === 'modularPipeline'; + const FocusIcon = isModularPipeline ? focusModeIcon : null; + const isChecked = isModularPipeline ? checked || focused : checked; + const VisibilityIcon = isChecked ? visibleIcon : invisibleIcon; + + return ( +
    + + + {VisibilityIcon && ( + + )} + {FocusIcon && ( + + )} + + ); +}; + +export default NodeListRow; diff --git a/src/components/node-list-tree/node-list-row/node-list-row.scss b/src/components/node-list-tree/node-list-row/node-list-row.scss new file mode 100755 index 0000000000..346a8f533e --- /dev/null +++ b/src/components/node-list-tree/node-list-row/node-list-row.scss @@ -0,0 +1,87 @@ +@use '../../../styles/variables' as var; +@use '../../node-list-tree/styles/variables' as variables; + +.MuiTreeItem-iconContainer svg { + z-index: var.$zindex-MuiTreeItem-icon; +} + +.node-list-row { + align-items: center; + cursor: default; + display: flex; + height: 32px; + position: relative; + transform: translate(0, 0); + + &:hover, + &--active { + background-color: var(--color-nodelist-row-selected); + } + + &--selected { + // Additional selector required to increase specificity to override previous rule + background-color: var(--color-nodelist-row-selected); + border-right: 1px solid var.$blue-300; + } + + // to ensure the background of the row covers the full width on hover + &::before { + position: absolute; + top: 0; + bottom: 0; + left: -100px; + width: 100px; + background: var(--color-nodelist-row-selected); + transform: translate(0, 0); + opacity: 0; + content: ' '; + pointer-events: none; + } +} + +.MuiTreeItem-content:hover { + .node-list-row__type-icon path { + opacity: 1; + } +} + +.node-list-row--active::before, +.node-list-row--selected::before, +.node-list-row:hover::before { + opacity: 1; +} + +.node-list-row__icon { + display: block; + flex-shrink: 0; + width: variables.$row-icon-size; + height: variables.$row-icon-size; + fill: var(--color-text); + + &--disabled > * { + opacity: 0.1; + } +} + +.node-list-row__type-icon { + &--nested > * { + opacity: 0.3; + } + + &--faded > * { + opacity: 0.2; + } + + &--active, + &--selected, + .node-list-row--visible:hover &, + [data-whatintent='keyboard'] .node-list-row__text:focus & { + > * { + opacity: 1; + } + + &--faded > * { + opacity: 0.55; + } + } +} diff --git a/src/components/node-list-tree/node-list-row/node-list-row.test.js b/src/components/node-list-tree/node-list-row/node-list-row.test.js new file mode 100644 index 0000000000..eda3cf1bf0 --- /dev/null +++ b/src/components/node-list-tree/node-list-row/node-list-row.test.js @@ -0,0 +1,78 @@ +import React from 'react'; +import NodeListRow from './node-list-row'; +import { setup } from '../../../utils/state.mock'; + +// Mock props +const mockProps = { + name: 'Test Row', + kind: 'modular-pipeline', + active: false, + disabled: false, + selected: false, + visible: true, + onMouseEnter: jest.fn(), + onMouseLeave: jest.fn(), + onClick: jest.fn(), + icon: null, + type: 'modularPipeline', + checked: true, + focused: false, +}; + +describe('NodeListRow Component', () => { + it('renders without crashing', () => { + expect(() => setup.mount()).not.toThrow(); + }); + + it('handles mouseenter events', () => { + const wrapper = setup.mount(); + const nodeRow = () => wrapper.find('.node-list-row'); + nodeRow().simulate('mouseenter'); + expect(mockProps.onMouseEnter.mock.calls.length).toEqual(1); + }); + + it('handles mouseleave events', () => { + const wrapper = setup.mount(); + const nodeRow = () => wrapper.find('.node-list-row'); + nodeRow().simulate('mouseleave'); + expect(mockProps.onMouseLeave.mock.calls.length).toEqual(1); + }); + + it('applies the node-list-row--active class when active is true', () => { + const wrapper = setup.mount(); + expect( + wrapper.find('.node-list-row').hasClass('node-list-row--active') + ).toBe(true); + }); + + it('applies the node-list-row--selected class when selected is true', () => { + const wrapper = setup.mount(); + expect( + wrapper.find('.node-list-row').hasClass('node-list-row--selected') + ).toBe(true); + }); + + it('applies the node-list-row--selected class when highlight is true and isSlicingPipelineApplied is false', () => { + const wrapper = setup.mount( + + ); + expect( + wrapper.find('.node-list-row').hasClass('node-list-row--selected') + ).toBe(true); + }); + + it('applies the overwrite class if not selected or active', () => { + const activeNodeWrapper = setup.mount( + + ); + expect( + activeNodeWrapper + .find('.node-list-row') + .hasClass('node-list-row--overwrite') + ).toBe(true); + }); +}); diff --git a/src/components/node-list/node-list-tree-item.js b/src/components/node-list-tree/node-list-tree-item/node-list-tree-item.js similarity index 72% rename from src/components/node-list/node-list-tree-item.js rename to src/components/node-list-tree/node-list-tree-item/node-list-tree-item.js index 5a08c0ca25..488ab74d21 100644 --- a/src/components/node-list/node-list-tree-item.js +++ b/src/components/node-list-tree/node-list-tree-item/node-list-tree-item.js @@ -1,8 +1,10 @@ import React from 'react'; +import classnames from 'classnames'; import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; import ChevronRightIcon from '@mui/icons-material/ChevronRight'; import { TreeItem } from '@mui/x-tree-view'; -import NodeListRow from './node-list-row'; +import NodeListRow from '../node-list-row/node-list-row'; +import { getDataTestAttribute } from '../../../utils/get-data-test-attribute'; const arrowIconColor = '#8e8e90'; @@ -12,11 +14,15 @@ const NodeListTreeItem = ({ onItemMouseEnter, onItemMouseLeave, onItemChange, + onToggleHoveredFocusMode, children, isSlicingPipelineApplied, }) => ( } label={ onItemClick(data)} - onMouseEnter={() => onItemMouseEnter(data)} - onMouseLeave={() => onItemMouseLeave(data)} + isSlicingPipelineApplied={isSlicingPipelineApplied} + key={data.id} + kind="element" + label={data.highlightedLabel || data.name} + name={data.name} onChange={(e) => onItemChange(data, !e.target.checked, e.target.dataset.iconType) } + onClick={(e) => onItemClick(e, data)} + onMouseEnter={() => onItemMouseEnter(data)} + onMouseLeave={() => onItemMouseLeave(data)} + onToggleHoveredFocusMode={onToggleHoveredFocusMode} + parentClassName={'node-list-tree-item-row'} rowType="tree" - focused={data.focused} + selected={data.selected} + type={data.type} + visible={data.visible} + visibleIcon={data.visibleIcon} /> } > diff --git a/src/components/node-list/node-list-tree.js b/src/components/node-list-tree/node-list-tree.js similarity index 77% rename from src/components/node-list/node-list-tree.js rename to src/components/node-list-tree/node-list-tree.js index fa89c3fec8..fdb5df54d3 100644 --- a/src/components/node-list/node-list-tree.js +++ b/src/components/node-list-tree/node-list-tree.js @@ -1,5 +1,4 @@ import React from 'react'; -import { connect } from 'react-redux'; import uniqueId from 'lodash/uniqueId'; import { styled } from '@mui/system'; @@ -8,14 +7,13 @@ import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; import ChevronRightIcon from '@mui/icons-material/ChevronRight'; import sortBy from 'lodash/sortBy'; -import { loadNodeData } from '../../actions/nodes'; -import { getNodeSelected } from '../../selectors/nodes'; import { isModularPipelineType } from '../../selectors/node-types'; -import NodeListTreeItem from './node-list-tree-item'; +import NodeListTreeItem from './node-list-tree-item/node-list-tree-item'; import VisibleIcon from '../icons/visible'; import InvisibleIcon from '../icons/invisible'; import FocusModeIcon from '../icons/focus-mode'; -import { getSlicedPipeline } from '../../selectors/sliced-pipeline'; + +import './styles/node-list.scss'; // Display order of node groups const GROUPED_NODES_DISPLAY_ORDER = { @@ -36,20 +34,6 @@ const StyledTreeView = styled(TreeView)({ padding: '0 0 0 20px', }); -/** - * Return whether the given modular pipeline ID is on focus mode path, i.e. - * it's not the currently focused pipeline nor one of its children. - * @param {String} focusModeID The currently focused modular pipeline ID. - * @param {String} modularPipelineID The modular pipeline ID to check. - * @return {Boolean} Whether the given modular pipeline ID is on focus mode path. - */ -const isOnFocusedModePath = (focusModeID, modularPipelineID) => { - return ( - modularPipelineID === focusModeID || - modularPipelineID.startsWith(`${focusModeID}.`) - ); -}; - /** * Return the data of a modular pipeline to display as a row in the node list. * @param {Object} params @@ -94,16 +78,17 @@ const getModularPipelineRowData = ({ * @param {Boolean} selected Whether the node is currently disabled * @param {Boolean} selected Whether the node is currently selected */ -const getNodeRowData = (node, disabled, selected, highlight) => { +const getNodeRowData = (node, disabled, hoveredNode, selected, highlight) => { const checked = !node.disabledNode; + return { ...node, visibleIcon: VisibleIcon, invisibleIcon: InvisibleIcon, - active: node.active, + active: node.active || hoveredNode === node.id, selected, highlight, - faded: disabled || node.disabledNode, + faded: disabled || !checked, visible: !disabled && checked, checked, disabled, @@ -111,49 +96,45 @@ const getNodeRowData = (node, disabled, selected, highlight) => { }; const TreeListProvider = ({ + hoveredNode, nodeSelected, modularPipelinesSearchResult, modularPipelinesTree, onItemChange, onItemMouseEnter, onItemMouseLeave, + onToggleHoveredFocusMode, onItemClick, onNodeToggleExpanded, focusMode, - disabledModularPipeline, expanded, onToggleNodeSelected, slicedPipeline, isSlicingPipelineApplied, + nodesDisabledViaModularPipeline, }) => { // render a leaf node in the modular pipelines tree const renderLeafNode = (node) => { // As part of the slicing pipeline logic, child nodes not included in the sliced pipeline are assigned an empty data object. // Therefore, if a child node has an empty data object, it indicates it's not part of the slicing pipeline and should not be rendered. - if (Object.keys(node).length === 0) { + if (!node || Object.keys(node).length === 0) { return null; } const disabled = node.disabledTag || node.disabledType || - (focusMode && - !node.modularPipelines - .map((modularPipelineID) => - isOnFocusedModePath(focusMode.id, modularPipelineID) - ) - .some(Boolean)) || - (node.modularPipelines && - node.modularPipelines - .map( - (modularPipelineID) => disabledModularPipeline[modularPipelineID] - ) - .some(Boolean)); + nodesDisabledViaModularPipeline[node.id]; const selected = nodeSelected[node.id]; - const highlight = slicedPipeline.includes(node.id); - const data = getNodeRowData(node, disabled, selected, highlight); + const data = getNodeRowData( + node, + disabled, + hoveredNode, + selected, + highlight + ); return ( ({ - nodeSelected: getNodeSelected(state), - expanded: state.modularPipeline.expanded, - slicedPipeline: getSlicedPipeline(state), - isSlicingPipelineApplied: state.slice.apply, -}); - -export const mapDispatchToProps = (dispatch) => ({ - onToggleNodeSelected: (nodeID) => { - dispatch(loadNodeData(nodeID)); - }, -}); - -export default connect(mapStateToProps, mapDispatchToProps)(TreeListProvider); +export default TreeListProvider; diff --git a/src/components/node-list/styles/_panels.scss b/src/components/node-list-tree/styles/_panels.scss similarity index 100% rename from src/components/node-list/styles/_panels.scss rename to src/components/node-list-tree/styles/_panels.scss diff --git a/src/components/node-list/styles/_variables.scss b/src/components/node-list-tree/styles/_variables.scss similarity index 100% rename from src/components/node-list/styles/_variables.scss rename to src/components/node-list-tree/styles/_variables.scss diff --git a/src/components/node-list/styles/node-list.scss b/src/components/node-list-tree/styles/node-list.scss similarity index 58% rename from src/components/node-list/styles/node-list.scss rename to src/components/node-list-tree/styles/node-list.scss index 3d45c4f370..d3ca6ac65c 100644 --- a/src/components/node-list/styles/node-list.scss +++ b/src/components/node-list-tree/styles/node-list.scss @@ -1,11 +1,6 @@ @use '../../../styles/mixins' as mixins; @use '../../../styles/variables' as colors; -@use './group'; @use './panels'; -@use './row'; -@use './row-label'; -@use './row-toggle'; -@use './section'; @use './variables'; .kui-theme--light { @@ -84,12 +79,67 @@ } } +// Root class for overwriting styles of the pipeline tree item .pipeline-treeItem__root--overwrite { + position: relative; + .Mui-selected { - background-color: transparent !important; + background-color: transparent !important; // Override default background color } .MuiTreeItem-content { - padding: 0; + padding: 0; // Remove padding } + + // When hovering over the tree item content + .MuiTreeItem-content:hover { + background-color: var(--color-nodelist-row-active) !important; + + &::before { + position: absolute; + top: 0; + bottom: 0; + left: -100px; + width: 100px; + background: var(--color-nodelist-row-active); + transform: translate(0, 0); + opacity: 1; + content: ' '; + pointer-events: none; + } + + // If it represents the modular pipeline node, change the color of the sibling .MuiTreeItem-group + ~ .MuiTreeItem-group { + background-color: var(--color-nodelist-row-active); + position: relative; + + // Ensure all .row__type-icon path elements have opacity 1 + .node-list-row__type-icon path { + opacity: 1; + } + + // Apply the after-shadow mixin to ensure the background covers the full width on hover + &::after { + content: ''; + position: absolute; + left: -40px; + top: 0; + height: 100%; // Match the height of the parent + width: 50px; + background-color: var(--color-nodelist-row-active); + } + } + } +} + +// disable mouse events for the overwrite disabled class +.pipeline-treeItem__root--overwrite--disabled { + pointer-events: none; +} + +.pipeline-nodelist__elements-panel .MuiTreeItem-label { + // Handle MuiTreeItem icon offset for correct width + $icon-offset: 15px + 4px; + + width: calc(100% - #{$icon-offset}); } diff --git a/src/components/node-list/index.js b/src/components/node-list/index.js deleted file mode 100644 index 74353d8944..0000000000 --- a/src/components/node-list/index.js +++ /dev/null @@ -1,379 +0,0 @@ -import React, { useEffect, useState } from 'react'; -import { connect } from 'react-redux'; -import debounce from 'lodash/debounce'; -import NodeList from './node-list'; -import { - getFilteredItems, - getGroups, - isTagType, - isElementType, - isGroupType, -} from './node-list-items'; -import { - getNodeTypes, - isModularPipelineType, -} from '../../selectors/node-types'; -import { getTagData, getTagNodeCounts } from '../../selectors/tags'; -import { - getFocusedModularPipeline, - getModularPipelinesSearchResult, -} from '../../selectors/modular-pipelines'; -import { - getGroupedNodes, - getNodeSelected, - getInputOutputNodesForFocusedModularPipeline, - getModularPipelinesTree, -} from '../../selectors/nodes'; -import { toggleTagActive, toggleTagFilter } from '../../actions/tags'; -import { toggleTypeDisabled } from '../../actions/node-type'; -import { toggleParametersHovered, toggleFocusMode } from '../../actions'; -import { - toggleModularPipelineActive, - toggleModularPipelineDisabled, - toggleModularPipelinesExpanded, -} from '../../actions/modular-pipelines'; -import { resetSlicePipeline } from '../../actions/slice'; -import { - loadNodeData, - toggleNodeHovered, - toggleNodesDisabled, -} from '../../actions/nodes'; -import { useGeneratePathname } from '../../utils/hooks/use-generate-pathname'; -import './styles/node-list.scss'; -import { params, NODE_TYPES } from '../../config'; - -/** - * Provides data from the store to populate a NodeList component. - * Also handles user interaction and dispatches updates back to the store. - */ -const NodeListProvider = ({ - faded, - nodes, - nodeSelected, - tags, - tagNodeCounts, - nodeTypes, - onToggleNodesDisabled, - onToggleNodeSelected, - onToggleNodeActive, - onToggleParametersActive, - onToggleTagActive, - onToggleTagFilter, - onToggleModularPipelineActive, - onToggleModularPipelineDisabled, - onToggleModularPipelineExpanded, - onToggleTypeDisabled, - onToggleFocusMode, - modularPipelinesTree, - focusMode, - disabledModularPipeline, - inputOutputDataNodes, - onResetSlicePipeline, - isSlicingPipelineApplied, -}) => { - const [searchValue, updateSearchValue] = useState(''); - const [isResetFilterActive, setIsResetFilterActive] = useState(false); - - const { - toSelectedPipeline, - toSelectedNode, - toFocusedModularPipeline, - toUpdateUrlParamsOnResetFilter, - toUpdateUrlParamsOnFilter, - toSetQueryParam, - } = useGeneratePathname(); - - const items = getFilteredItems({ - nodes, - tags, - nodeTypes, - tagNodeCounts, - nodeSelected, - searchValue, - focusMode, - inputOutputDataNodes, - }); - - const modularPipelinesSearchResult = searchValue - ? getModularPipelinesSearchResult(modularPipelinesTree, searchValue) - : null; - - const groups = getGroups({ items }); - - const onItemClick = (item) => { - if (isGroupType(item.type)) { - onGroupItemChange(item, item.checked); - } else if (isModularPipelineType(item.type)) { - onToggleNodeSelected(null); - } else { - if (item.faded || item.selected) { - onToggleNodeSelected(null); - toSelectedPipeline(); - } else { - onToggleNodeSelected(item.id); - toSelectedNode(item); - // Reset the pipeline slicing filters if no slicing is currently applied - if (!isSlicingPipelineApplied) { - onResetSlicePipeline(); - } - } - } - }; - - // To get existing values from URL query parameters - const getExistingValuesFromUrlQueryParams = (paramName, searchParams) => { - const paramValues = searchParams.get(paramName); - return new Set(paramValues ? paramValues.split(',') : []); - }; - - const handleUrlParamsUpdateOnFilter = (item) => { - const searchParams = new URLSearchParams(window.location.search); - const paramName = isElementType(item.type) ? params.types : params.tags; - const existingValues = getExistingValuesFromUrlQueryParams( - paramName, - searchParams - ); - - toUpdateUrlParamsOnFilter(item, paramName, existingValues); - }; - - // To update URL query parameters when a filter group is clicked - const handleUrlParamsUpdateOnGroupFilter = ( - groupType, - groupItems, - groupItemsDisabled - ) => { - if (groupItemsDisabled) { - // If all items in group are disabled - groupItems.forEach((item) => { - handleUrlParamsUpdateOnFilter(item); - }); - } else { - // If some items in group are enabled - const paramName = isElementType(groupType) ? params.types : params.tags; - toSetQueryParam(paramName, []); - } - }; - - const onItemChange = (item, checked, clickedIconType) => { - if (isGroupType(item.type) || isModularPipelineType(item.type)) { - onGroupItemChange(item, checked); - - // Update URL query parameters when a filter item is clicked - if (!clickedIconType) { - handleUrlParamsUpdateOnFilter(item); - } - - if (isModularPipelineType(item.type)) { - if (clickedIconType === 'focus') { - if (focusMode === null) { - onToggleFocusMode(item); - toFocusedModularPipeline(item); - - if (disabledModularPipeline[item.id]) { - onToggleModularPipelineDisabled([item.id], checked); - } - } else { - onToggleFocusMode(null); - toSelectedPipeline(); - } - } else { - onToggleModularPipelineDisabled([item.id], checked); - onToggleModularPipelineActive([item.id], false); - } - } - } else { - if (checked) { - onToggleNodeActive(null); - } - - onToggleNodesDisabled([item.id], checked); - } - }; - - const onItemMouseEnter = (item) => { - if (isTagType(item.type)) { - onToggleTagActive(item.id, true); - } else if (isModularPipelineType(item.type)) { - onToggleModularPipelineActive(item.id, true); - } else if (isElementType(item.type) && item.id === 'parameters') { - // Show parameters highlight when mouse enter parameters filter item - onToggleParametersActive(true); - } else if (item.visible) { - onToggleNodeActive(item.id); - } - }; - - const onItemMouseLeave = (item) => { - if (isTagType(item.type)) { - onToggleTagActive(item.id, false); - } else if (isModularPipelineType(item.type)) { - onToggleModularPipelineActive(item.id, false); - } else if (isElementType(item.type) && item.id === 'parameters') { - // Hide parameters highlight when mouse leave parameters filter item - onToggleParametersActive(false); - } else if (item.visible) { - onToggleNodeActive(null); - } - }; - - const onGroupToggleChanged = (groupType) => { - // Enable all items in group if none enabled, otherwise disable all of them - const groupItems = items[groupType] || []; - const groupItemsDisabled = groupItems.every( - (groupItem) => !groupItem.checked - ); - - // Update URL query parameters when a filter group is clicked - handleUrlParamsUpdateOnGroupFilter( - groupType, - groupItems, - groupItemsDisabled - ); - - if (isTagType(groupType)) { - onToggleTagFilter( - groupItems.map((item) => item.id), - groupItemsDisabled - ); - } else if (isElementType(groupType)) { - onToggleTypeDisabled( - groupItems.reduce( - (state, item) => ({ ...state, [item.id]: !groupItemsDisabled }), - {} - ) - ); - } - }; - - const handleToggleModularPipelineExpanded = (expanded) => { - onToggleModularPipelineExpanded(expanded); - }; - - const onGroupItemChange = (item, wasChecked) => { - // Toggle the group - if (isTagType(item.type)) { - onToggleTagFilter(item.id, !wasChecked); - } else if (isElementType(item.type)) { - onToggleTypeDisabled({ [item.id]: wasChecked }); - } - - // Reset node selection - onToggleNodeSelected(null); - onToggleNodeActive(null); - }; - - // Deselect node on Escape key - const handleKeyDown = (event) => { - if (event.keyCode === 27) { - onToggleNodeSelected(null); - } - }; - - // Reset applied filters to default - const onResetFilter = () => { - onToggleTypeDisabled({ task: false, data: false, parameters: true }); - onToggleTagFilter( - tags.map((item) => item.id), - false - ); - - toUpdateUrlParamsOnResetFilter(); - }; - - // Helper function to check if NodeTypes is modified - const hasModifiedNodeTypes = (nodeTypes) => { - return nodeTypes.some( - (item) => NODE_TYPES[item.id]?.defaultState !== item.disabled - ); - }; - - // Updates the reset filter button status based on the node types and tags. - useEffect(() => { - const isNodeTypeModified = hasModifiedNodeTypes(nodeTypes); - const isNodeTagModified = tags.some((tag) => tag.enabled); - setIsResetFilterActive(isNodeTypeModified || isNodeTagModified); - }, [tags, nodeTypes]); - - useEffect(() => { - window.addEventListener('keydown', handleKeyDown); - return () => window.removeEventListener('keydown', handleKeyDown); - }); - - return ( - - ); -}; - -export const mapStateToProps = (state) => ({ - tags: getTagData(state), - tagNodeCounts: getTagNodeCounts(state), - nodes: getGroupedNodes(state), - nodeSelected: getNodeSelected(state), - nodeTypes: getNodeTypes(state), - focusMode: getFocusedModularPipeline(state), - disabledModularPipeline: state.modularPipeline.disabled, - inputOutputDataNodes: getInputOutputNodesForFocusedModularPipeline(state), - modularPipelinesTree: getModularPipelinesTree(state), - isSlicingPipelineApplied: state.slice.apply, -}); - -export const mapDispatchToProps = (dispatch) => ({ - onToggleTagActive: (tagIDs, active) => { - dispatch(toggleTagActive(tagIDs, active)); - }, - onToggleTagFilter: (tagIDs, enabled) => { - dispatch(toggleTagFilter(tagIDs, enabled)); - }, - onToggleModularPipelineActive: (modularPipelineIDs, active) => { - dispatch(toggleModularPipelineActive(modularPipelineIDs, active)); - }, - onToggleModularPipelineDisabled: (modularPipelineIDs, disabled) => { - dispatch(toggleModularPipelineDisabled(modularPipelineIDs, disabled)); - }, - onToggleTypeDisabled: (typeID, disabled) => { - dispatch(toggleTypeDisabled(typeID, disabled)); - }, - onToggleNodeSelected: (nodeID) => { - dispatch(loadNodeData(nodeID)); - }, - onToggleModularPipelineExpanded: (expanded) => { - dispatch(toggleModularPipelinesExpanded(expanded)); - }, - onToggleNodeActive: (nodeID) => { - dispatch(toggleNodeHovered(nodeID)); - }, - onToggleParametersActive: (active) => { - dispatch(toggleParametersHovered(active)); - }, - onToggleNodesDisabled: (nodeIDs, disabled) => { - dispatch(toggleNodesDisabled(nodeIDs, disabled)); - }, - onToggleFocusMode: (modularPipeline) => { - dispatch(toggleFocusMode(modularPipeline)); - }, - onResetSlicePipeline: () => { - dispatch(resetSlicePipeline()); - }, -}); - -export default connect(mapStateToProps, mapDispatchToProps)(NodeListProvider); diff --git a/src/components/node-list/node-list-group.js b/src/components/node-list/node-list-group.js deleted file mode 100644 index 9b54a2d72b..0000000000 --- a/src/components/node-list/node-list-group.js +++ /dev/null @@ -1,77 +0,0 @@ -import React from 'react'; -import classnames from 'classnames'; -import NodeListRow from './node-list-row'; -import NodeRowList from './node-list-row-list'; - -export const NodeListGroup = ({ - allUnchecked, - checked, - collapsed, - group, - id, - invisibleIcon, - items, - kind, - name, - onItemChange, - onItemClick, - onItemMouseEnter, - onItemMouseLeave, - onToggleChecked, - onToggleCollapsed, - visibleIcon, -}) => { - const disabledGroup = items.length === 0; - - return ( -
  • -

    - { - onToggleChecked(id, !e.target.checked); - }} - rowType="filter" - visibleIcon={visibleIcon} - > -

    - -
  • - ); -}; - -export default NodeListGroup; diff --git a/src/components/node-list/node-list-group.test.js b/src/components/node-list/node-list-group.test.js deleted file mode 100644 index e8ca53fb8a..0000000000 --- a/src/components/node-list/node-list-group.test.js +++ /dev/null @@ -1,88 +0,0 @@ -import React from 'react'; -import { NodeListGroup } from './node-list-group'; -import { getNodeTypes } from '../../selectors/node-types'; -import { setup, mockState } from '../../utils/state.mock'; - -describe('NodeListGroup', () => { - const items = []; - - it('renders without throwing', () => { - const type = getNodeTypes(mockState.spaceflights)[0]; - expect(() => - setup.mount() - ).not.toThrow(); - }); - - it('handles checkbox change events', () => { - const type = getNodeTypes(mockState.spaceflights)[0]; - const onToggleChecked = jest.fn(); - const wrapper = setup.mount( - - ); - const checkbox = () => wrapper.find('input'); - checkbox().simulate('change', { target: { checked: false } }); - expect(onToggleChecked.mock.calls.length).toEqual(1); - }); - - it('handles collapse button click events', () => { - const type = getNodeTypes(mockState.spaceflights)[0]; - const onToggleCollapsed = jest.fn(); - const wrapper = setup.mount( - - ); - wrapper.find('.pipeline-type-group-toggle').simulate('click'); - expect(() => onToggleCollapsed.mock.calls.length.toEqual(1)).toThrow(); - }); - - it('adds class when collapsed prop true', () => { - const type = getNodeTypes(mockState.spaceflights)[0]; - const wrapper = setup.mount( - - ); - const children = wrapper.find('.pipeline-nodelist__children'); - expect(children.hasClass('pipeline-nodelist__children--closed')).toBe(true); - }); - - it('removes class when collapsed prop false', () => { - const type = getNodeTypes(mockState.spaceflights)[0]; - const wrapper = setup.mount( - - ); - const children = wrapper.find('.pipeline-nodelist__children'); - expect(children.hasClass('pipeline-nodelist__children--closed')).toBe( - false - ); - }); - - it('adds disabled class when items list is empty', () => { - const type = getNodeTypes(mockState.spaceflights)[0]; - const wrapper = setup.mount( - - ); - expect(items.length).toBe(0); - const button = () => wrapper.find('button'); - expect(button().hasClass('pipeline-type-group-toggle--disabled')).toBe( - true - ); - }); -}); diff --git a/src/components/node-list/node-list-groups.js b/src/components/node-list/node-list-groups.js deleted file mode 100644 index a91dd31e94..0000000000 --- a/src/components/node-list/node-list-groups.js +++ /dev/null @@ -1,60 +0,0 @@ -import React, { useState } from 'react'; -import { loadLocalStorage, saveLocalStorage } from '../../store/helpers'; -import NodeListGroup from './node-list-group'; -import { localStorageName } from '../../config'; - -const storedState = loadLocalStorage(localStorageName); - -const NodeListGroups = ({ - groups, - items, - onGroupToggleChanged, - onItemChange, - onItemClick, - onItemMouseEnter, - onItemMouseLeave, - searchValue, -}) => { - const [collapsed, setCollapsed] = useState(storedState.groupsCollapsed || {}); - - // Collapse/expand node group - const onToggleGroupCollapsed = (groupID) => { - const groupsCollapsed = { - ...collapsed, - [groupID]: !collapsed[groupID], - }; - - setCollapsed(groupsCollapsed); - saveLocalStorage(localStorageName, { groupsCollapsed }); - }; - - return ( - - ); -}; - -export default NodeListGroups; diff --git a/src/components/node-list/node-list-groups.test.js b/src/components/node-list/node-list-groups.test.js deleted file mode 100644 index abaa7d4d52..0000000000 --- a/src/components/node-list/node-list-groups.test.js +++ /dev/null @@ -1,49 +0,0 @@ -import React from 'react'; -import NodeListGroups from './node-list-groups'; -import { mockState, setup } from '../../utils/state.mock'; -import { getNodeTypes } from '../../selectors/node-types'; -import { getGroupedNodes } from '../../selectors/nodes'; -import { getGroups } from './node-list-items'; - -describe('NodeListGroups', () => { - const mockProps = () => { - const items = getGroupedNodes(mockState.spaceflights); - const nodeTypes = getNodeTypes(mockState.spaceflights); - const groups = getGroups({ nodeTypes, items }); - return { items, groups }; - }; - - it('renders without throwing', () => { - expect(() => - setup.mount() - ).not.toThrow(); - }); - - it('handles collapse button click events', () => { - const wrapper = setup.mount(); - const nodeList = () => - wrapper.find('.pipeline-nodelist__list--nested').first(); - const toggle = () => wrapper.find('.pipeline-type-group-toggle').first(); - expect(nodeList().length).toBe(1); - expect(toggle().hasClass('pipeline-type-group-toggle--alt')).toBe(false); - expect(() => { - toggle().hasClass('pipeline-type-group-toggle--disabled').toBe(false); - toggle().simulate('click'); - expect(nodeList().length).toBe(1); - expect(toggle().hasClass('pipeline-type-group-toggle--alt')).toBe(true); - }).toThrow(); - }); - - it('handles group checkbox change events', () => { - const onGroupToggleChanged = jest.fn(); - const wrapper = setup.mount( - - ); - const checkbox = () => wrapper.find('input').first(); - checkbox().simulate('change', { target: { checked: false } }); - expect(onGroupToggleChanged.mock.calls.length).toEqual(1); - }); -}); diff --git a/src/components/node-list/node-list-row-list.js b/src/components/node-list/node-list-row-list.js deleted file mode 100644 index 4566fbaafc..0000000000 --- a/src/components/node-list/node-list-row-list.js +++ /dev/null @@ -1,86 +0,0 @@ -import React from 'react'; -import modifiers from '../../utils/modifiers'; -import NodeListRow, { nodeListRowHeight } from './node-list-row'; -import LazyList from '../lazy-list'; - -const NodeRowList = ({ - items = [], - group, - collapsed, - onItemClick, - onItemChange, - onItemMouseEnter, - onItemMouseLeave, -}) => ( - (end - start) * nodeListRowHeight} - total={items.length} - > - {({ - start, - end, - total, - listRef, - upperRef, - lowerRef, - listStyle, - upperStyle, - lowerStyle, - }) => ( -
      -
    • 0, - })} - ref={upperRef} - style={upperStyle} - /> -
    • - {items.slice(start, end).map((item) => ( - onItemClick(item)} - onMouseEnter={() => onItemMouseEnter(item)} - onMouseLeave={() => onItemMouseLeave(item)} - onChange={(e) => onItemChange(item, !e.target.checked)} - rowType="filter" - /> - ))} -
    - )} -
    -); - -export default NodeRowList; diff --git a/src/components/node-list/node-list-row.js b/src/components/node-list/node-list-row.js deleted file mode 100644 index fdabf9d584..0000000000 --- a/src/components/node-list/node-list-row.js +++ /dev/null @@ -1,255 +0,0 @@ -import React, { memo } from 'react'; -import { connect } from 'react-redux'; -import classnames from 'classnames'; -import { changed, replaceAngleBracketMatches } from '../../utils'; -import NodeIcon from '../icons/node-icon'; -import VisibleIcon from '../icons/visible'; -import InvisibleIcon from '../icons/invisible'; -import FocusModeIcon from '../icons/focus-mode'; -import { getNodeActive } from '../../selectors/nodes'; -import { toggleHoveredFocusMode } from '../../actions'; - -// The exact fixed height of a row as measured by getBoundingClientRect() -export const nodeListRowHeight = 32; - -/** - * Returns `true` if there are no props changes, therefore the last render can be reused. - * Performance: Checks only the minimal set of props known to change after first render. - */ -const shouldMemo = (prevProps, nextProps) => - !changed( - [ - 'active', - 'checked', - 'allUnchecked', - 'disabled', - 'faded', - 'focused', - 'visible', - 'selected', - 'highlight', - 'label', - 'children', - 'count', - ], - prevProps, - nextProps - ); - -const NodeListRow = memo( - ({ - container: Container = 'div', - active, - checked, - allUnchecked, - children, - disabled, - faded, - focused, - visible, - id, - label, - count, - name, - kind, - onMouseEnter, - onMouseLeave, - onChange, - onClick, - selected, - highlight, - isSlicingPipelineApplied, - type, - icon, - visibleIcon = VisibleIcon, - invisibleIcon = InvisibleIcon, - focusModeIcon = FocusModeIcon, - rowType, - onToggleHoveredFocusMode, - }) => { - const isModularPipeline = type === 'modularPipeline'; - const FocusIcon = isModularPipeline ? focusModeIcon : null; - const isChecked = isModularPipeline ? checked || focused : checked; - const VisibilityIcon = isChecked ? visibleIcon : invisibleIcon; - const isButton = onClick && kind !== 'filter'; - const TextButton = isButton ? 'button' : 'div'; - - return ( - - {icon && ( - - )} - - - - {typeof count === 'number' && ( - - {count} - - )} - {VisibilityIcon && ( - - )} - {FocusIcon && ( - - )} - {children} - - ); - }, - shouldMemo -); - -export const mapDispatchToProps = (dispatch) => ({ - onToggleHoveredFocusMode: (active) => { - dispatch(toggleHoveredFocusMode(active)); - }, -}); - -export const mapStateToProps = (state, ownProps) => ({ - ...ownProps, - active: - typeof ownProps.active !== 'undefined' - ? ownProps.active - : getNodeActive(state)[ownProps.id] || false, -}); - -export default connect(mapStateToProps, mapDispatchToProps)(NodeListRow); diff --git a/src/components/node-list/node-list-row.test.js b/src/components/node-list/node-list-row.test.js deleted file mode 100644 index f4651e200f..0000000000 --- a/src/components/node-list/node-list-row.test.js +++ /dev/null @@ -1,191 +0,0 @@ -import React from 'react'; -import NodeListRow, { mapStateToProps } from './node-list-row'; -import { getNodeData } from '../../selectors/nodes'; -import { setup, mockState } from '../../utils/state.mock'; - -describe('NodeListRow', () => { - const node = getNodeData(mockState.spaceflights)[0]; - const setupProps = () => { - const props = { - active: true, - checked: true, - disabled: false, - faded: false, - visible: true, - id: node.id, - label: node.highlightedLabel, - name: node.name, - onClick: jest.fn(), - onMouseEnter: jest.fn(), - onMouseLeave: jest.fn(), - onChange: jest.fn(), - }; - return { props }; - }; - - it('renders without throwing', () => { - expect(() => setup.mount()).not.toThrow(); - }); - - describe('node list item', () => { - it('handles mouseenter events', () => { - const { props } = setupProps(); - const wrapper = setup.mount(); - const nodeRow = () => wrapper.find('.pipeline-nodelist__row'); - nodeRow().simulate('mouseenter'); - expect(props.onMouseEnter.mock.calls.length).toEqual(1); - }); - - it('handles mouseleave events', () => { - const { props } = setupProps(); - const wrapper = setup.mount(); - const nodeRow = () => wrapper.find('.pipeline-nodelist__row'); - nodeRow().simulate('mouseleave'); - expect(props.onMouseLeave.mock.calls.length).toEqual(1); - }); - - it('applies the overwrite class if not active', () => { - const { props } = setupProps(); - const activeNodeWrapper = setup.mount( - - ); - expect( - activeNodeWrapper - .find('.pipeline-nodelist__row') - .hasClass('pipeline-nodelist__row--overwrite') - ).toBe(true); - }); - - it('applies the overwrite class if not selected or active', () => { - const { props } = setupProps(); - const activeNodeWrapper = setup.mount( - - ); - expect( - activeNodeWrapper - .find('.pipeline-nodelist__row') - .hasClass('pipeline-nodelist__row--overwrite') - ).toBe(true); - }); - - it('does not applies the overwrite class if not selected', () => { - const { props } = setupProps(); - const activeNodeWrapper = setup.mount( - - ); - expect( - activeNodeWrapper - .find('.pipeline-nodelist__row') - .hasClass('pipeline-nodelist__row--overwrite') - ).toBe(false); - }); - - it('does not applies the overwrite class if active', () => { - const { props } = setupProps(); - const activeNodeWrapper = setup.mount( - - ); - expect( - activeNodeWrapper - .find('.pipeline-nodelist__row') - .hasClass('pipeline-nodelist__row--overwrite') - ).toBe(false); - }); - - it('uses active class if active', () => { - const { props } = setupProps(); - const activeNodeWrapper = setup.mount( - - ); - expect( - activeNodeWrapper - .find('.pipeline-nodelist__row') - .hasClass('pipeline-nodelist__row--active') - ).toBe(true); - }); - - it('uses disabled class if disabled (via type/tag only)', () => { - const { props } = setupProps(); - const disabledNodeWrapper = setup.mount( - - ); - expect( - disabledNodeWrapper - .find('.pipeline-nodelist__row') - .hasClass('pipeline-nodelist__row--disabled') - ).toBe(true); - }); - - it('shows count if count prop set', () => { - const { props } = setupProps(); - const mockCount = 123; - const wrapper = setup.mount(); - expect(wrapper.find('.pipeline-nodelist__row__count').text()).toBe( - mockCount.toString() - ); - }); - - it('does not show count if count prop not set', () => { - const { props } = setupProps(); - const wrapper = setup.mount(); - expect(wrapper.find('.pipeline-nodelist__row__count').exists()).toBe( - false - ); - }); - - describe('focus mode', () => { - it('sets the focus toggle to the checked mode when the row is selected for focus mode', () => { - const { props } = setupProps(); - const wrapper = setup.mount( - - ); - - expect( - wrapper.find('.pipeline-row__toggle-icon--focus-checked').exists() - ).toBe(true); - }); - - it('hides the visibility toggle when the row is selected for focus mode', () => { - const { props } = setupProps(); - const wrapper = setup.mount( - - ); - - expect(wrapper.find('.pipeline-row__toggle--disabled').exists()).toBe( - true - ); - }); - - it('switches the visibility toggle from hide to show when the row is selected for focus mode', () => { - const { props } = setupProps(); - const wrapper = setup.mount( - - ); - expect(wrapper.find('VisibleIcon')).toHaveLength(1); - }); - }); - }); - - describe('node list item checkbox', () => { - const { props } = setupProps(); - const wrapper = setup.mount(); - const checkbox = () => wrapper.find('input'); - - it('handles toggle event', () => { - checkbox().simulate('change', { target: { checked: false } }); - expect(props.onChange.mock.calls.length).toEqual(1); - }); - }); - - it('maps state to props', () => { - const expectedResult = expect.objectContaining({ - active: expect.any(Boolean), - }); - expect(mapStateToProps(mockState.spaceflights, {})).toEqual(expectedResult); - }); -}); diff --git a/src/components/node-list/node-list.js b/src/components/node-list/node-list.js deleted file mode 100644 index 0106c0594c..0000000000 --- a/src/components/node-list/node-list.js +++ /dev/null @@ -1,116 +0,0 @@ -import React from 'react'; -import classnames from 'classnames'; -import { Scrollbars } from 'react-custom-scrollbars-2'; -import SearchList from '../search-list'; -import NodeListGroups from './node-list-groups'; -import NodeListTree from './node-list-tree'; -import SplitPanel from '../split-panel'; - -import './styles/node-list.scss'; - -/** - * Scrollable list of toggleable items, with search & filter functionality - */ -const NodeList = ({ - faded, - items, - modularPipelinesTree, - modularPipelinesSearchResult, - groups, - searchValue, - getGroupState, - onUpdateSearchValue, - onGroupToggleChanged, - onItemClick, - onItemMouseEnter, - onItemMouseLeave, - onItemChange, - onModularPipelineToggleExpanded, - focusMode, - disabledModularPipeline, - onResetFilter, - isResetFilterActive, -}) => { - return ( -
    - - - {({ isResizing, props: { container, panelA, panelB, handle } }) => ( -
    -
    - -
    - -
    -
    -
    -
    -
    - -
    -

    - Filters -

    - -
    - -
    -
    -
    - )} - -
    - ); -}; - -export default NodeList; diff --git a/src/components/node-list/styles/_group.scss b/src/components/node-list/styles/_group.scss deleted file mode 100644 index 0d456bd2f5..0000000000 --- a/src/components/node-list/styles/_group.scss +++ /dev/null @@ -1,173 +0,0 @@ -@use '../../../styles/variables' as var; -@use './variables'; - -%nolist { - margin: 0; - padding: 0; - list-style: none; -} - -.pipeline-nodelist__list { - @extend %nolist; - - &--nested { - margin: 0 0 1.2em; - } - - .pipeline-nodelist__children { - // Avoid placeholder fade leaking out for small lists - overflow: hidden; - - &--closed { - display: none; - } - } -} - -$placeholder-fade: 120px; - -.pipeline-nodelist__placeholder-upper, -.pipeline-nodelist__placeholder-lower { - z-index: var.$zindex-nodelist-placeholder; - pointer-events: none; -} - -.pipeline-nodelist__placeholder-upper::after, -.pipeline-nodelist__placeholder-lower::after { - position: absolute; - width: 100%; - height: $placeholder-fade; - opacity: 0; - transition: opacity ease 0.3s; - content: ' '; - pointer-events: none; -} - -// Add fade overlay at the lazy list boundaries visible during scroll -.pipeline-nodelist__filter-panel { - .pipeline-nodelist__placeholder-upper::after { - bottom: -$placeholder-fade; - background: linear-gradient( - 0deg, - var(--color-nodelist-bg-filter-transparent) 0%, - var(--color-nodelist-filter-panel) 100% - ); - } - - .pipeline-nodelist__placeholder-lower::after { - top: -$placeholder-fade; - background: linear-gradient( - 0deg, - var(--color-nodelist-filter-panel) 0%, - var(--color-nodelist-bg-filter-transparent) 100% - ); - } -} - -.pipeline-nodelist__placeholder-upper--fade::after, -.pipeline-nodelist__placeholder-lower--fade::after { - opacity: 1; -} - -.pipeline-nodelist__heading { - position: sticky; - top: 0; - z-index: var.$zindex-nodelist-heading; - margin: 0; - - // Avoid pixel gap above when scrolling. - transform: translateY(-1px); - - .pipeline-nodelist__row__text { - position: relative; - opacity: 0.65; - } - - .pipeline-nodelist__row__text .pipeline-nodelist__row__label { - font-size: 1.3em; - } -} - -.pipeline-nodelist__elements-panel .pipeline-nodelist__heading { - background: var(--color-nodelist-element-panel); - - &::after { - position: absolute; - bottom: -19px; - z-index: var.$zindex-group-background-fade; - width: 100%; - height: 20px; - background: linear-gradient( - 0deg, - var(--color-nodelist-bg-transparent) 0%, - var(--color-nodelist-element-panel) 100% - ); - transition: opacity ease 0.3s; - content: ' '; - pointer-events: none; - } -} - -.pipeline-nodelist__filter-panel .pipeline-nodelist__heading { - background: var(--color-nodelist-filter-panel); - - &::after { - position: absolute; - bottom: -19px; - z-index: var.$zindex-group-background-fade; - width: 100%; - height: 20px; - background: linear-gradient( - 0deg, - var(--color-nodelist-bg-transparent) 0%, - var(--color-nodelist-filter-panel) 100% - ); - transition: opacity ease 0.3s; - content: ' '; - pointer-events: none; - } -} - -.pipeline-type-group-toggle { - width: variables.$toggle-size; - height: variables.$toggle-size; - padding: 0; - color: var(--color-default-alt); - font-size: inherit; - font-family: inherit; - line-height: 1em; - text-align: center; - background: none; - border: none; - border-radius: 50%; - box-shadow: none; - cursor: pointer; - transition: transform ease 0.1s; - - &:focus { - outline: none; - - [data-whatintent='keyboard'] & { - box-shadow: 0 0 0 3px var.$blue-300 inset; - } - } - - &::before { - font-size: 1.8em; - opacity: 0.55; - content: '▾'; - } - - &:hover::before { - opacity: 1; - } - - &--alt { - transform: rotate(90deg); - } - - &--disabled { - color: var.$black-400; - transform: rotate(90deg); - } -} diff --git a/src/components/node-list/styles/_row-label.scss b/src/components/node-list/styles/_row-label.scss deleted file mode 100644 index 72fc48a6c8..0000000000 --- a/src/components/node-list/styles/_row-label.scss +++ /dev/null @@ -1,102 +0,0 @@ -@use '../../../styles/variables' as colors; -@use './variables'; - -.pipeline-nodelist__elements-panel .MuiTreeItem-label { - // Handle MuiTreeItem icon offset for correct width - $icon-offset: 15px + 4px; - - width: calc(100% - #{$icon-offset}); -} - -.pipeline-nodelist__row__text { - display: flex; - align-items: center; - - // Fixed with required for overflow elipsis - width: calc(100% - 7em); - margin-right: auto; - padding: variables.$row-padding-y 0 variables.$row-padding-y 0; - color: inherit; - font-size: inherit; - font-family: inherit; - line-height: 1.6; - letter-spacing: inherit; - text-align: inherit; - background: none; - border: none; - border-radius: 0; - box-shadow: none; - cursor: default; - user-select: none; - - &--tree { - padding: variables.$row-padding-y 0 variables.$row-padding-y 1em; - } - - &:focus { - outline: none; - box-shadow: 0 0 0 4px colors.$blue-300 inset; - - [data-whatintent='mouse'] & { - box-shadow: none; - } - } -} - -.pipeline-nodelist__row__label { - overflow: hidden; - font-size: 1.4em; - white-space: nowrap; - text-overflow: ellipsis; - - &--faded { - opacity: 0.65; - } - - &--disabled { - opacity: 0.3 !important; - } - - b { - color: var(--color-nodelist-highlight); - font-weight: normal; - } -} - -.pipeline-nodelist__row__count { - display: inline-block; - flex-shrink: 0; - width: 2.2em; - margin: 0 0.7em 0.1em auto; - overflow: hidden; - font-size: 1.16em; - text-align: right; - text-overflow: ellipsis; - opacity: 0.75; - user-select: none; - - .pipeline-nodelist__row--unchecked & { - opacity: 0.55; - } -} - -.pipeline-nodelist__row--unchecked { - // Fade row text when unchecked - .pipeline-nodelist__row__label--kind-filter { - opacity: 0.55; - } - - // Brighter row text when unchecked and hovered - &:hover { - .pipeline-nodelist__row__label--kind-filter { - opacity: 0.8; - } - } - - // Bright row text when all unchecked - .pipeline-nodelist__group--all-unchecked & { - .pipeline-nodelist__row__label--kind-filter { - opacity: 1; - } - } -} diff --git a/src/components/node-list/styles/_row.scss b/src/components/node-list/styles/_row.scss deleted file mode 100644 index 409be89666..0000000000 --- a/src/components/node-list/styles/_row.scss +++ /dev/null @@ -1,116 +0,0 @@ -@use '../../../styles/variables' as var; -@use './variables'; - -.MuiTreeItem-iconContainer svg { - z-index: var.$zindex-MuiTreeItem-icon; -} - -.pipeline-nodelist__row { - position: relative; - display: flex; - align-items: center; - height: 32px; // Fixed row height required for lazy list, apply any changes to node-list-row.js. - transform: translate( - 0, - 0 - ); // Force GPU layers to avoid drawing lag on scroll. - - background-color: initial; - cursor: default; - - &--overwrite { - .Mui-selected & { - .kui-theme--dark & { - background-color: var.$slate-200; - } - - .kui-theme--light & { - background-color: var.$white-0; - } - } - } - - &--kind-filter { - padding: 0 variables.$row-offset-right 0 variables.$row-offset-left; - } - - &--active, - &--visible:hover { - background-color: var(--color-nodelist-row-active); - } - - &--selected, - &--visible#{&}--selected { - // Additional selector required to increase specificity to override previous rule - background-color: var(--color-nodelist-row-selected); - border-right: 1px solid var.$blue-300; - } - - &--disabled { - pointer-events: none; - } - - &::before { - position: absolute; - top: 0; - bottom: 0; - left: -100px; - width: 100px; - background: var(--color-nodelist-row-selected); - transform: translate(0, 0); - opacity: 0; - content: ' '; - pointer-events: none; - } -} - -.pipeline-nodelist__row--active::before, -.pipeline-nodelist__row--selected::before, -.pipeline-nodelist__row:hover::before { - opacity: 1; -} - -.pipeline-nodelist__row--overwrite::before { - .Mui-selected & { - opacity: 1; - } -} - -.pipeline-nodelist__row__icon { - display: block; - flex-shrink: 0; - width: variables.$row-icon-size; - height: variables.$row-icon-size; - fill: var(--color-text); - - &.pipeline-row__toggle-icon--focus-checked { - fill: var.$blue-300; - } - - &--disabled > * { - opacity: 0.1; - } -} - -.pipeline-nodelist__row__type-icon { - &--nested > * { - opacity: 0.3; - } - - &--faded > * { - opacity: 0.2; - } - - &--active, - &--selected, - .pipeline-nodelist__row--visible:hover &, - [data-whatintent='keyboard'] .pipeline-nodelist__row__text:focus & { - > * { - opacity: 1; - } - - &--faded > * { - opacity: 0.55; - } - } -} diff --git a/src/components/nodes-panel/index.js b/src/components/nodes-panel/index.js new file mode 100644 index 0000000000..af6acf42d9 --- /dev/null +++ b/src/components/nodes-panel/index.js @@ -0,0 +1,18 @@ +import React from 'react'; +import NodesPanel from './nodes-panel'; + +import { NodesPanelContextProvider } from './utils/nodes-panel-context'; + +/** + * Acts as a wrapper component that provides the AppContext to the NodesPanel component. + * This ensures that NodesPanel has access to the necessary context values and functions. + */ +const NodesPanelProvider = ({ faded }) => { + return ( + + + + ); +}; + +export default NodesPanelProvider; diff --git a/src/components/nodes-panel/nodes-panel.js b/src/components/nodes-panel/nodes-panel.js new file mode 100644 index 0000000000..8a8957cf61 --- /dev/null +++ b/src/components/nodes-panel/nodes-panel.js @@ -0,0 +1,140 @@ +import React, { useContext, useEffect, useState } from 'react'; +import debounce from 'lodash/debounce'; +import classnames from 'classnames'; +import { Scrollbars } from 'react-custom-scrollbars-2'; +import SearchList from '../search-list'; +import Filters from '../filters/filters'; +import NodeListTree from '../node-list-tree/node-list-tree'; +import SplitPanel from '../split-panel'; +import { FiltersContext } from './utils/filters-context'; +import { NodeListContext } from './utils/node-list-context'; +import { getModularPipelinesSearchResult } from '../../selectors/modular-pipelines'; +import { getFiltersSearchResult } from '../../selectors/filtered-node-list-items'; + +/** + * Scrollable list of toggleable items, with search & filter functionality + */ +const NodesPanel = ({ faded }) => { + const [searchValue, updateSearchValue] = useState(''); + + const { + groupCollapsed, + groups, + isResetFilterActive, + items, + handleGroupToggleChanged, + handleResetFilter, + handleToggleGroupCollapsed, + handleFiltersRowClicked, + } = useContext(FiltersContext); + + const { + hoveredNode, + expanded, + focusMode, + handleItemMouseEnter, + handleItemMouseLeave, + handleKeyDown, + handleModularPipelineToggleExpanded, + handleNodeListRowChanged, + handleNodeListRowClicked, + handleToggleHoveredFocusMode, + isSlicingPipelineApplied, + modularPipelinesTree, + selectedNodes, + slicedPipeline, + nodesDisabledViaModularPipeline, + } = useContext(NodeListContext); + + const modularPipelinesSearchResult = searchValue + ? getModularPipelinesSearchResult(modularPipelinesTree, searchValue) + : null; + + const filtersSearchResult = searchValue + ? getFiltersSearchResult(items, searchValue) + : null; + + useEffect(() => { + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }); + + return ( +
    + + + {({ isResizing, props: { container, panelA, panelB, handle } }) => ( +
    +
    + +
    + +
    +
    +
    +
    +
    + + 0 ? filtersSearchResult : items} + onGroupToggleChanged={handleGroupToggleChanged} + onItemChange={handleFiltersRowClicked} + onResetFilter={handleResetFilter} + onToggleGroupCollapsed={handleToggleGroupCollapsed} + searchValue={searchValue} + /> + +
    +
    + )} + +
    + ); +}; + +export default NodesPanel; diff --git a/src/components/node-list/node-list.test.js b/src/components/nodes-panel/nodes-panel.test.js similarity index 74% rename from src/components/node-list/node-list.test.js rename to src/components/nodes-panel/nodes-panel.test.js index edceb82879..8d56c56adc 100644 --- a/src/components/node-list/node-list.test.js +++ b/src/components/nodes-panel/nodes-panel.test.js @@ -12,14 +12,14 @@ import { getTagData } from '../../selectors/tags'; import { mockState, setup } from '../../utils/state.mock'; import IndicatorPartialIcon from '../icons/indicator-partial'; import SplitPanel from '../split-panel'; -import NodeList, { mapStateToProps } from './index'; +import NodesPanel from './index'; jest.mock('lodash/debounce', () => (func) => { func.cancel = jest.fn(); return func; }); -describe('NodeList', () => { +describe('NodesPanel', () => { beforeEach(() => { window.localStorage.clear(); }); @@ -27,11 +27,11 @@ describe('NodeList', () => { it('renders without crashing', () => { const wrapper = setup.mount( - + ); const search = wrapper.find('.pipeline-search-list'); - const nodeList = wrapper.find('.pipeline-nodelist__list'); + const nodeList = wrapper.find('.filters__section-wrapper'); expect(search.length).toBe(1); expect(nodeList.length).toBeGreaterThan(0); }); @@ -40,7 +40,7 @@ describe('NodeList', () => { describe('displays nodes matching search value', () => { const wrapper = setup.mount( - + ); @@ -59,7 +59,7 @@ describe('NodeList', () => { const search = () => wrapper.find('.search-input__field'); search().simulate('change', { target: { value: searchText } }); const nodeList = wrapper.find( - '.pipeline-nodelist__elements-panel .pipeline-nodelist__row' + '.pipeline-nodelist__elements-panel .node-list-tree-item-row' ); const nodes = getNodeData(mockState.spaceflights); const tags = getTagData(mockState.spaceflights); @@ -94,7 +94,7 @@ describe('NodeList', () => { it('clears the search input and resets the list when hitting the Escape key', () => { const wrapper = setup.mount( - + ); const searchWrapper = wrapper.find('.pipeline-search-list'); @@ -102,7 +102,7 @@ describe('NodeList', () => { const search = () => wrapper.find('.search-input__field'); const nodeList = () => wrapper.find( - '.pipeline-nodelist__elements-panel .pipeline-nodelist__row' + '.pipeline-nodelist__elements-panel .node-list-tree-item-row' ); const nodes = getNodeData(mockState.spaceflights); @@ -141,7 +141,7 @@ describe('NodeList', () => { it('displays search results when in focus mode', () => { const wrapper = setup.mount( - + ); const searchWrapper = wrapper.find('.pipeline-search-list'); @@ -149,7 +149,7 @@ describe('NodeList', () => { const search = () => wrapper.find('.search-input__field'); const nodeList = () => wrapper.find( - '.pipeline-nodelist__elements-panel .pipeline-nodelist__row' + '.pipeline-nodelist__elements-panel .node-list-tree-item-row' ); const nodes = getNodeData(mockState.spaceflights); @@ -192,13 +192,13 @@ describe('NodeList', () => { const elements = (wrapper) => wrapper .find('.MuiTreeItem-label') - .find('.pipeline-nodelist__row') + .find('.node-list-tree-item-row') .map((row) => [row.prop('title')]); it('shows full node names when pretty name is turned off', () => { const wrapper = setup.mount( - + , { beforeLayoutActions: [() => toggleIsPrettyName(false)], @@ -215,7 +215,7 @@ describe('NodeList', () => { it('shows formatted node names when pretty name is turned on', () => { const wrapper = setup.mount( - + , { beforeLayoutActions: [() => toggleIsPrettyName(true)], @@ -233,10 +233,10 @@ describe('NodeList', () => { describe('checkboxes on tag filter items', () => { const checkboxByName = (wrapper, text) => - wrapper.find(`.pipeline-nodelist__row__checkbox[name="${text}"]`); + wrapper.find(`.toggle-control__checkbox[name="${text}"]`); - const rowByName = (wrapper, text) => - wrapper.find(`.pipeline-nodelist__row[title="${text}"]`); + const filterRowByName = (wrapper, text) => + wrapper.find(`.node-list-filter-row[title="${text}"]`); const changeRows = (wrapper, names, checked) => names.forEach((name) => @@ -248,52 +248,19 @@ describe('NodeList', () => { const elements = (wrapper) => wrapper .find('.MuiTreeItem-label') - .find('.pipeline-nodelist__row') - .map((row) => [ - row.prop('title'), - !row.hasClass('pipeline-nodelist__row--disabled'), - ]); + .find('.node-list-tree-item-row') + .map((row) => [row.prop('title'), !row.hasClass('row--disabled')]); - const elementsEnabled = (wrapper) => { - return elements(wrapper).filter(([_, enabled]) => enabled); - }; - - const tagItem = (wrapper) => - wrapper.find('.pipeline-nodelist__group--type-tag'); + const tagItem = (wrapper) => wrapper.find('.filters-section--type-tag'); const partialIcon = (wrapper) => tagItem(wrapper).find(IndicatorPartialIcon); - it('selecting tags enables only elements with given tags and modular pipelines', () => { - //Parameters are enabled here to override the default behavior - const wrapper = setup.mount( - - - , - { - beforeLayoutActions: [() => toggleTypeDisabled('parameters', false)], - } - ); - - changeRows(wrapper, ['Preprocessing'], true); - expect(elementsEnabled(wrapper)).toEqual([ - ['data_processing', true], - ['data_science', true], - ]); - - changeRows(wrapper, ['Preprocessing', 'Features'], true); - expect(elementsEnabled(wrapper)).toEqual([ - ['data_processing', true], - ['data_science', true], - ['model_input_table', true], - ]); - }); - it('selecting a tag sorts elements by modular pipelines first then by task, data and parameter nodes ', () => { //Parameters are enabled here to override the default behavior const wrapper = setup.mount( - + , { beforeLayoutActions: [() => toggleTypeDisabled('parameters', false)], @@ -313,10 +280,10 @@ describe('NodeList', () => { it('adds a class to tag group item when all tags unchecked', () => { const wrapper = setup.mount( - + ); - const uncheckedClass = 'pipeline-nodelist__group--all-unchecked'; + const uncheckedClass = 'filters-section--all-unchecked'; expect(tagItem(wrapper).hasClass(uncheckedClass)).toBe(true); changeRows(wrapper, ['Preprocessing'], true); @@ -328,28 +295,38 @@ describe('NodeList', () => { it('adds a class to the row when a tag row unchecked', () => { const wrapper = setup.mount( - + ); - const uncheckedClass = 'pipeline-nodelist__row--unchecked'; + const uncheckedClass = 'toggle-control--icon--unchecked'; + + const filterRow = filterRowByName(wrapper, 'Preprocessing'); + const hasUncheckedClass = filterRow.find(`.${uncheckedClass}`).exists(); + expect(hasUncheckedClass).toBe(true); - expect(rowByName(wrapper, 'Preprocessing').hasClass(uncheckedClass)).toBe( - true - ); changeRows(wrapper, ['Preprocessing'], true); - expect(rowByName(wrapper, 'Preprocessing').hasClass(uncheckedClass)).toBe( - false - ); + const hasUncheckedClassAfterChangeTrue = filterRowByName( + wrapper, + 'Preprocessing' + ) + .find(`.${uncheckedClass}`) + .exists(); + expect(hasUncheckedClassAfterChangeTrue).toBe(false); + changeRows(wrapper, ['Preprocessing'], false); - expect(rowByName(wrapper, 'Preprocessing').hasClass(uncheckedClass)).toBe( - true - ); + const hasUncheckedClassAfterChangeFalse = filterRowByName( + wrapper, + 'Preprocessing' + ) + .find(`.${uncheckedClass}`) + .exists(); + expect(hasUncheckedClassAfterChangeFalse).toBe(true); }); it('shows as partially selected when at least one but not all tags selected', () => { const wrapper = setup.mount( - + ); @@ -366,13 +343,13 @@ describe('NodeList', () => { ['Features', 'Preprocessing', 'Split', 'Train'], true ); - expect(partialIcon(wrapper)).toHaveLength(0); + expect(partialIcon(wrapper)).toHaveLength(1); }); it('saves enabled tags in localStorage on selecting a tag on node-list', () => { const wrapper = setup.mount( - + ); changeRows(wrapper, ['Preprocessing'], true); @@ -383,28 +360,28 @@ describe('NodeList', () => { }); }); + // FILTER GROUP describe('node list', () => { it('renders the correct number of tags in the filter panel', () => { const wrapper = setup.mount( - + ); - const nodeList = wrapper.find( - '.pipeline-nodelist__list--nested .pipeline-nodelist__row' - ); - // const nodes = getNodeData(mockState.spaceflights); + const nodeList = wrapper.find('.filters-group .node-list-filter-row'); const tags = getTagData(mockState.spaceflights); const elementTypes = Object.keys(sidebarElementTypes); expect(nodeList.length).toBe(tags.length + elementTypes.length); }); + it('renders the correct number of modular pipelines and nodes in the tree sidepanel', () => { const wrapper = setup.mount( - + ); - const nodeList = wrapper.find('.pipeline-nodelist__row__text--tree'); + + const nodeList = wrapper.find('.row-text--tree'); const modularPipelinesTree = getModularPipelinesTree( mockState.spaceflights ); @@ -416,7 +393,7 @@ describe('NodeList', () => { it('renders elements panel, filter panel inside a SplitPanel with a handle', () => { const wrapper = setup.mount( - + ); const split = wrapper.find(SplitPanel); @@ -437,33 +414,13 @@ describe('NodeList', () => { }); }); - describe('node list element item', () => { - const wrapper = setup.mount( - - - - ); - // this needs to be the 3rd element as the first 2 elements are modular pipelines rows which does not apply the '--active' class - const nodeRow = () => wrapper.find('.pipeline-nodelist__row').at(3); - - it('handles mouseenter events', () => { - nodeRow().simulate('mouseenter'); - expect(nodeRow().hasClass('pipeline-nodelist__row--active')).toBe(true); - }); - - it('handles mouseleave events', () => { - nodeRow().simulate('mouseleave'); - expect(nodeRow().hasClass('pipeline-nodelist__row--active')).toBe(false); - }); - }); - describe('node list element item checkbox', () => { const wrapper = setup.mount( - + ); - const checkbox = () => wrapper.find('.pipeline-nodelist__row input').at(4); + const checkbox = () => wrapper.find('.node-list-tree-item-row input').at(4); it('handles toggle off event', () => { checkbox().simulate('change', { @@ -493,13 +450,11 @@ describe('NodeList', () => { describe('Reset node filters', () => { const wrapper = setup.mount( - + ); - const resetFilterButton = wrapper.find( - '.pipeline-nodelist-section__reset-filter' - ); + const resetFilterButton = wrapper.find('.filters__reset-button'); it('On first load before applying filter button should be disabled', () => { expect(resetFilterButton.prop('disabled')).toBe(true); @@ -507,7 +462,7 @@ describe('NodeList', () => { it('After applying any filter filter button should not be disabled', () => { const nodeTypeFilter = wrapper.find( - `.pipeline-nodelist__row__checkbox[name="Datasets"]` + `.toggle-control__checkbox[name="Datasets"]` ); nodeTypeFilter.simulate('click'); @@ -526,30 +481,4 @@ describe('NodeList', () => { expect(window.location.search).not.toContain('tags'); }); }); - - it('maps state to props', () => { - const nodeList = expect.arrayContaining([ - expect.objectContaining({ - disabled: expect.any(Boolean), - disabledNode: expect.any(Boolean), - disabledTag: expect.any(Boolean), - disabledType: expect.any(Boolean), - id: expect.any(String), - name: expect.any(String), - type: expect.any(String), - }), - ]); - const expectedResult = expect.objectContaining({ - tags: expect.any(Object), - nodes: expect.objectContaining({ - data: nodeList, - task: nodeList, - modularPipeline: nodeList, - }), - nodeSelected: expect.any(Object), - nodeTypes: expect.any(Array), - modularPipelinesTree: expect.any(Object), - }); - expect(mapStateToProps(mockState.spaceflights)).toEqual(expectedResult); - }); }); diff --git a/src/components/nodes-panel/utils/filters-context.js b/src/components/nodes-panel/utils/filters-context.js new file mode 100644 index 0000000000..1801552b30 --- /dev/null +++ b/src/components/nodes-panel/utils/filters-context.js @@ -0,0 +1,251 @@ +import React, { useState, useEffect, createContext } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; + +import { useGeneratePathname } from '../../../utils/hooks/use-generate-pathname'; +import { loadLocalStorage, saveLocalStorage } from '../../../store/helpers'; + +import { getTagData, getTagNodeCounts } from '../../../selectors/tags'; +import { + getGroupedNodes, + getNodeSelected, + getInputOutputNodesForFocusedModularPipeline, +} from '../../../selectors/nodes'; +import { getNodeTypes } from '../../../selectors/node-types'; +import { getFocusedModularPipeline } from '../../../selectors/modular-pipelines'; + +import { toggleTagFilter } from '../../../actions/tags'; +import { toggleTypeDisabled } from '../../../actions/node-type'; +import { loadNodeData, toggleNodeHovered } from '../../../actions/nodes'; + +import { params, localStorageName, NODE_TYPES } from '../../../config'; +import { + getFilteredItems, + isTagType, + isElementType, + getGroups, +} from '../../../selectors/filtered-node-list-items'; + +// Load the stored state from local storage +const storedState = loadLocalStorage(localStorageName); + +// Custom hook to group useSelector calls +const useFiltersContextSelector = () => { + const dispatch = useDispatch(); + const tags = useSelector(getTagData); + const nodes = useSelector(getGroupedNodes); + const nodeTypes = useSelector(getNodeTypes); + const tagNodeCounts = useSelector(getTagNodeCounts); + const nodeSelected = useSelector(getNodeSelected); + const focusMode = useSelector(getFocusedModularPipeline); + const inputOutputDataNodes = useSelector( + getInputOutputNodesForFocusedModularPipeline + ); + + const onToggleTypeDisabled = (typeID, disabled) => { + dispatch(toggleTypeDisabled(typeID, disabled)); + }; + + const onToggleTagFilter = (tagIDs, enabled) => { + dispatch(toggleTagFilter(tagIDs, enabled)); + }; + + const onToggleNodeSelected = (nodeID) => { + dispatch(loadNodeData(nodeID)); + }; + + const onToggleNodeHovered = (nodeID) => { + dispatch(toggleNodeHovered(nodeID)); + }; + + return { + tags, + nodes, + nodeTypes, + tagNodeCounts, + nodeSelected, + focusMode, + inputOutputDataNodes, + onToggleTypeDisabled, + onToggleTagFilter, + onToggleNodeSelected, + onToggleNodeHovered, + }; +}; + +// Create a context for filters +export const FiltersContext = createContext(); + +export const FiltersContextProvider = ({ children }) => { + const { + tags, + nodes, + nodeTypes, + tagNodeCounts, + nodeSelected, + focusMode, + inputOutputDataNodes, + onToggleTypeDisabled, + onToggleTagFilter, + onToggleNodeSelected, + onToggleNodeHovered, + } = useFiltersContextSelector(); + + const [groupCollapsed, setGroupCollapsed] = useState( + storedState.groupsCollapsed || {} + ); + const [isResetFilterActive, setIsResetFilterActive] = useState(false); + + // Helper function to check if NodeTypes are modified + const hasModifiedNodeTypes = (nodeTypes) => { + return nodeTypes.some( + (item) => NODE_TYPES[item.id]?.defaultState !== item.disabled + ); + }; + + // Effect to update the reset filter button status based on node types and tags + useEffect(() => { + const isNodeTypeModified = hasModifiedNodeTypes(nodeTypes); + const isNodeTagModified = tags.some((tag) => tag.enabled); + setIsResetFilterActive(isNodeTypeModified || isNodeTagModified); + }, [tags, nodeTypes]); + + const { + toUpdateUrlParamsOnResetFilter, + toUpdateUrlParamsOnFilter, + toSetQueryParam, + } = useGeneratePathname(); + + // Function to reset applied filters to default + const handleResetFilter = () => { + onToggleTypeDisabled({ task: false, data: false, parameters: true }); + onToggleTagFilter( + tags.map((item) => item.id), + false + ); + toUpdateUrlParamsOnResetFilter(); + }; + + // Function to collapse/expand node group of filters + const handleToggleGroupCollapsed = (groupID) => { + const updatedGroupCollapsed = { + ...groupCollapsed, + [groupID]: !groupCollapsed[groupID], + }; + setGroupCollapsed(updatedGroupCollapsed); + saveLocalStorage(localStorageName, { + groupsCollapsed: updatedGroupCollapsed, + }); + }; + + const items = getFilteredItems({ + nodes, + tags, + nodeTypes, + tagNodeCounts, + nodeSelected, + searchValue: '', + focusMode, + inputOutputDataNodes, + }); + + const groups = getGroups({ items }); + + // Function to get existing values from URL query parameters + const getExistingValuesFromUrlQueryParams = (paramName, searchParams) => { + const paramValues = searchParams.get(paramName); + return new Set(paramValues ? paramValues.split(',') : []); + }; + + // Function to update URL query parameters when a filter is applied + const handleUrlParamsUpdateOnFilter = (item) => { + const searchParams = new URLSearchParams(window.location.search); + const paramName = isElementType(item.type) ? params.types : params.tags; + const existingValues = getExistingValuesFromUrlQueryParams( + paramName, + searchParams + ); + toUpdateUrlParamsOnFilter(item, paramName, existingValues); + }; + + // Function to update URL query parameters when a filter group is clicked + const handleUrlParamsUpdateOnGroupFilter = ( + groupType, + groupItems, + groupItemsDisabled + ) => { + if (groupItemsDisabled) { + groupItems.forEach((item) => { + handleUrlParamsUpdateOnFilter(item); + }); + } else { + const paramName = isElementType(groupType) ? params.types : params.tags; + toSetQueryParam(paramName, []); + } + }; + + // Function to handle group toggle change + const handleGroupToggleChanged = (groupType) => { + const groupItems = items[groupType] || []; + const groupItemsDisabled = groupItems.every( + (groupItem) => !groupItem.checked + ); + + handleUrlParamsUpdateOnGroupFilter( + groupType, + groupItems, + groupItemsDisabled + ); + + if (isTagType(groupType)) { + onToggleTagFilter( + groupItems.map((item) => item.id), + groupItemsDisabled + ); + } else if (isElementType(groupType)) { + onToggleTypeDisabled( + groupItems.reduce( + (state, item) => ({ ...state, [item.id]: !groupItemsDisabled }), + {} + ) + ); + } + }; + + const onGroupItemChange = (item, wasChecked) => { + // Toggle the group + if (isTagType(item.type)) { + onToggleTagFilter(item.id, !wasChecked); + } else if (isElementType(item.type)) { + onToggleTypeDisabled({ [item.id]: wasChecked }); + } + + // Reset node selection + onToggleNodeSelected(null); + onToggleNodeHovered(null); + }; + + const handleFiltersRowClicked = (event, item) => { + onGroupItemChange(item, item.checked); + handleUrlParamsUpdateOnFilter(item); + + // to prevent page reload on form submission + event.preventDefault(); + }; + + return ( + + {children} + + ); +}; diff --git a/src/components/nodes-panel/utils/node-list-context.js b/src/components/nodes-panel/utils/node-list-context.js new file mode 100644 index 0000000000..a7c4cde3fc --- /dev/null +++ b/src/components/nodes-panel/utils/node-list-context.js @@ -0,0 +1,232 @@ +import React, { createContext } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; + +import { useGeneratePathname } from '../../../utils/hooks/use-generate-pathname'; + +import { + getFocusedModularPipeline, + getModularPipelinesTree, +} from '../../../selectors/modular-pipelines'; +import { isModularPipelineType } from '../../../selectors/node-types'; +import { getNodeSelected } from '../../../selectors/nodes'; +import { getSlicedPipeline } from '../../../selectors/sliced-pipeline'; + +import { + toggleModularPipelinesExpanded, + toggleModularPipelineActive, + toggleModularPipelineDisabled, +} from '../../../actions/modular-pipelines'; +import { toggleFocusMode, toggleHoveredFocusMode } from '../../../actions'; +import { + loadNodeData, + toggleNodeHovered, + toggleNodesDisabled, +} from '../../../actions/nodes'; +import { resetSlicePipeline } from '../../../actions/slice'; +import { getnodesDisabledViaModularPipeline } from '../../../selectors/disabled'; + +// Custom hook to group useSelector calls +const useNodeListContextSelector = () => { + const dispatch = useDispatch(); + const hoveredNode = useSelector((state) => state.node.hovered); + const selectedNodes = useSelector(getNodeSelected); + const nodesDisabledViaModularPipeline = useSelector( + getnodesDisabledViaModularPipeline + ); + const expanded = useSelector((state) => state.modularPipeline.expanded); + const slicedPipeline = useSelector(getSlicedPipeline); + const modularPipelinesTree = useSelector(getModularPipelinesTree); + const isSlicingPipelineApplied = useSelector((state) => state.slice.apply); + const focusMode = useSelector(getFocusedModularPipeline); + const disabledModularPipeline = useSelector( + (state) => state.modularPipeline.disabled + ); + + const onToggleFocusMode = (modularPipeline) => { + dispatch(toggleFocusMode(modularPipeline)); + }; + const onToggleHoveredFocusMode = (active) => { + dispatch(toggleHoveredFocusMode(active)); + }; + const onToggleNodeSelected = (nodeID) => { + dispatch(loadNodeData(nodeID)); + }; + const onToggleNodeHovered = (nodeID) => { + dispatch(toggleNodeHovered(nodeID)); + }; + const onToggleNodesDisabled = (nodeIDs, disabled) => { + dispatch(toggleNodesDisabled(nodeIDs, disabled)); + }; + const onToggleModularPipelineExpanded = (expanded) => { + dispatch(toggleModularPipelinesExpanded(expanded)); + }; + const onToggleModularPipelineDisabled = (modularPipelineIDs, disabled) => { + dispatch(toggleModularPipelineDisabled(modularPipelineIDs, disabled)); + }; + const onToggleModularPipelineActive = (modularPipelineIDs, active) => { + dispatch(toggleModularPipelineActive(modularPipelineIDs, active)); + }; + const onResetSlicePipeline = () => { + dispatch(resetSlicePipeline()); + }; + + return { + disabledModularPipeline, + expanded, + focusMode, + hoveredNode, + isSlicingPipelineApplied, + modularPipelinesTree, + selectedNodes, + slicedPipeline, + nodesDisabledViaModularPipeline, + onResetSlicePipeline, + onToggleFocusMode, + onToggleHoveredFocusMode, + onToggleModularPipelineActive, + onToggleModularPipelineDisabled, + onToggleModularPipelineExpanded, + onToggleNodeHovered, + onToggleNodesDisabled, + onToggleNodeSelected, + }; +}; + +export const NodeListContext = createContext(); + +export const NodeListContextProvider = ({ children }) => { + const { + disabledModularPipeline, + expanded, + focusMode, + hoveredNode, + isSlicingPipelineApplied, + modularPipelinesTree, + selectedNodes, + slicedPipeline, + nodesDisabledViaModularPipeline, + onResetSlicePipeline, + onToggleFocusMode, + onToggleHoveredFocusMode, + onToggleModularPipelineActive, + onToggleModularPipelineDisabled, + onToggleModularPipelineExpanded, + onToggleNodeHovered, + onToggleNodesDisabled, + onToggleNodeSelected, + } = useNodeListContextSelector(); + const { toSelectedPipeline, toSelectedNode, toFocusedModularPipeline } = + useGeneratePathname(); + + // Handle row click in the node list + const handleNodeListRowClicked = (event, item) => { + if (isModularPipelineType(item.type)) { + onToggleNodeSelected(null); + } else { + if (item.faded || item.selected) { + onToggleNodeSelected(null); + toSelectedPipeline(); + } else { + onToggleNodeSelected(item.id); + toSelectedNode(item); + // Reset the pipeline slicing filters if no slicing is currently applied + if (!isSlicingPipelineApplied) { + onResetSlicePipeline(); + } + } + } + + // Prevent page reload on form submission + event.preventDefault(); + }; + + // Handle changes in the node list row + const handleNodeListRowChanged = (item, checked, clickedIconType) => { + if (isModularPipelineType(item.type)) { + if (clickedIconType === 'focus') { + if (focusMode === null) { + onToggleFocusMode(item); + toFocusedModularPipeline(item); + + if (disabledModularPipeline[item.id]) { + onToggleModularPipelineDisabled([item.id], checked); + } + } else { + onToggleFocusMode(null); + toSelectedPipeline(); + } + } else { + onToggleModularPipelineDisabled([item.id], checked); + onToggleModularPipelineActive([item.id], false); + } + } else { + if (checked) { + onToggleNodeHovered(null); + } + + onToggleNodesDisabled([item.id], checked); + } + // reset the node data + onToggleNodeSelected(null); + onToggleNodeHovered(null); + }; + + // Handle mouse enter event on an item + const handleItemMouseEnter = (item) => { + if (isModularPipelineType(item.type)) { + onToggleModularPipelineActive(item.id, true); + return; + } + + if (item.visible) { + onToggleNodeHovered(item.id); + } + }; + + // Handle mouse leave event on an item + const handleItemMouseLeave = (item) => { + if (isModularPipelineType(item.type)) { + onToggleModularPipelineActive(item.id, false); + return; + } + if (item.visible) { + onToggleNodeHovered(null); + } + }; + + // Toggle hovered focus mode + const handleToggleHoveredFocusMode = (active) => { + onToggleHoveredFocusMode(active); + }; + + // Deselect node on Escape key + const handleKeyDown = (event) => { + if (event.keyCode === 27) { + onToggleNodeSelected(null); + } + }; + + return ( + + {children} + + ); +}; diff --git a/src/components/nodes-panel/utils/nodes-panel-context.js b/src/components/nodes-panel/utils/nodes-panel-context.js new file mode 100644 index 0000000000..aa32e99d3f --- /dev/null +++ b/src/components/nodes-panel/utils/nodes-panel-context.js @@ -0,0 +1,11 @@ +import React from 'react'; +import { NodeListContextProvider } from './node-list-context'; +import { FiltersContextProvider } from './filters-context'; + +export const NodesPanelContextProvider = ({ children }) => { + return ( + + {children} + + ); +}; diff --git a/src/components/sidebar/sidebar.js b/src/components/sidebar/sidebar.js index 653a104ba7..73b6fbcc14 100644 --- a/src/components/sidebar/sidebar.js +++ b/src/components/sidebar/sidebar.js @@ -5,7 +5,7 @@ import ExperimentPrimaryToolbar from '../experiment-tracking/experiment-primary- import FlowchartPrimaryToolbar from '../flowchart-primary-toolbar'; import MiniMap from '../minimap'; import MiniMapToolbar from '../minimap-toolbar'; -import NodeList from '../node-list'; +import NodesPanel from '../nodes-panel'; import PipelineList from '../pipeline-list'; import RunsList from '../experiment-tracking/runs-list'; @@ -88,7 +88,7 @@ export const Sidebar = ({ >
    - +