bashhub 3.0.2__py3-none-any.whl
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.
- bashhub/__init__.py +0 -0
- bashhub/bashhub.py +240 -0
- bashhub/bashhub_globals.py +83 -0
- bashhub/bashhub_setup.py +237 -0
- bashhub/bh.py +125 -0
- bashhub/i_search.py +157 -0
- bashhub/interactive_search.py +120 -0
- bashhub/model/__init__.py +6 -0
- bashhub/model/command.py +55 -0
- bashhub/model/command_form.py +15 -0
- bashhub/model/min_command.py +13 -0
- bashhub/model/serializable.py +47 -0
- bashhub/model/status_view.py +15 -0
- bashhub/model/system.py +42 -0
- bashhub/rest_client.py +251 -0
- bashhub/shell/bashhub.fish +105 -0
- bashhub/shell/bashhub.sh +73 -0
- bashhub/shell/bashhub.zsh +65 -0
- bashhub/shell/deps/bash-preexec.sh +341 -0
- bashhub/shell/deps/lib-bashhub.sh +161 -0
- bashhub/shell_utils.py +17 -0
- bashhub/version.py +5 -0
- bashhub/view/__init__.py +0 -0
- bashhub/view/status.py +25 -0
- bashhub-3.0.2.dist-info/METADATA +203 -0
- bashhub-3.0.2.dist-info/RECORD +30 -0
- bashhub-3.0.2.dist-info/WHEEL +5 -0
- bashhub-3.0.2.dist-info/entry_points.txt +3 -0
- bashhub-3.0.2.dist-info/licenses/LICENSE.md +190 -0
- bashhub-3.0.2.dist-info/top_level.txt +1 -0
bashhub/i_search.py
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
|
|
3
|
+
import npyscreen
|
|
4
|
+
import datetime
|
|
5
|
+
import time
|
|
6
|
+
from . import rest_client
|
|
7
|
+
import curses
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class CommandList(npyscreen.MultiLineAction):
|
|
11
|
+
def __init__(self, *args, **keywords):
|
|
12
|
+
super(CommandList, self).__init__(*args, **keywords)
|
|
13
|
+
self.command_handlers = {}
|
|
14
|
+
|
|
15
|
+
# Any non highlited command handlers
|
|
16
|
+
self.add_handlers({
|
|
17
|
+
"q": self.exit_app,
|
|
18
|
+
ord("n"): self.h_cursor_line_down,
|
|
19
|
+
ord("p"): self.h_cursor_line_up,
|
|
20
|
+
curses.ascii.ESC: self.exit_app
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
# All handlers for when a command is highlighted
|
|
24
|
+
self.add_command_handlers({
|
|
25
|
+
ord("i"): self.go_to_command_details,
|
|
26
|
+
curses.ascii.SP: self.go_to_command_details,
|
|
27
|
+
curses.ascii.NL: self.select_command,
|
|
28
|
+
curses.ascii.CR: self.select_command,
|
|
29
|
+
curses.ascii.BS: self.delete_command,
|
|
30
|
+
curses.ascii.DEL: self.delete_command,
|
|
31
|
+
curses.KEY_BACKSPACE: self.delete_command,
|
|
32
|
+
curses.KEY_DC: self.delete_command
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
# Disable handling of ALL mouse events right now. Without this we're
|
|
36
|
+
# unable to select text when inside of interactive search. This is
|
|
37
|
+
# convenient for access to the clipboard since on bash it'll
|
|
38
|
+
# automatically execute the command. Eventually find a way to allow this.
|
|
39
|
+
# It'd be nice to allow clicking to select a line.
|
|
40
|
+
curses.mousemask(0)
|
|
41
|
+
|
|
42
|
+
def delete_command(self, command):
|
|
43
|
+
confirmed = npyscreen.notify_ok_cancel(
|
|
44
|
+
str(command), "Delete Command")
|
|
45
|
+
if confirmed:
|
|
46
|
+
result = rest_client.delete_command(command.uuid)
|
|
47
|
+
if result:
|
|
48
|
+
self.parent.parentApp.commands.remove(command)
|
|
49
|
+
self.parent.update_list()
|
|
50
|
+
|
|
51
|
+
def exit_app(self, vl):
|
|
52
|
+
self.parent.parentApp.switchForm(None)
|
|
53
|
+
|
|
54
|
+
def display_value(self, vl):
|
|
55
|
+
return "{0}".format(vl)
|
|
56
|
+
|
|
57
|
+
def add_command_handlers(self, command_handlers):
|
|
58
|
+
self.command_handlers = command_handlers
|
|
59
|
+
# wire up to use npyscreens h_act_on_hightlited
|
|
60
|
+
event_handlers = dict((key, self.h_act_on_highlighted)
|
|
61
|
+
for (key, value) in command_handlers.items())
|
|
62
|
+
self.add_handlers(event_handlers)
|
|
63
|
+
|
|
64
|
+
def actionHighlighted(self, command, keypress):
|
|
65
|
+
if keypress in self.command_handlers:
|
|
66
|
+
return self.command_handlers[keypress](command)
|
|
67
|
+
|
|
68
|
+
def go_to_command_details(self, command):
|
|
69
|
+
command_details = rest_client.get_command(command.uuid)
|
|
70
|
+
self.parent.parentApp.getForm('EDITRECORDFM').value = command_details
|
|
71
|
+
self.parent.parentApp.switchForm('EDITRECORDFM')
|
|
72
|
+
|
|
73
|
+
def select_command(self, command):
|
|
74
|
+
self.parent.parentApp.return_value = command
|
|
75
|
+
self.parent.parentApp.switchForm(None)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class CommandListDisplay(npyscreen.FormMutt):
|
|
79
|
+
MAIN_WIDGET_CLASS = CommandList
|
|
80
|
+
|
|
81
|
+
#COMMAND_WIDGET_CLASS = None
|
|
82
|
+
|
|
83
|
+
def beforeEditing(self):
|
|
84
|
+
self.wStatus1.value = "Bashhub Commands "
|
|
85
|
+
self.update_list()
|
|
86
|
+
|
|
87
|
+
def update_list(self):
|
|
88
|
+
self.wMain.values = self.parentApp.commands
|
|
89
|
+
self.wMain.display()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class EditRecord(npyscreen.ActionForm):
|
|
93
|
+
def __init__(self, *args, **keywords):
|
|
94
|
+
super(EditRecord, self).__init__()
|
|
95
|
+
self.add_handlers({
|
|
96
|
+
"q": self.previous_form,
|
|
97
|
+
curses.ascii.ESC: self.exit_app
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
def create(self):
|
|
101
|
+
self.value = None
|
|
102
|
+
self.command = self.add(npyscreen.TitleFixedText, name="Command:")
|
|
103
|
+
self.path = self.add(npyscreen.TitleFixedText, name="Path:")
|
|
104
|
+
self.created = self.add(npyscreen.TitleFixedText, name="Created At:")
|
|
105
|
+
self.exit_status = self.add(npyscreen.TitleFixedText,
|
|
106
|
+
name="Exit Status:")
|
|
107
|
+
self.system_name = self.add(npyscreen.TitleFixedText,
|
|
108
|
+
name="System Name:")
|
|
109
|
+
self.session_id = self.add(npyscreen.TitleFixedText,
|
|
110
|
+
name="Session Id:")
|
|
111
|
+
self.uuid = self.add(npyscreen.TitleFixedText, name="UUID:")
|
|
112
|
+
|
|
113
|
+
def exit_app(self, vl):
|
|
114
|
+
self.parentApp.switchForm(None)
|
|
115
|
+
|
|
116
|
+
def previous_form(self, vl):
|
|
117
|
+
self.parentApp.switchFormPrevious()
|
|
118
|
+
|
|
119
|
+
def beforeEditing(self):
|
|
120
|
+
if self.value:
|
|
121
|
+
record = self.value
|
|
122
|
+
self.name = "Command Details"
|
|
123
|
+
date_string = datetime.datetime.fromtimestamp(
|
|
124
|
+
record.created / 1000).strftime('%Y-%m-%d %H:%M:%S')
|
|
125
|
+
self.created.value = date_string
|
|
126
|
+
self.command.value = record.command
|
|
127
|
+
self.path.value = record.path
|
|
128
|
+
|
|
129
|
+
# Handle old commands that don't have exit status
|
|
130
|
+
exit_status = "None" if record.exit_status is None else str(
|
|
131
|
+
record.exit_status)
|
|
132
|
+
self.exit_status.value = exit_status
|
|
133
|
+
|
|
134
|
+
self.system_name.value = record.system_name
|
|
135
|
+
self.session_id.value = record.session_id
|
|
136
|
+
self.uuid.value = record.uuid
|
|
137
|
+
|
|
138
|
+
else:
|
|
139
|
+
self.command = "not found"
|
|
140
|
+
|
|
141
|
+
def on_ok(self):
|
|
142
|
+
self.parentApp.switchFormPrevious()
|
|
143
|
+
|
|
144
|
+
def on_cancel(self):
|
|
145
|
+
self.parentApp.switchFormPrevious()
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class InteractiveSearch(npyscreen.NPSAppManaged):
|
|
149
|
+
def __init__(self, commands, rest_client=None):
|
|
150
|
+
super(InteractiveSearch, self).__init__()
|
|
151
|
+
self.commands = commands
|
|
152
|
+
self.rest_client = rest_client
|
|
153
|
+
self.return_value = None
|
|
154
|
+
|
|
155
|
+
def onStart(self):
|
|
156
|
+
self.addForm("MAIN", CommandListDisplay)
|
|
157
|
+
self.addForm("EDITRECORDFM", EditRecord)
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
#!/usr/bin/python
|
|
2
|
+
"""
|
|
3
|
+
Sampled from Lyle Scott scrolling curses
|
|
4
|
+
"""
|
|
5
|
+
import curses
|
|
6
|
+
import sys
|
|
7
|
+
import random
|
|
8
|
+
import time
|
|
9
|
+
import locale
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class InteractiveSearch:
|
|
13
|
+
DOWN = 1
|
|
14
|
+
UP = -1
|
|
15
|
+
SPACE_KEY = 32
|
|
16
|
+
ESC_KEY = 27
|
|
17
|
+
ENTER_KEY = 10
|
|
18
|
+
|
|
19
|
+
PREFIX_SELECTED = '_X_'
|
|
20
|
+
PREFIX_DESELECTED = '___'
|
|
21
|
+
|
|
22
|
+
outputLines = []
|
|
23
|
+
commands = []
|
|
24
|
+
screen = None
|
|
25
|
+
|
|
26
|
+
def __init__(self, commands):
|
|
27
|
+
|
|
28
|
+
self.commands = commands
|
|
29
|
+
# Parse out our input
|
|
30
|
+
self.outputLines = [x.__str__() for x in commands]
|
|
31
|
+
self.nOutputLines = len(self.outputLines)
|
|
32
|
+
|
|
33
|
+
self.topLineNum = 0
|
|
34
|
+
self.highlightLineNum = 0
|
|
35
|
+
self.markedLineNums = []
|
|
36
|
+
|
|
37
|
+
def run(self):
|
|
38
|
+
# Locale set to support utf-8 characters.
|
|
39
|
+
locale.setlocale(locale.LC_ALL, "")
|
|
40
|
+
return curses.wrapper(self._run)
|
|
41
|
+
|
|
42
|
+
def _run(self, screen):
|
|
43
|
+
self.screen = screen
|
|
44
|
+
curses.cbreak()
|
|
45
|
+
curses.start_color()
|
|
46
|
+
curses.use_default_colors()
|
|
47
|
+
self.screen.border(0)
|
|
48
|
+
while True:
|
|
49
|
+
self.displayScreen()
|
|
50
|
+
# get user command
|
|
51
|
+
c = self.screen.getch()
|
|
52
|
+
if c == curses.KEY_UP or c == ord('k'):
|
|
53
|
+
self.updown(self.UP)
|
|
54
|
+
elif c == curses.KEY_DOWN or c == ord('j'):
|
|
55
|
+
self.updown(self.DOWN)
|
|
56
|
+
elif c == self.ENTER_KEY:
|
|
57
|
+
return self.selectLine()
|
|
58
|
+
elif c == self.ESC_KEY or c == ord('q'):
|
|
59
|
+
sys.exit()
|
|
60
|
+
|
|
61
|
+
def markLine(self):
|
|
62
|
+
linenum = self.topLineNum + self.highlightLineNum
|
|
63
|
+
if linenum in self.markedLineNums:
|
|
64
|
+
self.markedLineNums.remove(linenum)
|
|
65
|
+
else:
|
|
66
|
+
self.markedLineNums.append(linenum)
|
|
67
|
+
|
|
68
|
+
def selectLine(self):
|
|
69
|
+
linenum = self.topLineNum + self.highlightLineNum
|
|
70
|
+
self.screen.erase()
|
|
71
|
+
self.restoreScreen()
|
|
72
|
+
return self.commands[linenum]
|
|
73
|
+
|
|
74
|
+
def displayScreen(self):
|
|
75
|
+
# clear screen
|
|
76
|
+
self.screen.erase()
|
|
77
|
+
|
|
78
|
+
# now paint the rows
|
|
79
|
+
top = self.topLineNum
|
|
80
|
+
bottom = self.topLineNum + curses.LINES
|
|
81
|
+
for (index, line, ) in enumerate(self.outputLines[top:bottom]):
|
|
82
|
+
line = '%s' % (line, )
|
|
83
|
+
|
|
84
|
+
# highlight current line
|
|
85
|
+
if index != self.highlightLineNum:
|
|
86
|
+
self.screen.addstr(index, 0, line)
|
|
87
|
+
else:
|
|
88
|
+
self.screen.addstr(index, 0, line, curses.A_STANDOUT)
|
|
89
|
+
self.screen.refresh()
|
|
90
|
+
|
|
91
|
+
# move highlight up/down one line
|
|
92
|
+
def updown(self, increment):
|
|
93
|
+
nextLineNum = self.highlightLineNum + increment
|
|
94
|
+
|
|
95
|
+
# paging
|
|
96
|
+
if increment == self.UP and self.highlightLineNum == 0 and self.topLineNum != 0:
|
|
97
|
+
self.topLineNum += self.UP
|
|
98
|
+
return
|
|
99
|
+
elif increment == self.DOWN and nextLineNum == curses.LINES and (
|
|
100
|
+
self.topLineNum + curses.LINES) != self.nOutputLines:
|
|
101
|
+
self.topLineNum += self.DOWN
|
|
102
|
+
return
|
|
103
|
+
|
|
104
|
+
# scroll highlight line
|
|
105
|
+
if increment == self.UP and (self.topLineNum != 0 or
|
|
106
|
+
self.highlightLineNum != 0):
|
|
107
|
+
self.highlightLineNum = nextLineNum
|
|
108
|
+
elif increment == self.DOWN and (
|
|
109
|
+
self.topLineNum + self.highlightLineNum + 1
|
|
110
|
+
) != self.nOutputLines and self.highlightLineNum != curses.LINES:
|
|
111
|
+
self.highlightLineNum = nextLineNum
|
|
112
|
+
|
|
113
|
+
def restoreScreen(self):
|
|
114
|
+
curses.nocbreak()
|
|
115
|
+
curses.echo()
|
|
116
|
+
curses.endwin()
|
|
117
|
+
|
|
118
|
+
# catch any weird termination situations
|
|
119
|
+
def __del__(self):
|
|
120
|
+
self.restoreScreen()
|
bashhub/model/command.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
#!/usr/bin/python
|
|
2
|
+
from .min_command import MinCommand
|
|
3
|
+
from time import *
|
|
4
|
+
import jsonpickle
|
|
5
|
+
import sys
|
|
6
|
+
import uuid
|
|
7
|
+
from .serializable import Serializable
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Command(Serializable):
|
|
11
|
+
def __init__(self,
|
|
12
|
+
command,
|
|
13
|
+
path,
|
|
14
|
+
uuid,
|
|
15
|
+
username,
|
|
16
|
+
system_name,
|
|
17
|
+
session_id,
|
|
18
|
+
created,
|
|
19
|
+
id,
|
|
20
|
+
exit_status=None):
|
|
21
|
+
self.command = command
|
|
22
|
+
self.path = path
|
|
23
|
+
self.uuid = uuid
|
|
24
|
+
self.exit_status = exit_status
|
|
25
|
+
self.username = username
|
|
26
|
+
self.system_name = system_name
|
|
27
|
+
self.session_id = session_id
|
|
28
|
+
self.created = created
|
|
29
|
+
self.id = id
|
|
30
|
+
|
|
31
|
+
# Optional fields not set by jsonpickle.
|
|
32
|
+
exit_status = None
|
|
33
|
+
|
|
34
|
+
def to_min_command(self):
|
|
35
|
+
return MinCommand(self.command, self.created, self.uuid)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class RegisterUser(Serializable):
|
|
39
|
+
def __init__(self, email, username, password, registration_code=""):
|
|
40
|
+
self.email = email
|
|
41
|
+
self.username = username
|
|
42
|
+
self.password = password
|
|
43
|
+
self.registration_code = registration_code
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class LoginForm(Serializable):
|
|
47
|
+
def __init__(self, username, password, mac=None):
|
|
48
|
+
self.username = username
|
|
49
|
+
self.password = password
|
|
50
|
+
self.mac = mac
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class LoginResponse(Serializable):
|
|
54
|
+
def __init__(self, access_token):
|
|
55
|
+
self.access_token = access_token
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from time import *
|
|
2
|
+
import uuid
|
|
3
|
+
from .serializable import Serializable
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class CommandForm(Serializable):
|
|
7
|
+
def __init__(self, command, path, exit_status, process_id,
|
|
8
|
+
process_start_time):
|
|
9
|
+
self.uuid = uuid.uuid4().__str__()
|
|
10
|
+
self.command = command
|
|
11
|
+
self.path = path
|
|
12
|
+
self.exit_status = exit_status
|
|
13
|
+
self.process_id = int(process_id)
|
|
14
|
+
self.process_start_time = process_start_time
|
|
15
|
+
self.created = int(round(time() * 1000))
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import requests
|
|
3
|
+
from .serializable import Serializable
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class MinCommand(Serializable):
|
|
7
|
+
def __init__(self, command, created, uuid):
|
|
8
|
+
self.command = command
|
|
9
|
+
self.created = created
|
|
10
|
+
self.uuid = uuid
|
|
11
|
+
|
|
12
|
+
def __str__(self):
|
|
13
|
+
return self.command
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import jsonpickle
|
|
2
|
+
import json
|
|
3
|
+
import requests
|
|
4
|
+
import inflection
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Serializable(object):
|
|
8
|
+
def to_JSON(self):
|
|
9
|
+
underscores = jsonpickle.encode(self)
|
|
10
|
+
temp = json.loads(underscores)
|
|
11
|
+
camel_case = self.convert_json(temp, self.lower_camelize)
|
|
12
|
+
return jsonpickle.encode(camel_case)
|
|
13
|
+
|
|
14
|
+
@classmethod
|
|
15
|
+
def lower_camelize(cls, string):
|
|
16
|
+
return inflection.camelize(string, False)
|
|
17
|
+
|
|
18
|
+
@classmethod
|
|
19
|
+
def convert_json(cls, d, convert):
|
|
20
|
+
new_d = {}
|
|
21
|
+
for k, v in d.items():
|
|
22
|
+
new_d[convert(k)] = cls.convert_json(v, convert) if isinstance(
|
|
23
|
+
v, dict) else v
|
|
24
|
+
return new_d
|
|
25
|
+
|
|
26
|
+
@classmethod
|
|
27
|
+
def from_JSON(cls, response):
|
|
28
|
+
temp_camel_case = json.loads(response)
|
|
29
|
+
temp = cls.convert_json(temp_camel_case, inflection.underscore)
|
|
30
|
+
|
|
31
|
+
# Add back our python classname so jsonpickle
|
|
32
|
+
# knows what class to deserialize it as
|
|
33
|
+
class_name = cls.__module__ + '.' + cls.__name__
|
|
34
|
+
temp['py/object'] = class_name
|
|
35
|
+
|
|
36
|
+
pickle = json.dumps(temp)
|
|
37
|
+
return jsonpickle.decode(pickle)
|
|
38
|
+
|
|
39
|
+
@classmethod
|
|
40
|
+
def from_JSON_list(cls, response):
|
|
41
|
+
|
|
42
|
+
#response = json.load(response)
|
|
43
|
+
|
|
44
|
+
# Use list comprehension to map every json object
|
|
45
|
+
# back to its object with from_JSON
|
|
46
|
+
items = [cls.from_JSON(json.dumps(item)) for item in response]
|
|
47
|
+
return items
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from .serializable import Serializable
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class StatusView(Serializable):
|
|
5
|
+
def __init__(self, username, total_commands, total_sessions, total_systems,
|
|
6
|
+
total_commands_today, session_name, session_start_time,
|
|
7
|
+
session_total_commands):
|
|
8
|
+
self.username = username
|
|
9
|
+
self.total_commands = total_commands
|
|
10
|
+
self.total_sessions = total_sessions
|
|
11
|
+
self.total_systems = total_systems
|
|
12
|
+
self.total_commands_today = total_commands_today
|
|
13
|
+
self.session_name = session_name
|
|
14
|
+
self.session_start_time = session_start_time
|
|
15
|
+
self.session_total_commands = session_total_commands
|
bashhub/model/system.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import jsonpickle
|
|
2
|
+
import json
|
|
3
|
+
import requests
|
|
4
|
+
from .serializable import Serializable
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class System(Serializable):
|
|
8
|
+
def __init__(self, name, mac, id, created, updated, hostname,
|
|
9
|
+
client_version):
|
|
10
|
+
self.name = name
|
|
11
|
+
self.mac = mac
|
|
12
|
+
self.id = id
|
|
13
|
+
self.created = created
|
|
14
|
+
self.updated = updated
|
|
15
|
+
self.hostname = hostname
|
|
16
|
+
self.client_version = client_version
|
|
17
|
+
|
|
18
|
+
def __str__(self):
|
|
19
|
+
return self.name + " " + self.id
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class RegisterSystem(Serializable):
|
|
23
|
+
def __init__(self, name, mac, hostname, client_version):
|
|
24
|
+
self.name = name
|
|
25
|
+
self.mac = mac
|
|
26
|
+
self.hostname = hostname
|
|
27
|
+
self.client_version = client_version
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class SystemPatch(Serializable):
|
|
31
|
+
def __init__(self,
|
|
32
|
+
name=None,
|
|
33
|
+
mac=None,
|
|
34
|
+
hostname=None,
|
|
35
|
+
client_version=None):
|
|
36
|
+
self.name = name
|
|
37
|
+
self.mac = mac
|
|
38
|
+
self.hostname = hostname
|
|
39
|
+
self.client_version = client_version
|
|
40
|
+
|
|
41
|
+
def __str__(self):
|
|
42
|
+
return self.name + " " + self.mac
|