kop-cli 0.2.0b2__tar.gz → 0.3.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (159) hide show
  1. kop_cli-0.3.1/CHANGELOG.md +11 -0
  2. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/PKG-INFO +4 -4
  3. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/README.md +1 -1
  4. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/docs/guide/Pods.md +18 -0
  5. kop_cli-0.3.1/docs/images/guide/transfer.png +0 -0
  6. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/docs/index.md +1 -1
  7. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/docs/tutorial.md +6 -0
  8. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/pyproject.toml +2 -2
  9. kop_cli-0.3.1/src/kop/__init__.py +2 -0
  10. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/src/kop/controllers/handler.py +26 -0
  11. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/src/kop/factory.py +6 -0
  12. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/src/kop/provider/logs.py +47 -10
  13. kop_cli-0.3.1/src/kop/provider/transfer.py +325 -0
  14. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/src/kop/widgets/Modals.py +10 -10
  15. kop_cli-0.3.1/src/kop/widgets/Transfer.py +384 -0
  16. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/tests/test_log_controller.py +24 -0
  17. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/tests/test_pod_logs.py +33 -23
  18. kop_cli-0.2.0b2/src/kop/__init__.py +0 -2
  19. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/.gitignore +0 -0
  20. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/.python-version +0 -0
  21. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/CODE_OF_CONDUCT.md +0 -0
  22. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/LICENSE +0 -0
  23. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/NOTICE +0 -0
  24. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/__init__.py +0 -0
  25. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/docs/faq.md +0 -0
  26. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/docs/getting_started.md +0 -0
  27. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/docs/guide/ClusterRoleBindings.md +0 -0
  28. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/docs/guide/ClusterRoles.md +0 -0
  29. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/docs/guide/Clusters.md +0 -0
  30. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/docs/guide/ConfigMaps.md +0 -0
  31. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/docs/guide/CronJobs.md +0 -0
  32. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/docs/guide/DaemonSets.md +0 -0
  33. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/docs/guide/Deployments.md +0 -0
  34. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/docs/guide/EndpointSlices.md +0 -0
  35. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/docs/guide/Endpoints.md +0 -0
  36. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/docs/guide/IngressClasses.md +0 -0
  37. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/docs/guide/Ingresses.md +0 -0
  38. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/docs/guide/Jobs.md +0 -0
  39. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/docs/guide/Namespaces.md +0 -0
  40. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/docs/guide/NetworkPolicies.md +0 -0
  41. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/docs/guide/Nodes.md +0 -0
  42. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/docs/guide/PersistentVolumeClaims.md +0 -0
  43. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/docs/guide/PersistentVolumes.md +0 -0
  44. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/docs/guide/RoleBindings.md +0 -0
  45. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/docs/guide/Roles.md +0 -0
  46. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/docs/guide/Secrets.md +0 -0
  47. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/docs/guide/ServiceAccounts.md +0 -0
  48. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/docs/guide/Services.md +0 -0
  49. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/docs/guide/StatefulSets.md +0 -0
  50. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/docs/guide/StorageClasses.md +0 -0
  51. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/docs/guide/index.md +0 -0
  52. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/docs/guide/zh/pods-zh.md +0 -0
  53. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/docs/guide.md +0 -0
  54. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/docs/images/guide/create_resource.png +0 -0
  55. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/docs/images/guide/edit_resource.png +0 -0
  56. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/docs/images/guide/forward_port.png +0 -0
  57. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/docs/images/guide/pod_logs.png +0 -0
  58. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/docs/images/guide/resource_detail.png +0 -0
  59. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/docs/images/guide/scale_resource.png +0 -0
  60. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/docs/images/guide/search_kinds.png +0 -0
  61. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/docs/images/guide/select_namespace.png +0 -0
  62. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/docs/images/sample.png +0 -0
  63. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/docs/images/tutorial/action_workspace.png +0 -0
  64. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/docs/images/tutorial/add_new_cluster.png +0 -0
  65. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/docs/images/tutorial/cluster_area.png +0 -0
  66. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/docs/images/tutorial/detail_view.png +0 -0
  67. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/docs/images/tutorial/keys.png +0 -0
  68. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/docs/images/tutorial/resource_view.png +0 -0
  69. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/docs/images/tutorial/startup.png +0 -0
  70. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/docs/images/tutorial/startup_without_cluster.png +0 -0
  71. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/docs/images/tutorial/themes.png +0 -0
  72. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/docs/readme_ch.md +0 -0
  73. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/mkdocs.yml +0 -0
  74. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/scripts/event.py +0 -0
  75. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/scripts/forward.py +0 -0
  76. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/scripts/kube_client.py +0 -0
  77. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/scripts/mock_resource_view_deployment.py +0 -0
  78. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/scripts/mock_resource_view_pod.py +0 -0
  79. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/scripts/multiple_select.py +0 -0
  80. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/scripts/pixels.py +0 -0
  81. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/scripts/pod_logs.py +0 -0
  82. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/scripts/rich_detail.py +0 -0
  83. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/scripts/set_release_version.py +0 -0
  84. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/scripts/view_pod_logs.py +0 -0
  85. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/src/kop/app/__init__.py +0 -0
  86. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/src/kop/app/main.py +0 -0
  87. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/src/kop/models.py +0 -0
  88. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/src/kop/provider/attach.py +0 -0
  89. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/src/kop/provider/client.py +0 -0
  90. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/src/kop/provider/config.py +0 -0
  91. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/src/kop/provider/events.py +0 -0
  92. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/src/kop/provider/exec.py +0 -0
  93. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/src/kop/provider/forward.py +0 -0
  94. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/src/kop/provider/utils.py +0 -0
  95. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/src/kop/registry.py +0 -0
  96. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/src/kop/renderers/details.py +0 -0
  97. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/src/kop/renderers/fields.py +0 -0
  98. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/src/kop/renderers/formatter.py +0 -0
  99. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/src/kop/renderers/forms.py +0 -0
  100. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/src/kop/renderers/table.py +0 -0
  101. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/src/kop/templates/resource/clusterrolebindings.yaml +0 -0
  102. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/src/kop/templates/resource/clusterroles.yaml +0 -0
  103. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/src/kop/templates/resource/configmaps.yaml +0 -0
  104. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/src/kop/templates/resource/cronjobs.yaml +0 -0
  105. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/src/kop/templates/resource/daemonsets.yaml +0 -0
  106. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/src/kop/templates/resource/deployments.yaml +0 -0
  107. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/src/kop/templates/resource/endpointslices.yaml +0 -0
  108. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/src/kop/templates/resource/ingressclasses.yaml +0 -0
  109. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/src/kop/templates/resource/ingresses.yaml +0 -0
  110. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/src/kop/templates/resource/jobs.yaml +0 -0
  111. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/src/kop/templates/resource/namespaces.yaml +0 -0
  112. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/src/kop/templates/resource/networkpolicies.yaml +0 -0
  113. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/src/kop/templates/resource/node-shell-pod.yaml +0 -0
  114. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/src/kop/templates/resource/persistentvolumeclaims.yaml +0 -0
  115. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/src/kop/templates/resource/persistentvolumes.yaml +0 -0
  116. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/src/kop/templates/resource/pods.yaml +0 -0
  117. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/src/kop/templates/resource/rolebindings.yaml +0 -0
  118. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/src/kop/templates/resource/roles.yaml +0 -0
  119. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/src/kop/templates/resource/secrets.yaml +0 -0
  120. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/src/kop/templates/resource/serviceaccounts.yaml +0 -0
  121. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/src/kop/templates/resource/services.yaml +0 -0
  122. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/src/kop/templates/resource/statefulsets.yaml +0 -0
  123. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/src/kop/templates/resource/storageclasses.yaml +0 -0
  124. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/src/kop/validations.py +0 -0
  125. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/src/kop/views/ActionWorkspace.py +0 -0
  126. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/src/kop/views/EditView.py +0 -0
  127. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/src/kop/views/PodAttach.py +0 -0
  128. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/src/kop/views/PodLog.py +0 -0
  129. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/src/kop/views/PodTerminal.py +0 -0
  130. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/src/kop/views/ResourceView.py +0 -0
  131. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/src/kop/views/StartupView.py +0 -0
  132. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/src/kop/widgets/Actions.py +0 -0
  133. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/src/kop/widgets/Attach.py +0 -0
  134. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/src/kop/widgets/Columns.py +0 -0
  135. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/src/kop/widgets/Detail.py +0 -0
  136. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/src/kop/widgets/Directory.py +0 -0
  137. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/src/kop/widgets/Dynamic.py +0 -0
  138. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/src/kop/widgets/Edit.py +0 -0
  139. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/src/kop/widgets/Events.py +0 -0
  140. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/src/kop/widgets/Expandable.py +0 -0
  141. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/src/kop/widgets/Focusable.py +0 -0
  142. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/src/kop/widgets/Forward.py +0 -0
  143. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/src/kop/widgets/Log.py +0 -0
  144. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/src/kop/widgets/MultipleSelect.py +0 -0
  145. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/src/kop/widgets/Panel.py +0 -0
  146. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/src/kop/widgets/Pty.py +0 -0
  147. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/src/kop/widgets/RichDetail.py +0 -0
  148. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/src/kop/widgets/Rules.py +0 -0
  149. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/src/kop/widgets/SideMenu.py +0 -0
  150. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/src/kop/widgets/__init__.py +0 -0
  151. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/tests/test_action_workspace.py +0 -0
  152. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/tests/test_detail_modal_renderer.py +0 -0
  153. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/tests/test_edit_view.py +0 -0
  154. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/tests/test_event_service.py +0 -0
  155. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/tests/test_pagination_integration.py +0 -0
  156. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/tests/test_resource_view.py +0 -0
  157. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/tests/test_startup_view.py +0 -0
  158. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/tests/test_table_renderer.py +0 -0
  159. {kop_cli-0.2.0b2 → kop_cli-0.3.1}/uv.lock +0 -0
@@ -0,0 +1,11 @@
1
+ ## New Features
2
+
3
+ - Transfer files beteewn local and pod
4
+
5
+ ## Bug fixes
6
+
7
+ - fixed Kubernetes Client v36.0.0 where the read_namespaced_pod_log function did not support the watch parameter.
8
+
9
+ ## Docs
10
+
11
+ - Update on the introduction and usage of transfer files
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kop-cli
3
- Version: 0.2.0b2
3
+ Version: 0.3.1
4
4
  Summary: Terminal-based kubernetes operation platform
5
5
  Author-email: vegaoqiang <vegaoqiang@gmail.com>
6
6
  License-Expression: Apache-2.0
@@ -26,9 +26,9 @@ Classifier: Topic :: Software Development
26
26
  Classifier: Topic :: System :: Systems Administration
27
27
  Classifier: Topic :: Terminals
28
28
  Requires-Python: >=3.9
29
- Requires-Dist: kubernetes>=33.1.0
29
+ Requires-Dist: kubernetes==33.1.0
30
30
  Requires-Dist: pyte
31
- Requires-Dist: textual>=8.1.1
31
+ Requires-Dist: textual==8.2.7
32
32
  Requires-Dist: tree-sitter-bash
33
33
  Requires-Dist: tree-sitter-json
34
34
  Requires-Dist: tree-sitter-yaml
@@ -112,7 +112,7 @@ In this mode, the file path and kubeconfig validity are verified, and after vali
112
112
 
113
113
  ## Operations and Shortcuts
114
114
  See demo video:
115
- [![Watch the demo](https://img.youtube.com/vi/sEXl9UQQxVc/maxresdefault.jpg)](https://www.youtube.com/watch?v=sEXl9UQQxVc)
115
+ [![Watch the demo](https://img.youtube.com/vi/6L1jOtYvKYg/maxresdefault.jpg)](https://www.youtube.com/watch?v=6L1jOtYvKYg)
116
116
 
117
117
  Please refer to the [Documentation](https://vegaoqiang.github.io/kop/) for more detailed usage instructions.
118
118
 
@@ -72,7 +72,7 @@ In this mode, the file path and kubeconfig validity are verified, and after vali
72
72
 
73
73
  ## Operations and Shortcuts
74
74
  See demo video:
75
- [![Watch the demo](https://img.youtube.com/vi/sEXl9UQQxVc/maxresdefault.jpg)](https://www.youtube.com/watch?v=sEXl9UQQxVc)
75
+ [![Watch the demo](https://img.youtube.com/vi/6L1jOtYvKYg/maxresdefault.jpg)](https://www.youtube.com/watch?v=6L1jOtYvKYg)
76
76
 
77
77
  Please refer to the [Documentation](https://vegaoqiang.github.io/kop/) for more detailed usage instructions.
78
78
 
@@ -10,6 +10,7 @@ Available shortcuts for Pod-related operations are shown in the Footer area at t
10
10
  | `c` | Create Pods |
11
11
  | `d` | Delete Pod |
12
12
  | `e` | Edit Pod |
13
+ | `t` | Transfer files |
13
14
  | `f` | Port Forward |
14
15
  | `l` | Pod Logs |
15
16
  | `n` | Next Page |
@@ -84,6 +85,23 @@ Edits the Pod YAML configuration and submits changes.
84
85
  - If an update fails, adjust based on the error message and retry.
85
86
  - `Edit Pod` opens in the `Action Workspace`.
86
87
 
88
+ ### Transfer Files
89
+ Transfer files allows users to transfer files between their local machine and their pod.
90
+
91
+ > The `Transfer files` function requires the container to contain the `tar` utility; otherwise, it will not be able to transfer directories and only supports transferring single files.
92
+
93
+ **Steps:**
94
+
95
+ 1. In the left navigation, move the cursor to `Pods` or enter the `Pods` resource page.
96
+ 2. Move the cursor to the target pod
97
+ 3. Press the `t` key to open the Transfer dialog box.(If your pod contains multiple containers, please select one container.)
98
+ 4. In the `Transfer` dialog box, select the source file and destination path for this transfer.
99
+ 5. Click the `Transfer` button to start the transfer.
100
+
101
+ ![transfer files](../images/guide/transfer.png)
102
+
103
+ > `Transfer` supports custom target filenames
104
+
87
105
  ### Port Forward
88
106
  Forwards a local port to a Pod port so you can access container services from your machine.
89
107
 
@@ -7,7 +7,7 @@ hide:
7
7
 
8
8
  `kop` is a terminal-based (TUI) Kubernetes operations platform. Its goal is to provide an interactive experience similar to desktop cluster management tools, but fully within the terminal command line, aiming to solve the problem of conveniently operating Kubernetes clusters when no desktop environment is available.
9
9
 
10
- <iframe width="100%" height="350" src="https://www.youtube.com/embed/sEXl9UQQxVc?si=_EpzjW_I_nV4pofR" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>
10
+ <iframe width="560" height="315" src="https://www.youtube.com/embed/6L1jOtYvKYg?si=jduCsuOJI_GG6xNe" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>
11
11
 
12
12
  ## Why kop
13
13
 
@@ -61,6 +61,12 @@ Below the header is action tabs; you can use `Ctrl+[` or `ctrl+]` to switch bet
61
61
 
62
62
  The middle section shows the current action workspace. Below are the keyboard shortcuts for actions within the workspace.
63
63
 
64
+ ## Transfer Files
65
+ Transfer files allows users to transfer files between their local machine and their pod.
66
+
67
+ ![transfer files](images/guide/transfer.png)
68
+
69
+ Please see the usage instructions: [Transfer Files](guide/Pods.md#transfer-files)
64
70
 
65
71
  ## Theme
66
72
  Kop uses Textual to build its user interface and includes multiple modern themes. Press `Ctrl+P` in Kop will directly bring up the theme list, allowing you to select any theme.
@@ -39,8 +39,8 @@ classifiers = [
39
39
  ]
40
40
 
41
41
  dependencies = [
42
- "kubernetes>=33.1.0",
43
- "textual>=8.1.1",
42
+ "kubernetes==33.1.0",
43
+ "textual==8.2.7",
44
44
  "tree-sitter-yaml",
45
45
  "tree-sitter-bash",
46
46
  "tree-sitter-json",
@@ -0,0 +1,2 @@
1
+ __version__ = "0.3.1"
2
+ __git_commit__ = ""
@@ -20,6 +20,7 @@ from kop.widgets.Modals import (
20
20
  NodeShellLoading,
21
21
  NodeShellFailed,
22
22
  )
23
+ from kop.widgets.Transfer import FileTransferModal
23
24
  from kubernetes import client
24
25
  from typing import Optional
25
26
  from kop.provider.logs import PodLogs
@@ -572,6 +573,31 @@ class PodActionHandler(BaseActionHandlerMixin):
572
573
  callback=forward_callback,
573
574
  )
574
575
 
576
+ @staticmethod
577
+ def transfer(action, resource: PodViewModel, app):
578
+ if resource.status != "Running":
579
+ app.notify("Pod is not running", severity="error")
580
+ return
581
+
582
+ def open_transfer(container_name: str) -> None:
583
+ app.push_screen(
584
+ FileTransferModal(
585
+ api_client=app.endpoint.api_client,
586
+ pod_name=resource.name,
587
+ namespace=resource.namespace,
588
+ container_name=container_name,
589
+ )
590
+ )
591
+
592
+ if len(resource.containers) == 1:
593
+ container_obj = resource.containers[0].lazy_clean()
594
+ open_transfer(container_name=container_obj.name)
595
+ else:
596
+ app.push_screen(
597
+ Option([cs.lazy_clean().name for cs in resource.containers], action=action.name),
598
+ callback=open_transfer,
599
+ )
600
+
575
601
  @staticmethod
576
602
  def delete(action, resource: PodViewModel, app):
577
603
  def delete_callback(resource) -> None:
@@ -280,6 +280,12 @@ class PodFacotry(BaseFactory):
280
280
  tooltip="Pod logs",
281
281
  action="log",
282
282
  key="l"),
283
+ ActionModel(name="transfer",
284
+ label="Transfer",
285
+ variant="default",
286
+ tooltip="Transfer files",
287
+ action="transfer",
288
+ key="t"),
283
289
  ActionModel(name="forward",
284
290
  label="Forward",
285
291
  variant="default",
@@ -3,10 +3,39 @@ import threading
3
3
  import json
4
4
  import re
5
5
  from typing import Optional
6
- from kubernetes import watch
7
6
  from kubernetes.client import CoreV1Api
8
7
 
9
8
 
9
+ class PodLogStream:
10
+ def __init__(self, response) -> None:
11
+ self.response = response
12
+ self._stopped = False
13
+ self._lock = threading.Lock()
14
+
15
+ def stop(self) -> None:
16
+ with self._lock:
17
+ if self._stopped:
18
+ return
19
+ self._stopped = True
20
+ response = self.response
21
+
22
+ close = getattr(response, "close", None)
23
+ if callable(close):
24
+ close()
25
+ release_conn = getattr(response, "release_conn", None)
26
+ if callable(release_conn):
27
+ release_conn()
28
+
29
+ def __iter__(self):
30
+ stream = getattr(self.response, "stream", None)
31
+ if callable(stream):
32
+ yield from stream(decode_content=True)
33
+ return
34
+ data = getattr(self.response, "data", None)
35
+ if data:
36
+ yield data
37
+
38
+
10
39
 
11
40
 
12
41
  class PodLogs:
@@ -53,17 +82,17 @@ class PodLogs:
53
82
  )
54
83
 
55
84
  def watch_logs(self, timestamps: Optional[bool] = None, tail_lines: Optional[int] = 100):
56
- self.w = w = watch.Watch()
85
+ response = self.core_api.read_namespaced_pod_log(
86
+ **self._log_params(timestamps=timestamps, follow=True, tail_lines=tail_lines),
87
+ _preload_content=False,
88
+ )
89
+ self.w = log_stream = PodLogStream(response)
57
90
  try:
58
- for line in w.stream(
59
- self.core_api.read_namespaced_pod_log,
60
- **self._log_params(timestamps=timestamps, follow=True, tail_lines=tail_lines),
61
- ):
91
+ for line in log_stream:
62
92
  yield line
63
93
  finally:
64
- if w:
65
- w.stop()
66
- self.w = None
94
+ log_stream.stop()
95
+ self.w = None
67
96
 
68
97
 
69
98
  class LogController:
@@ -93,7 +122,15 @@ class LogController:
93
122
  def stop(self, wait: bool = True, timeout: float = 0.5) -> None:
94
123
  self._stop_event.set()
95
124
  if self.pod_logs.w:
96
- self.pod_logs.w.stop()
125
+ stream = self.pod_logs.w
126
+ if wait:
127
+ stream.stop()
128
+ else:
129
+ threading.Thread(
130
+ target=stream.stop,
131
+ daemon=True,
132
+ name="pod-log-stream-stop",
133
+ ).start()
97
134
  if wait and self._thread and self._thread.is_alive():
98
135
  self._thread.join(timeout=timeout)
99
136
 
@@ -0,0 +1,325 @@
1
+ import io
2
+ import os
3
+ import shlex
4
+ import tarfile
5
+ from dataclasses import dataclass
6
+ from pathlib import Path, PurePosixPath
7
+ from tempfile import TemporaryFile
8
+ from typing import Literal, Optional
9
+
10
+ from kubernetes.client import ApiClient, CoreV1Api
11
+ from kubernetes.stream import stream
12
+
13
+
14
+ EndpointKind = Literal["local", "pod"]
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class TransferEndpoint:
19
+ kind: EndpointKind
20
+ path: Path | PurePosixPath
21
+ is_dir: bool
22
+ container: Optional[str] = None
23
+
24
+ @property
25
+ def display(self) -> str:
26
+ return f"{self.kind}:{self.path}"
27
+
28
+ @property
29
+ def name(self) -> str:
30
+ name = self.path.name
31
+ return name or str(self.path)
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class PodFileEntry:
36
+ path: PurePosixPath
37
+ is_dir: bool
38
+
39
+ @property
40
+ def label(self) -> str:
41
+ """
42
+ self.path == self.path.parent is possible when path is root "/"
43
+ """
44
+ suffix = "/" if self.is_dir and self.path != self.path.parent else ""
45
+ return f"{self.path.name or '/'}{suffix}"
46
+
47
+
48
+ class PodFileSystem:
49
+ def __init__(
50
+ self,
51
+ api_client: ApiClient,
52
+ pod_name: str,
53
+ namespace: str,
54
+ container_name: Optional[str] = None,
55
+ ) -> None:
56
+ self.core_api = CoreV1Api(api_client=api_client)
57
+ self.pod_name = pod_name
58
+ self.namespace = namespace
59
+ self.container_name = container_name
60
+
61
+ def list_dir(self, path: PurePosixPath | str) -> list[PodFileEntry]:
62
+ pod_path = PurePosixPath(str(path) or "/")
63
+ quoted_path = shlex.quote(str(pod_path))
64
+ script = (
65
+ f"p={quoted_path}; "
66
+ 'if [ ! -d "$p" ]; then echo "not a directory: $p" >&2; exit 2; fi; '
67
+ 'for entry in "$p"/* "$p"/.[!.]* "$p"/..?*; do '
68
+ '[ -e "$entry" ] || continue; '
69
+ 'name=${entry##*/}; '
70
+ 'if [ -d "$entry" ]; then printf "d\\t%s\\n" "$name"; '
71
+ 'else printf "f\\t%s\\n" "$name"; fi; '
72
+ "done"
73
+ )
74
+ output = self._exec(["sh", "-c", script])
75
+ entries: list[PodFileEntry] = []
76
+ for line in output.splitlines():
77
+ if "\t" not in line:
78
+ continue
79
+ kind, name = line.split("\t", 1)
80
+ if not name or name in {".", ".."}:
81
+ continue
82
+ entries.append(PodFileEntry(path=pod_path / name, is_dir=kind == "d"))
83
+ entries.sort(key=lambda item: (not item.is_dir, item.path.name.lower()))
84
+ return entries
85
+
86
+ def _exec(self, command: list[str]) -> str:
87
+ return stream(
88
+ self.core_api.connect_get_namespaced_pod_exec,
89
+ self.pod_name,
90
+ self.namespace,
91
+ command=command,
92
+ container=self.container_name,
93
+ stderr=True,
94
+ stdin=False,
95
+ stdout=True,
96
+ tty=False,
97
+ _preload_content=True,
98
+ )
99
+
100
+
101
+ class PodFileTransfer:
102
+ def __init__(
103
+ self,
104
+ api_client: ApiClient,
105
+ pod_name: str,
106
+ namespace: str,
107
+ container_name: Optional[str] = None,
108
+ ) -> None:
109
+ self.core_api = CoreV1Api(api_client=api_client)
110
+ self.pod_name = pod_name
111
+ self.namespace = namespace
112
+ self.container_name = container_name
113
+ self._command_cache: dict[str, bool] = {}
114
+
115
+ def upload(self, source: Path, dest_dir: PurePosixPath, dest_name: str) -> None:
116
+ if not source.exists():
117
+ raise FileNotFoundError(str(source))
118
+ if not dest_name:
119
+ raise ValueError("Destination name is required")
120
+
121
+ if not self.has_command("tar"):
122
+ self._upload_file_with_cat(source, dest_dir, dest_name)
123
+ return
124
+ self._upload_with_tar(source, dest_dir, dest_name)
125
+
126
+ def download(
127
+ self,
128
+ source: PurePosixPath,
129
+ dest_dir: Path,
130
+ dest_name: str,
131
+ source_is_dir: bool = False,
132
+ ) -> None:
133
+ if not dest_name:
134
+ raise ValueError("Destination name is required")
135
+ if not self.has_command("tar"):
136
+ if source_is_dir:
137
+ raise RuntimeError("Directory download requires tar in the container")
138
+ self._download_file_with_cat(source, dest_dir, dest_name)
139
+ return
140
+ self._download_with_tar(source, dest_dir, dest_name)
141
+
142
+ def has_command(self, command: str) -> bool:
143
+ if command in self._command_cache:
144
+ return self._command_cache[command]
145
+ quoted = shlex.quote(command)
146
+ output = stream(
147
+ self.core_api.connect_get_namespaced_pod_exec,
148
+ self.pod_name,
149
+ self.namespace,
150
+ command=["sh", "-c", f"command -v {quoted} >/dev/null 2>&1 && echo yes || echo no"],
151
+ container=self.container_name,
152
+ stderr=False,
153
+ stdin=False,
154
+ stdout=True,
155
+ tty=False,
156
+ _preload_content=True,
157
+ )
158
+ available = str(output).strip() == "yes"
159
+ self._command_cache[command] = available
160
+ return available
161
+
162
+ def _upload_with_tar(self, source: Path, dest_dir: PurePosixPath, dest_name: str) -> None:
163
+ quoted_dir = shlex.quote(str(dest_dir))
164
+ command = ["sh", "-c", f"mkdir -p {quoted_dir} && tar xf - -C {quoted_dir}"]
165
+ resp = self._open_exec(command, stdin=True)
166
+ try:
167
+ with TemporaryFile() as archive:
168
+ with tarfile.open(fileobj=archive, mode="w") as tar:
169
+ tar.add(source, arcname=dest_name, recursive=True)
170
+ archive.seek(0)
171
+ while True:
172
+ chunk = archive.read(1024 * 256)
173
+ if not chunk:
174
+ break
175
+ resp.write_stdin(chunk)
176
+ resp.write_stdin(b"")
177
+ self._drain_response(resp)
178
+ finally:
179
+ resp.close()
180
+
181
+ def _upload_file_with_cat(self, source: Path, dest_dir: PurePosixPath, dest_name: str) -> None:
182
+ if source.is_dir():
183
+ raise RuntimeError("Directory upload requires tar in the container")
184
+
185
+ dest_path = dest_dir / dest_name
186
+ quoted_dir = shlex.quote(str(dest_dir))
187
+ quoted_dest = shlex.quote(str(dest_path))
188
+ command = ["sh", "-c", f"mkdir -p {quoted_dir} && cat > {quoted_dest}"]
189
+ resp = self._open_exec(command, stdin=True)
190
+ try:
191
+ with source.open("rb") as source_file:
192
+ while True:
193
+ chunk = source_file.read(1024 * 256)
194
+ if not chunk:
195
+ break
196
+ resp.write_stdin(chunk)
197
+ self._drain_response(resp)
198
+ finally:
199
+ resp.close()
200
+
201
+ def _download_with_tar(self, source: PurePosixPath, dest_dir: Path, dest_name: str) -> None:
202
+ dest_dir.mkdir(parents=True, exist_ok=True)
203
+ parent = str(source.parent) if str(source.parent) else "/"
204
+ source_name = source.name or "."
205
+ command = [
206
+ "sh",
207
+ "-c",
208
+ f"tar cf - -C {shlex.quote(parent)} {shlex.quote(source_name)}",
209
+ ]
210
+ resp = self._open_exec(command, stdin=False)
211
+ try:
212
+ archive_data = io.BytesIO()
213
+ empty_reads = 0
214
+ while resp.is_open():
215
+ resp.update(timeout=1)
216
+ stdout = resp.read_channel(1, timeout=0)
217
+ if stdout:
218
+ if isinstance(stdout, str):
219
+ stdout = stdout.encode()
220
+ archive_data.write(stdout)
221
+ empty_reads = 0
222
+ stderr = resp.read_channel(2, timeout=0)
223
+ if stderr:
224
+ raise RuntimeError(self._channel_text(stderr).strip())
225
+ if not stdout:
226
+ empty_reads += 1
227
+ if empty_reads >= 3:
228
+ break
229
+ archive_data.seek(0)
230
+ with tarfile.open(fileobj=archive_data, mode="r:*") as tar:
231
+ self._safe_extract(tar, dest_dir)
232
+ extracted = dest_dir / source_name
233
+ target = dest_dir / dest_name
234
+ if extracted != target and extracted.exists():
235
+ if target.exists():
236
+ raise FileExistsError(str(target))
237
+ os.replace(extracted, target)
238
+ finally:
239
+ resp.close()
240
+
241
+ def _download_file_with_cat(self, source: PurePosixPath, dest_dir: Path, dest_name: str) -> None:
242
+ dest_dir.mkdir(parents=True, exist_ok=True)
243
+ target = dest_dir / dest_name
244
+ command = ["sh", "-c", f"cat {shlex.quote(str(source))}"]
245
+ resp = self._open_exec(command, stdin=False)
246
+ try:
247
+ data = self._read_stdout_response(resp)
248
+ target.write_bytes(data)
249
+ finally:
250
+ resp.close()
251
+
252
+ def _open_exec(self, command: list[str], stdin: bool):
253
+ return stream(
254
+ self.core_api.connect_get_namespaced_pod_exec,
255
+ self.pod_name,
256
+ self.namespace,
257
+ command=command,
258
+ container=self.container_name,
259
+ stderr=True,
260
+ stdin=stdin,
261
+ stdout=True,
262
+ tty=False,
263
+ binary=True,
264
+ _preload_content=False,
265
+ )
266
+
267
+ def _drain_response(self, resp) -> None:
268
+ errors: list[str] = []
269
+ empty_reads = 0
270
+ while resp.is_open():
271
+ resp.update(timeout=1)
272
+ stderr = resp.read_channel(2, timeout=0)
273
+ if stderr:
274
+ errors.append(self._channel_text(stderr))
275
+ stdout = resp.read_channel(1, timeout=0)
276
+ if stderr or stdout:
277
+ empty_reads = 0
278
+ else:
279
+ empty_reads += 1
280
+ if empty_reads >= 3:
281
+ break
282
+ if errors:
283
+ raise RuntimeError("".join(errors).strip())
284
+
285
+ def _read_stdout_response(self, resp) -> bytes:
286
+ output = io.BytesIO()
287
+ errors: list[str] = []
288
+ empty_reads = 0
289
+ while resp.is_open():
290
+ resp.update(timeout=1)
291
+ stdout = resp.read_channel(1, timeout=0)
292
+ if stdout:
293
+ if isinstance(stdout, str):
294
+ stdout = stdout.encode()
295
+ output.write(stdout)
296
+ empty_reads = 0
297
+ stderr = resp.read_channel(2, timeout=0)
298
+ if stderr:
299
+ errors.append(self._channel_text(stderr))
300
+ empty_reads = 0
301
+ if not stdout and not stderr:
302
+ empty_reads += 1
303
+ if empty_reads >= 3:
304
+ break
305
+ if errors:
306
+ raise RuntimeError("".join(errors).strip())
307
+ return output.getvalue()
308
+
309
+ def _channel_text(self, value: bytes | str) -> str:
310
+ if isinstance(value, bytes):
311
+ return value.decode("utf-8", "replace")
312
+ return value
313
+
314
+ def _safe_extract(self, tar: tarfile.TarFile, dest_dir: Path) -> None:
315
+ """
316
+ Extracts a tar file to the specified destination directory while
317
+ preventing path traversal attacks and local file or directory were
318
+ overwritten by downloaded files
319
+ """
320
+ base = dest_dir.resolve()
321
+ for member in tar.getmembers():
322
+ target = (dest_dir / member.name).resolve()
323
+ if base not in target.parents and target != base:
324
+ raise RuntimeError(f"Unsafe tar path: {member.name}")
325
+ tar.extractall(dest_dir)
@@ -567,7 +567,7 @@ class ActionPortForward(ModalScreen):
567
567
  #remote_port_select {
568
568
  width: 100%;
569
569
  }
570
- #cancel, #start, #stop {
570
+ #cancel-forward, #start-forward, #stop-forward {
571
571
  width: 100%;
572
572
  }
573
573
  #action_buttons {
@@ -620,9 +620,9 @@ class ActionPortForward(ModalScreen):
620
620
  Select(options=port_options, value=initial_value, allow_blank=False, id="remote_port_select"),
621
621
  Label("Open in Browser"),
622
622
  Switch(id="open_in_browser", value=True),
623
- Grid(Button("Cancel", variant="error", id="cancel"),
624
- Button("Stop", variant="warning", id="stop", disabled=True),
625
- Button("Start", variant="primary", id="start", disabled=False),
623
+ Grid(Button("Cancel", variant="error", id="cancel-forward"),
624
+ Button("Stop", variant="warning", id="stop-forward", disabled=True),
625
+ Button("Start", variant="primary", id="start-forward", disabled=False),
626
626
  id="action_buttons",
627
627
  ),
628
628
  id="dialog",
@@ -642,14 +642,14 @@ class ActionPortForward(ModalScreen):
642
642
  def action_start(self) -> None:
643
643
  self.on_start_press()
644
644
 
645
- @on(Button.Pressed, "#cancel")
645
+ @on(Button.Pressed, "#cancel-forward")
646
646
  def on_cancel_press(self, event: Button.Pressed) -> None:
647
647
  self.app.pop_screen()
648
648
 
649
649
  def _update_action_buttons(self) -> None:
650
650
  remote_port_select = self.query_one("#remote_port_select", Select)
651
- start_btn = self.query_one("#start", Button)
652
- stop_btn = self.query_one("#stop", Button)
651
+ start_btn = self.query_one("#start-forward", Button)
652
+ stop_btn = self.query_one("#stop-forward", Button)
653
653
  selected = remote_port_select.value
654
654
  if selected == Select.NULL:
655
655
  start_btn.disabled = True
@@ -660,7 +660,7 @@ class ActionPortForward(ModalScreen):
660
660
  start_btn.disabled = is_forwarded or not self._is_local_port_valid()
661
661
  stop_btn.disabled = not is_forwarded
662
662
 
663
- @on(Button.Pressed, "#start")
663
+ @on(Button.Pressed, "#start-forward")
664
664
  def on_start_press(self) -> None:
665
665
  local_port_input = self.query_one("#local_port", Input)
666
666
  open_in_browser = self.query_one("#open_in_browser", Switch).value
@@ -689,7 +689,7 @@ class ActionPortForward(ModalScreen):
689
689
  }
690
690
  )
691
691
 
692
- @on(Button.Pressed, "#stop")
692
+ @on(Button.Pressed, "#stop-forward")
693
693
  def on_stop_press(self) -> None:
694
694
  remote_port_select = self.query_one("#remote_port_select", Select)
695
695
  selected = remote_port_select.value
@@ -721,7 +721,7 @@ class ActionPortForward(ModalScreen):
721
721
 
722
722
  @on(Input.Submitted, "#local_port")
723
723
  def submit_local_port(self) -> None:
724
- if not self.query_one("#start", Button).disabled:
724
+ if not self.query_one("#start-forward", Button).disabled:
725
725
  self.on_start_press()
726
726
 
727
727