1-
21# Pckgd
3- # Title: Pckgd
2+ # Title: Pckgd, A Package Manager
43# Description: A module for managing packages and their updates.
54# Updates from: github/TenthPres/TouchPointScripts/Pckgd/Pckgd.py
65# Version: 0.0.2
76# License: AGPL-3.0
8- # Author: James Kurtz
7+ # Author: James at Tenth
98
10- # Do not make edits to these files . They will be overwritten during updates.
9+ # Do not make edits to this file . They will be overwritten during updates.
1110
1211global model , q , Data
1312import json
13+ import time
1414
1515
1616class Pckgd :
@@ -20,10 +20,16 @@ def __init__(self, type_id, name, body):
2020 self .typeId = type_id
2121 self .headers = {}
2222 self .version = None
23+ self ._has_update_available = None
2324 self .parse_headers ()
2425 self .determine_version ()
26+ self .add_dependencies ()
27+
28+ def __del__ (self ):
29+ Pckgd ._save_meta_if_dirty ()
2530
2631 do_not_edit_demarcation = "========="
32+ dependents = {}
2733
2834 @staticmethod
2935 def find_installed_packages ():
@@ -48,7 +54,8 @@ def parse_headers(self):
4854 for line in lines :
4955 if (self .typeId == 5 and line .strip ().startswith ('#' )) or \
5056 (self .typeId == 4 and line .strip ().startswith ('--' )):
51- parts = line [1 :].split (':' , 1 )
57+ prefix_chars = 1 if self .typeId == 5 else 2
58+ parts = line [prefix_chars :].split (':' , 1 )
5259 if len (parts ) == 2 :
5360 key = parts [0 ].strip ()
5461 value = parts [1 ].strip ()
@@ -65,28 +72,110 @@ def parse_headers(self):
6572
6673 def get_action_buttons (self ):
6774 buttons = []
68- if 'Updates From' in self .headers :
69- buttons .append ({
70- "label" : "View Update Source" ,
71- "url" : self .get_update_source (),
72- "class" : "btn-primary"
73- })
7475
75- return buttons
76+ if self .typeId == 5 :
77+ buttons .append ("""
78+ <a href="/PyScript/{}" class="btn btn-default">Run</a>
79+ """ .format (self .filename ))
80+ elif self .typeId == 4 :
81+ buttons .append ("""
82+ <a href="/RunScript/{}" class="btn btn-default">Run</a>
83+ """ .format (self .filename ))
84+
85+ link = self .get_repo_link ()
86+ if link :
87+ buttons .append ("""
88+ <a href="{}" class="btn btn-default" target="_blank" title="View on GitHub"><i class="fa fa-github"></i></a>
89+ """ .format (link ))
90+
91+ if self .has_update_available ():
92+ buttons .append ("""
93+ <a href="PyScript/Pckgd?v=update&pkg={}" class="btn btn-primary">Update</a>
94+ """ .format (self .filename ))
95+
96+ return "\n " .join (buttons )
7697
7798 def determine_version (self ):
7899 # if version header is set AND it's a hex value or numbered, assume it's right.
79100 if 'Version' in self .headers and self .headers ['Version' ].strip () != "" and all (c in '0123456789abcdefABCDEF.' for c in self .headers ['Version' ]):
80101 self .version = self .headers ['Version' ]
81102 else :
82103 # find relevant part of body to hash
83- b = self .body .split ("Pckgd" , 1 )[1 ]
104+ b = self .body
105+ if "Pckgd" in self .body :
106+ b = b .split ("Pckgd" , 1 )[1 ]
84107 if Pckgd .do_not_edit_demarcation in b :
85108 b = b .split (Pckgd .do_not_edit_demarcation , 1 )[1 ]
86109
110+ b = b .strip ().replace ('\r \n ' , '\n ' ).replace ('\r ' , '\n ' )
111+
87112 self .version = Pckgd .calculate_version_hash (b )
88113 self .headers ['Version' ] = self .version
89114
115+
116+ def add_dependencies (self ):
117+ if 'Requires' in self .headers :
118+ dependencies = [d .strip () for d in self .headers ['Requires' ].split (',' )]
119+ for dep in dependencies :
120+ if dep not in Pckgd .dependents :
121+ Pckgd .dependents [dep ] = []
122+ Pckgd .dependents [dep ].append (self .filename_with_extension ())
123+
124+
125+ def dependents_list (self ):
126+ if self .filename_with_extension () in Pckgd .dependents :
127+ return Pckgd .dependents [self .filename_with_extension ()]
128+ return []
129+
130+ def dependencies_list (self ):
131+ if 'Requires' in self .headers :
132+ return [d .strip () for d in self .headers ['Requires' ].split (',' )]
133+ return []
134+
135+ def has_dependents (self ):
136+ deps = self .dependents_list ()
137+ return len (deps )
138+
139+ def has_dependencies (self ):
140+ return len (self .dependencies_list ())
141+
142+ def dependencies_with_updates_available (self ):
143+ updates = []
144+ for dep in self .dependencies_list ():
145+ dep_pkg = None
146+ for p in Pckgd .find_installed_packages ():
147+ if p .filename_with_extension () == dep :
148+ dep_pkg = p
149+ break
150+ if dep_pkg and dep_pkg .has_update_available ():
151+ updates .append ((dep , dep_pkg .has_update_available ()))
152+ return updates
153+
154+ def get_repo_link (self ):
155+ if not 'Updates From' in self .headers :
156+ return None
157+
158+ if self .headers ['Updates From' ].lower ().startswith ("github" ):
159+ path = self .headers ['Updates From' ].split ('/' , 3 )
160+ if len (path ) == 4 : # some level of validation...
161+ github_meta = Pckgd ._get_github_repo_metadata (path [1 ] + "/" + path [2 ])
162+
163+ default_branch = "main"
164+ if 'default_branch' in github_meta :
165+ default_branch = github_meta ['default_branch' ]
166+
167+ return "https://github.com/{}/{}/blob/{}/{}" .format (path [1 ], path [2 ], default_branch , path [3 ])
168+ return None
169+
170+
171+ def filename_with_extension (self ):
172+ if self .typeId == 5 :
173+ return self .filename + ".py"
174+ elif self .typeId == 4 :
175+ return self .filename + ".sql"
176+ else :
177+ return self .filename
178+
90179 """ Sets or updates a header for a given body. """
91180 @staticmethod
92181 def set_header (body , key , value , type_id ):
@@ -153,36 +242,77 @@ def get_update_source(self):
153242 return None
154243
155244 if self .headers ['Updates From' ].lower ().startswith ("github" ):
156- # TODO make this more efficient by caching default branch per repo and make use of other metadata, too.
157245 path = self .headers ['Updates From' ].split ('/' , 3 )
158246 if len (path ) == 4 : # some level of validation...
159-
160- # query github api to get default branch
161- url = "https://api.github.com/repos/{}/{}" .format (path [1 ], path [2 ])
162-
163- response = model .RestGet (url , {"Accept" : "application/vnd.github.v3+json" })
164- response = json .loads (response )
247+ github_meta = Pckgd ._get_github_repo_metadata (path [1 ] + "/" + path [2 ])
165248
166249 default_branch = "main"
167- if 'default_branch' in response :
168- default_branch = response ['default_branch' ]
250+ if 'default_branch' in github_meta :
251+ default_branch = github_meta ['default_branch' ]
169252
170253 return "https://raw.githubusercontent.com/{}/{}/refs/heads/{}/{}" .format (path [1 ], path [2 ], default_branch , path [3 ])
171254
172255 return None
173256
174257 """ Checks if an update is available for this package. Note that this makes at least one, possibly more, HTTP requests. """
175258 def has_update_available (self ):
176- update_source = self .get_update_source ()
177- if not update_source :
178- return False
259+ if self ._has_update_available is None :
260+
261+ update_source = self .get_update_source ()
262+ if not update_source :
263+ self ._has_update_available = False
264+ return self ._has_update_available
265+
266+ remote_content = model .RestGet (update_source , {})
267+ if remote_content == "404: Not Found" : # How GitHub specifically handles these things.
268+ self ._has_update_available = False
269+ raise Exception ("Update source not found: {}" .format (update_source ))
270+
271+ remote_pkg = Pckgd (self .typeId , self .filename , remote_content )
272+ if remote_pkg .version != self .version :
273+ self ._has_update_available = remote_pkg .version
274+ else :
275+ self ._has_update_available = False
276+ return self ._has_update_available
277+
278+ _saved_meta = None
279+
280+ @staticmethod
281+ def _get_saved_meta ():
282+ if Pckgd ._saved_meta is None :
283+ saved_meta = model .TextContent ("PckgdCache.json" )
284+ if saved_meta .strip () == "" :
285+ Pckgd ._saved_meta = {}
286+ else :
287+ Pckgd ._saved_meta = json .loads (saved_meta )
288+ return Pckgd ._saved_meta
289+
290+ @staticmethod
291+ def _get_github_repo_metadata (repo_path ):
292+ meta = Pckgd ._get_saved_meta ()
293+
294+ if not "github_repo_meta" in meta :
295+ meta ["github_repo_meta" ] = {}
179296
180- remote_content = model .RestGet (update_source , {})
181- if remote_content == "404: Not Found" : # How Github specifically handles these things.
182- raise Exception ("Update source not found: {}" .format (update_source ))
297+ if not repo_path in meta ["github_repo_meta" ]:
298+ meta ["github_repo_meta" ][repo_path ] = {}
183299
184- remote_pkg = Pckgd (self .typeId , self .filename , remote_content )
185- return remote_pkg .version != self .version
300+ if '_expires' not in meta ["github_repo_meta" ][repo_path ] or meta ["github_repo_meta" ][repo_path ]['_expires' ] < time .time ():
301+ # query GitHub api to get default branch and other such stuff
302+ url = "https://api.github.com/repos/{}" .format (repo_path )
303+ response = model .RestGet (url , {"Accept" : "application/vnd.github.v3+json" })
304+ meta ["github_repo_meta" ][repo_path ]['data' ] = json .loads (response )
305+ meta ["github_repo_meta" ][repo_path ]['_expires' ] = time .time () + 86400 # cache for 1 day
306+ meta ['_dirty' ] = True
307+
308+ return meta ["github_repo_meta" ][repo_path ]['data' ]
309+
310+ @staticmethod
311+ def _save_meta_if_dirty ():
312+ meta = Pckgd ._get_saved_meta ()
313+ if '_dirty' in meta and meta ['_dirty' ]:
314+ del meta ['_dirty' ]
315+ model .WriteContentText ("PckgdCache.json" , json .dumps (meta , indent = 2 ))
186316
187317
188318if model .HttpMethod == "get" and Data .v == "" :
@@ -197,31 +327,55 @@ def has_update_available(self):
197327 print ("<p>No packages are currently installed.<p />\n " )
198328
199329 for p in installed :
330+ if p .has_dependents ():
331+ continue # skip packages that have dependents; they will be shown with their parent package.
332+
200333 print ("<div class=\" package col-12 col-sm-6 col-md-4 col-lg-3\" >\n " )
201334 print ("<div class=\" package-header\" style=\" {}\" ></div>\n " .format (p .get_header_style ()))
202335
203336 print ("<div class=\" package-body\" >\n " )
204- name = p .headers ['Name ' ] if 'Name ' in p .headers else p .filename
337+ name = p .headers ['Title ' ] if 'Title ' in p .headers else p .filename
205338 print ("<h3>{}</h3>\n " .format (name ))
206339 if 'Description' in p .headers :
207340 print ("<p>{}</p>\n " .format (p .headers ['Description' ]))
208341
342+ update = False
209343 try :
210- if p .has_update_available ():
211- print ("<p><strong>Update available!</strong></p>\n " )
344+ update = p .has_update_available ()
212345 except Exception as e :
213346 print ("<p><strong>Error checking for updates: {}</strong></p>\n " .format (str (e )))
214347
348+ update_dep = False
349+ try :
350+ update_dep = p .dependencies_with_updates_available ()
351+ except Exception as e :
352+ print ("<p><strong>Error checking for dependency updates: {}</strong></p>\n " .format (str (e )))
353+
354+ if update or update_dep :
355+ print ("<p><strong style=\" color:#600\" >Update available.</strong></p>\n " )
356+
215357 pkg_caps = []
216358 if 'Author' in p .headers :
217- pkg_caps .append ("by {}" .format (p .headers ['Author' ]))
359+ pkg_caps .append ("by {}" .format (p .headers ['Author' ]))
218360
219361 if 'License' in p .headers :
220362 pkg_caps .append ("<span title=\" License\" >{}</span>" .format (p .headers ['License' ]))
221363
222364 pkg_caps .append ("Version: {}" .format (p .headers ['Version' ]))
223365
366+ if len (update_dep ) > 0 :
367+ pkg_caps .append ("<span style=\" color:#600\" title=\" {0} files are ready to be updated.\" >↻ {0} Files</span>" .format (1 * (not not update ) + len (update_dep )))
368+
369+ elif update :
370+ pkg_caps .append ("<span style=\" color:#600\" title=\" New Version Available\" >↻ {}</span>" .format (update ))
371+
372+ if p .has_dependencies () or p .has_dependents ():
373+ pkg_caps .append ("<span title=\" {0} Dependents: files that depend on this file\" >{0}</span>:<span title=\" {1} Dependencies: files on which this file depends\" >{1}</span>" .format (p .has_dependents (), p .has_dependencies ()))
374+
224375 print ("<p class=\" package-caption\" >{}</p>\n " .format (" • " .join (pkg_caps )))
376+
377+ print (p .get_action_buttons ())
378+
225379 print ("</div>\n " )
226380 print ("</div>\n " )
227381
@@ -239,6 +393,10 @@ def has_update_available(self):
239393 border-width: 0 1px 1px;
240394 border-style: solid;
241395 border-color: #ccc;
396+ height: 14em;
397+ margin-bottom: 2em;
398+ overflow-y: auto;
399+ overflow-x: hidden;
242400 }
243401 div.package-body h3 {
244402 margin-top: 0;
0 commit comments