Initial commit of a new experimental modular GAM.

This commit is contained in:
Jay Lee
2026-07-03 08:33:14 -04:00
parent 2fbc3c5c35
commit 8a89a91414
129 changed files with 88262 additions and 82716 deletions

0
src/gam/util/__init__.py Normal file
View File

188
src/gam/util/access.py Normal file
View File

@@ -0,0 +1,188 @@
"""GAM access-error and entity-warning utilities.
Extracted from gam/__init__.py. Provides access-error diagnostics,
API access denied handlers, and entity warning functions.
"""
import sys
from gamlib import glaction
from gamlib import glapi as API
from gamlib import glcfg as GC
from gamlib import glentity
from gamlib import glgapi as GAPI
from gamlib import glglobals as GM
from gamlib import glindent
from gamlib import glmsgs as Msg
def _getMain():
return sys.modules['gam']
Act = glaction.GamAction()
Ent = glentity.GamEntity()
Ind = glindent.GamIndent()
# Something's wrong with CustomerID??
def accessErrorMessage(cd, errMsg=None):
_m = _getMain()
if cd is None:
cd = _m.buildGAPIObject(API.DIRECTORY)
try:
_m.callGAPI(cd.customers(), 'get',
throwReasons=[GAPI.BAD_REQUEST, GAPI.INVALID_INPUT, GAPI.RESOURCE_NOT_FOUND,
GAPI.FORBIDDEN, GAPI.PERMISSION_DENIED],
customerKey=GC.Values[GC.CUSTOMER_ID], fields='id')
except (GAPI.badRequest, GAPI.invalidInput):
return _m.formatKeyValueList('',
[Ent.Singular(Ent.CUSTOMER_ID), GC.Values[GC.CUSTOMER_ID],
Msg.INVALID],
'')
except GAPI.resourceNotFound:
return _m.formatKeyValueList('',
[Ent.Singular(Ent.CUSTOMER_ID), GC.Values[GC.CUSTOMER_ID],
Msg.DOES_NOT_EXIST],
'')
except (GAPI.forbidden, GAPI.permissionDenied):
return _m.formatKeyValueList('',
Ent.FormatEntityValueList([Ent.CUSTOMER_ID, GC.Values[GC.CUSTOMER_ID],
Ent.DOMAIN, GC.Values[GC.DOMAIN],
Ent.USER, GM.Globals[GM.ADMIN]])+[Msg.ACCESS_FORBIDDEN],
'')
if errMsg:
return _m.formatKeyValueList('',
[Ent.Singular(Ent.CUSTOMER_ID), GC.Values[GC.CUSTOMER_ID],
errMsg],
'')
return None
def accessErrorExit(cd, errMsg=None):
_m = _getMain()
_m.systemErrorExit(_m.INVALID_DOMAIN_RC, accessErrorMessage(cd or _m.buildGAPIObject(API.DIRECTORY), errMsg))
def accessErrorExitNonDirectory(api, errMsg):
_m = _getMain()
_m.systemErrorExit(_m.API_ACCESS_DENIED_RC,
_m.formatKeyValueList('',
Ent.FormatEntityValueList([Ent.CUSTOMER_ID, GC.Values[GC.CUSTOMER_ID],
Ent.DOMAIN, GC.Values[GC.DOMAIN],
Ent.API, api])+[errMsg],
''))
def ClientAPIAccessDeniedExit(errMsg=None):
_m = _getMain()
if errMsg is None:
_m.stderrErrorMsg(Msg.API_ACCESS_DENIED)
missingScopes = API.getClientScopesSet(GM.Globals[GM.CURRENT_CLIENT_API])-GM.Globals[GM.CURRENT_CLIENT_API_SCOPES]
if missingScopes:
_m.writeStderr(Msg.API_CHECK_CLIENT_AUTHORIZATION.format(GM.Globals[GM.OAUTH2_CLIENT_ID],
','.join(sorted(missingScopes))))
_m.systemErrorExit(_m.API_ACCESS_DENIED_RC, None)
else:
_m.stderrErrorMsg(errMsg)
_m.systemErrorExit(_m.API_ACCESS_DENIED_RC, Msg.REAUTHENTICATION_IS_NEEDED)
def SvcAcctAPIAccessDenied():
_m = _getMain()
_m._getSvcAcctData()
if (GM.Globals[GM.CURRENT_SVCACCT_API] == API.GMAIL and
GM.Globals[GM.CURRENT_SVCACCT_API_SCOPES] and
GM.Globals[GM.CURRENT_SVCACCT_API_SCOPES][0] == API.GMAIL_SEND_SCOPE):
_m.systemErrorExit(_m.OAUTH2SERVICE_JSON_REQUIRED_RC, Msg.NO_SVCACCT_ACCESS_ALLOWED)
_m.stderrErrorMsg(Msg.API_ACCESS_DENIED)
apiOrScopes = API.getAPIName(GM.Globals[GM.CURRENT_SVCACCT_API]) if GM.Globals[GM.CURRENT_SVCACCT_API] else ','.join(sorted(GM.Globals[GM.CURRENT_SVCACCT_API_SCOPES]))
_m.writeStderr(Msg.API_CHECK_SVCACCT_AUTHORIZATION.format(GM.Globals[GM.OAUTH2SERVICE_JSON_DATA]['client_id'],
apiOrScopes,
GM.Globals[GM.CURRENT_SVCACCT_USER] or _m._getAdminEmail()))
def SvcAcctAPIAccessDeniedExit():
_m = _getMain()
SvcAcctAPIAccessDenied()
_m.systemErrorExit(_m.API_ACCESS_DENIED_RC, None)
def SvcAcctAPIDisabledExit():
_m = _getMain()
if not GM.Globals[GM.CURRENT_SVCACCT_USER] and GM.Globals[GM.CURRENT_CLIENT_API]:
ClientAPIAccessDeniedExit()
if GM.Globals[GM.CURRENT_SVCACCT_API]:
_m.stderrErrorMsg(Msg.SERVICE_ACCOUNT_API_DISABLED.format(API.getAPIName(GM.Globals[GM.CURRENT_SVCACCT_API])))
_m.systemErrorExit(_m.API_ACCESS_DENIED_RC, None)
_m.systemErrorExit(_m.API_ACCESS_DENIED_RC, Msg.API_ACCESS_DENIED)
def APIAccessDeniedExit():
_m = _getMain()
if not GM.Globals[GM.CURRENT_SVCACCT_USER] and GM.Globals[GM.CURRENT_CLIENT_API]:
ClientAPIAccessDeniedExit()
if GM.Globals[GM.CURRENT_SVCACCT_API]:
SvcAcctAPIAccessDeniedExit()
_m.systemErrorExit(_m.API_ACCESS_DENIED_RC, Msg.API_ACCESS_DENIED)
def checkEntityDNEorAccessErrorExit(cd, entityType, entityName, i=0, count=0):
_m = _getMain()
message = accessErrorMessage(cd)
if message:
_m.systemErrorExit(_m.INVALID_DOMAIN_RC, message)
_m.entityDoesNotExistWarning(entityType, entityName, i, count)
def checkEntityAFDNEorAccessErrorExit(cd, entityType, entityName, i=0, count=0):
_m = _getMain()
message = accessErrorMessage(cd)
if message:
_m.systemErrorExit(_m.INVALID_DOMAIN_RC, message)
_m.entityActionFailedWarning([entityType, entityName], Msg.DOES_NOT_EXIST, i, count)
def checkEntityItemValueAFDNEorAccessErrorExit(cd, entityType, entityName, itemType, itemValue, i=0, count=0):
_m = _getMain()
message = accessErrorMessage(cd)
if message:
_m.systemErrorExit(_m.INVALID_DOMAIN_RC, message)
_m.entityActionFailedWarning([entityType, entityName, itemType, itemValue], Msg.DOES_NOT_EXIST, i, count)
def entityUnknownWarning(entityType, entityName, i=0, count=0):
_m = _getMain()
domain = _m.getEmailAddressDomain(entityName)
if (domain.endswith(GC.Values[GC.DOMAIN])) or (domain.endswith('google.com')):
_m.entityDoesNotExistWarning(entityType, entityName, i, count)
else:
_m.entityServiceNotApplicableWarning(entityType, entityName, i, count)
def entityOrEntityUnknownWarning(entity1Type, entity1Name, entity2Type, entity2Name, i=0, count=0):
_m = _getMain()
_m.setSysExitRC(_m.ENTITY_DOES_NOT_EXIST_RC)
_m.writeStderr(_m.formatKeyValueList(Ind.Spaces(),
[f'{Msg.EITHER} {Ent.Singular(entity1Type)}', entity1Name, _m.getPhraseDNEorSNA(entity1Name), None,
f'{Msg.OR} {Ent.Singular(entity2Type)}', entity2Name, _m.getPhraseDNEorSNA(entity2Name)],
_m.currentCountNL(i, count)))
def duplicateAliasGroupUserWarning(cd, entityValueList, i=0, count=0):
_m = _getMain()
email = entityValueList[1]
try:
result = _m.callGAPI(cd.users(), 'get',
throwReasons=GAPI.USER_GET_THROW_REASONS,
userKey=email, fields='id,primaryEmail')
if (result['primaryEmail'].lower() == email) or (result['id'] == email):
kvList = [Ent.USER, email]
else:
kvList = [Ent.USER_ALIAS, email, Ent.USER, result['primaryEmail']]
except (GAPI.userNotFound, GAPI.badRequest,
GAPI.domainNotFound, GAPI.domainCannotUseApis, GAPI.forbidden, GAPI.backendError, GAPI.systemError):
try:
result = _m.callGAPI(cd.groups(), 'get',
throwReasons=GAPI.GROUP_GET_THROW_REASONS,
groupKey=email, fields='id,email')
if (result['email'].lower() == email) or (result['id'] == email):
kvList = [Ent.GROUP, email]
else:
kvList = [Ent.GROUP_ALIAS, email, Ent.GROUP, result['email']]
except (GAPI.groupNotFound,
GAPI.domainNotFound, GAPI.domainCannotUseApis, GAPI.forbidden, GAPI.badRequest):
kvList = [Ent.EMAIL, email]
_m.writeStderr(_m.formatKeyValueList(Ind.Spaces(),
Ent.FormatEntityValueList(entityValueList)+
[Act.Failed(), Msg.DUPLICATE]+
Ent.FormatEntityValueList(kvList),
_m.currentCountNL(i, count)))
_m.setSysExitRC(_m.ENTITY_DUPLICATE_RC)
return kvList[0]

1545
src/gam/util/api.py Normal file

File diff suppressed because it is too large Load Diff

1622
src/gam/util/args.py Normal file

File diff suppressed because it is too large Load Diff

1111
src/gam/util/batch.py Normal file

File diff suppressed because it is too large Load Diff

1104
src/gam/util/config.py Normal file

File diff suppressed because it is too large Load Diff

354
src/gam/util/connection.py Normal file
View File

@@ -0,0 +1,354 @@
"""Network diagnostics, version display, and usage functions.
Extracted from gam/__init__.py to reduce monolith size.
"""
import logging
import os
import platform
import socket
import ssl
import struct
import sys
import arrow
import httplib2
from cryptography import x509
from cryptography.hazmat.backends import default_backend
from importlib.metadata import version as lib_version
from urllib.parse import urlparse
from gamlib import glapi as API
from gamlib import glcfg as GC
from gamlib import glglobals as GM
from gamlib import glmsgs as Msg
from gamlib import glverlibs
class _InstanceProxy:
"""Lazy proxy that delegates attribute access to a named instance in the gam module."""
def __init__(self, name):
self._name = name
def __getattr__(self, attr):
return getattr(getattr(sys.modules['gam'], self._name), attr)
Cmd = _InstanceProxy('Cmd')
def _getEnt():
return sys.modules['gam'].Ent
def _getMain():
return sys.modules['gam']
# --- Constants ---
def _buildTimeOffsetUnits():
m = _getMain()
return [('day', m.SECONDS_PER_DAY), ('hour', m.SECONDS_PER_HOUR),
('minute', m.SECONDS_PER_MINUTE), ('second', 1)]
MACOS_CODENAMES = {
10: {
6: 'Snow Leopard',
7: 'Lion',
8: 'Mountain Lion',
9: 'Mavericks',
10: 'Yosemite',
11: 'El Capitan',
12: 'Sierra',
13: 'High Sierra',
14: 'Mojave',
15: 'Catalina',
16: 'Big Sur'
},
11: 'Big Sur',
12: 'Monterey',
13: 'Ventura',
14: 'Sonoma',
15: 'Sequoia',
26: 'Tahoe',
}
# --- Functions ---
def getLocalGoogleTimeOffset(testLocation=None):
m = _getMain()
if testLocation is None:
testLocation = m.GOOGLE_TIMECHECK_LOCATION
TIME_OFFSET_UNITS = _buildTimeOffsetUnits()
# If local time is well off, it breaks https because the server certificate will be seen as too old or new and thus invalid; http doesn't have that issue.
# Try with http first, if time is close (<MAX_LOCAL_GOOGLE_TIME_OFFSET seconds), retry with https as it should be OK
httpObj = m.getHttpObj()
for prot in ['http', 'https']:
try:
headerData = httpObj.request(f'{prot}://'+testLocation, 'HEAD')
googleUTC = arrow.Arrow.strptime(headerData[0]['date'], '%a, %d %b %Y %H:%M:%S %Z', tzinfo='UTC')
except (httplib2.HttpLib2Error, RuntimeError) as e:
m.handleServerError(e)
except httplib2.socks.HTTPError as e:
# If user has specified an HTTPS proxy, the http request will probably fail as httplib2
# turns a GET into a CONNECT which is not valid for an http address
if prot == 'http':
continue
m.handleServerError(e)
except (ValueError, KeyError):
if prot == 'http':
continue
m.systemErrorExit(m.NETWORK_ERROR_RC, Msg.INVALID_HTTP_HEADER.format(str(headerData)))
offset = remainder = int(abs((arrow.utcnow()-googleUTC).total_seconds()))
if offset < m.MAX_LOCAL_GOOGLE_TIME_OFFSET and prot == 'http':
continue
timeoff = []
for tou in TIME_OFFSET_UNITS:
uval, remainder = divmod(remainder, tou[1])
if uval:
timeoff.append(f'{uval} {tou[0]}{"s" if uval != 1 else ""}')
if not timeoff:
timeoff.append(Msg.LESS_THAN_1_SECOND)
nicetime = ', '.join(timeoff)
return (offset, nicetime)
def _getServerTLSUsed(location):
m = _getMain()
url = 'https://'+location
_, netloc, _, _, _, _ = urlparse(url)
conn = 'https:'+netloc
httpObj = m.getHttpObj()
triesLimit = 5
for n in range(1, triesLimit+1):
try:
httpObj.request(url, headers={'user-agent': m.GAM_USER_AGENT})
cipher_name, tls_ver, _ = httpObj.connections[conn].sock.cipher()
return tls_ver, cipher_name
except (httplib2.HttpLib2Error, RuntimeError) as e:
if n != triesLimit:
httpObj.connections = {}
m.waitOnFailure(n, triesLimit, m.NETWORK_ERROR_RC, str(e))
continue
m.handleServerError(e)
def getOSPlatform():
myos = platform.system()
if myos == 'Linux':
import distro
pltfrm = ' '.join(distro.linux_distribution(full_distribution_name=False)).title()
elif myos == 'Windows':
pltfrm = ' '.join(platform.win32_ver())
elif myos == 'Darwin':
myos = 'macOS'
mac_ver = platform.mac_ver()[0]
major_ver = int(mac_ver.split('.')[0]) # macver 10.14.6 == major_ver 10
minor_ver = int(mac_ver.split('.')[1]) # macver 10.14.6 == minor_ver 14
if major_ver == 10:
codename = MACOS_CODENAMES[major_ver].get(minor_ver, '')
else:
codename = MACOS_CODENAMES.get(major_ver, '')
pltfrm = ' '.join([codename, mac_ver])
else:
pltfrm = platform.platform()
return f'{myos} {pltfrm}'
def inspect_untrusted_cert(url):
"""Bypasses validation momentarily to extract the untrusted Issuer."""
parsed = urlparse(url if '://' in url else f'https://{url}')
host = parsed.hostname
port = parsed.port or 443
# Create an unverified context purely for diagnostic extraction
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
try:
with socket.create_connection((host, port), timeout=5) as sock:
with ctx.wrap_socket(sock, server_hostname=host) as ssock:
der_cert = ssock.getpeercert(binary_form=True)
cert = x509.load_der_x509_certificate(der_cert, default_backend())
issuer = cert.issuer.rfc4514_string()
subject = cert.subject.rfc4514_string()
try:
san_ext = cert.extensions.get_extension_for_oid(x509.oid.ExtensionOID.SUBJECT_ALTERNATIVE_NAME)
# Loop through the list of SANs (DNS names, IP addresses, etc.)
sans = [str(name.value) for name in san_ext.value]
san_str = ", ".join(sans)
except x509.ExtensionNotFound:
san_str = "None"
return f"Untrusted Issuer: {issuer}\n Server Subject: {subject}\n SANs: {san_str}"
except Exception as e:
return f"Failed to retrieve diagnostic certificate: {e}"
# gam checkconnection
def doCheckConnection():
m = _getMain()
def check_host(host):
nonlocal try_count, okay, not_okay, success_count
try_count += 1
dns_err = None
ip = 'unknown'
try:
ip = socket.getaddrinfo(host, None)[0][-1][0] # works with ipv6
except socket.gaierror as e:
dns_err = f'{not_okay}\n DNS failure: {str(e)}\n'
except Exception as e:
dns_err = f'{not_okay}\n Unknown DNS failure: {str(e)}\n'
check_line = f'Checking {host} ({ip}) ({try_count})...'
m.writeStdout(f'{check_line:<100}')
m.flushStdout()
if dns_err:
m.writeStdout(dns_err)
return
gen_firewall = 'You probably have security software or a firewall on your machine or network that is preventing GAM from making Internet connections. Check your network configuration or try running GAM on a hotspot or home network to see if the problem exists only on your organization\'s network.'
try:
if host.startswith('http'):
url = host
else:
url = f'https://{host}:443/'
httpObj.request(url, 'HEAD', headers=headers)
success_count += 1
m.writeStdout(f'{okay}\n')
except ConnectionRefusedError:
m.writeStdout(f'{not_okay}\n Connection refused. {gen_firewall}\n')
except ConnectionResetError:
m.writeStdout(f'{not_okay}\n Connection reset by peer. {gen_firewall}\n')
except httplib2.error.ServerNotFoundError:
m.writeStdout(f'{not_okay}\n Failed to find server. Your DNS is probably misconfigured.\n')
except ssl.SSLCertVerificationError as e:
diag_info = inspect_untrusted_cert(host)
# e.verify_message contains the specific OpenSSL error string
m.writeStdout(f'{not_okay}\n Certificate verification failed: {e.verify_message}\n Diagnostic Info:\n {diag_info}\nIf you are behind a firewall / proxy server that does TLS / SSL inspection you may need to point GAM at your certificate authority file by setting cacerts_pem = /path/to/your/certauth.pem in gam.cfg.\n')
except ssl.SSLError as e:
if e.reason == 'SSLV3_ALERT_HANDSHAKE_FAILURE':
m.writeStdout(f'{not_okay}\n GAM expects to connect with TLS 1.3 or newer and that failed. If your firewall / proxy server is not compatible with TLS 1.3 then you can tell GAM to allow TLS 1.2 by setting tls_min_version = TLSv1.2 in gam.cfg.\n')
elif e.reason == 'CERTIFICATE_VERIFY_FAILED':
m.writeStdout(f'{not_okay}\n Certificate verification failed. If you are behind a firewall / proxy server that does TLS / SSL inspection you may need to point GAM at your certificate authority file by setting cacerts_pem = /path/to/your/certauth.pem in gam.cfg.\n')
elif e.strerror and e.strerror.startswith('TLS/SSL connection has been closed\n'):
m.writeStdout(f'{not_okay}\n TLS connection was closed. {gen_firewall}\n')
else:
m.writeStdout(f'{not_okay}\n {str(e)}\n')
except TimeoutError:
m.writeStdout(f'{not_okay}\n Timed out trying to connect to host\n')
except Exception as e:
m.writeStdout(f'{not_okay}\n {str(e)}\n')
try_count = 0
httpObj = m.getHttpObj(timeout=30)
httpObj.follow_redirects = False
headers = {'user-agent': m.GAM_USER_AGENT}
okay = m.createGreenText('OK')
not_okay = m.createRedText('ERROR')
success_count = 0
initial_hosts = ['api.github.com',
'raw.githubusercontent.com',
'accounts.google.com',
'oauth2.googleapis.com',
'www.googleapis.com']
for host in initial_hosts:
check_host(host)
api_hosts = ['apps-apis.google.com',
'www.google.com']
for host in api_hosts:
check_host(host)
# For v2 discovery APIs, GAM gets discovery file from <api>.googleapis.com so
# add those domains.
disc_hosts = []
for api, config in API._INFO.items():
if config.get('v2discovery') and not config.get('localdiscovery'):
if mapped_api := config.get('mappedAPI'):
api = mapped_api
host = f'{api}.googleapis.com'
if host not in disc_hosts:
disc_hosts.append(host)
for host in disc_hosts:
check_host(host)
checked_hosts = initial_hosts + api_hosts + disc_hosts
# now we need to "build" each API and check it's base URL host
# if we haven't already. This may not be any hosts at all but
# to ensure we are checking all hosts GAM may use we should
# keep this.
for api in API._INFO:
if api in [API.CONTACTS, API.EMAIL_AUDIT]:
continue
svc = m.getService(api, httpObj)
base_url = svc._rootDesc.get('baseUrl')
parsed_base_url = urlparse(base_url)
base_host = parsed_base_url.netloc
if base_host not in checked_hosts:
m.writeStdout(f'Checking {base_host} for {api}\n')
check_host(base_host)
checked_hosts.append(base_host)
if success_count == try_count:
m.writeStdout(m.createGreenText('All hosts passed!\n'))
else:
m.systemErrorExit(3, m.createYellowText('Some hosts failed to connect! Please follow the recommendations for those hosts to correct any issues and try again.'))
# gam comment
def doComment():
m = _getMain()
m.writeStdout(Cmd.QuotedArgumentList(Cmd.Remaining())+'\n')
# gam version [check|checkrc|simple|extended] [timeoffset] [nooffseterror] [location <HostName>]
def doVersion(checkForArgs=True):
m = _getMain()
Ent = _getEnt()
forceCheck = 0
extended = noOffsetError = timeOffset = simple = False
testLocation = m.GOOGLE_TIMECHECK_LOCATION
if checkForArgs:
while Cmd.ArgumentsRemaining():
myarg = m.getArgument()
if myarg == 'check':
forceCheck = 1
elif myarg == 'checkrc':
forceCheck = -1
elif myarg == 'simple':
simple = True
elif myarg == 'extended':
extended = timeOffset = True
elif myarg == 'timeoffset':
timeOffset = True
elif myarg == 'nooffseterror':
noOffsetError = True
elif myarg == 'location':
testLocation = m.getString(Cmd.OB_HOST_NAME)
else:
m.unknownArgumentExit()
if simple:
m.writeStdout(m.__version__)
return
m.writeStdout((f'{m.GAM} {m.__version__} - {m.GAM_URL} - {GM.Globals[GM.GAM_TYPE]}\n'
f'{m.__author__}\n'
f'Python {sys.version_info[0]}.{sys.version_info[1]}.{sys.version_info[2]} {struct.calcsize("P")*8}-bit {sys.version_info[3]}\n'
f'{getOSPlatform()} {platform.machine()}\n'
f'Path: {GM.Globals[GM.GAM_PATH]}\n'
f'{Ent.Singular(Ent.CONFIG_FILE)}: {GM.Globals[GM.GAM_CFG_FILE]}, {Ent.Singular(Ent.SECTION)}: {GM.Globals[GM.GAM_CFG_SECTION_NAME]}, '
f'{GC.CUSTOMER_ID}: {GC.Values[GC.CUSTOMER_ID]}, {GC.DOMAIN}: {GC.Values[GC.DOMAIN]}\n'
f'Time: {m.ISOformatTimeStamp(m.todaysTime())}\n'
))
if sys.platform.startswith('win') and str(struct.calcsize('P')*8).find('32') != -1 and platform.machine().find('64') != -1:
m.printKeyValueList([Msg.UPDATE_GAM_TO_64BIT])
if timeOffset:
offsetSeconds, offsetFormatted = getLocalGoogleTimeOffset(testLocation)
m.printKeyValueList([Msg.YOUR_SYSTEM_TIME_DIFFERS_FROM_GOOGLE.format(testLocation, offsetFormatted)])
if offsetSeconds > m.MAX_LOCAL_GOOGLE_TIME_OFFSET:
if not noOffsetError:
m.systemErrorExit(m.NETWORK_ERROR_RC, Msg.PLEASE_CORRECT_YOUR_SYSTEM_TIME)
m.stderrWarningMsg(Msg.PLEASE_CORRECT_YOUR_SYSTEM_TIME)
if forceCheck:
m.doGAMCheckForUpdates(forceCheck)
if extended:
m.printKeyValueList([ssl.OPENSSL_VERSION])
tls_ver, cipher_name = _getServerTLSUsed(testLocation)
for lib in glverlibs.GAM_VER_LIBS:
try:
m.writeStdout(f'{lib} {lib_version(lib)}\n')
except:
pass
m.printKeyValueList([f'{testLocation} connects using {tls_ver} {cipher_name}'])
# gam help
def doUsage():
m = _getMain()
m.printBlankLine()
doVersion(checkForArgs=False)
m.writeStdout(Msg.HELP_SYNTAX.format(os.path.join(GM.Globals[GM.GAM_PATH], m.FN_GAMCOMMANDS_TXT)))
m.writeStdout(Msg.HELP_WIKI.format(m.GAM_WIKI))

1988
src/gam/util/csv_pf.py Normal file

File diff suppressed because it is too large Load Diff

570
src/gam/util/display.py Normal file
View File

@@ -0,0 +1,570 @@
"""GAM display utilities — printing, warning, action, and entity display functions.
Thin wrappers around writeStdout/writeStderr that format entity values, actions,
and messages for user-facing output. Depends only on gamlib modules and
util.output.
"""
import sys
from gamlib import glcfg as GC
from gamlib import glglobals as GM
from gamlib import glmsgs as Msg
class _InstanceProxy:
"""Lazy proxy that delegates attribute access to a named instance in the gam module."""
def __init__(self, name):
self._name = name
def __getattr__(self, attr):
return getattr(getattr(sys.modules['gam'], self._name), attr)
Act = _InstanceProxy('Act')
Ind = _InstanceProxy('Ind')
from util.output import (
currentCountNL,
formatKeyValueList,
printWarningMessage,
setSysExitRC,
writeStderr,
writeStdout,
)
# Constants duplicated from __init__.py to avoid circular imports.
ACTION_FAILED_RC = 50
ACTION_NOT_PERFORMED_RC = 51
BAD_REQUEST_RC = 53
ENTITY_DOES_NOT_EXIST_RC = 56
ENTITY_DUPLICATE_RC = 57
SERVICE_NOT_APPLICABLE_RC = 73
ERROR = 'ERROR'
FIRST_ITEM_MARKER = '%%first_item%%'
LAST_ITEM_MARKER = '%%last_item%%'
TOTAL_ITEMS_MARKER = '%%total_items%%'
def _getEnt():
"""Get the Ent instance from the main module (lazy accessor)."""
return sys.modules['gam'].Ent
def _getEscapeCRsNLs():
"""Get the escapeCRsNLs function from the main module (lazy accessor)."""
return sys.modules['gam'].escapeCRsNLs
# --- Warnings ---
def badRequestWarning(entityType, itemType, itemValue):
Ent = _getEnt()
printWarningMessage(BAD_REQUEST_RC,
f'{Msg.GOT} 0 {Ent.Plural(entityType)}: {Msg.INVALID} {Ent.Singular(itemType)} - {itemValue}')
def emptyQuery(query, entityType):
Ent = _getEnt()
return f'{Ent.Singular(Ent.QUERY)} ({query}) {Msg.NO_ENTITIES_FOUND.format(Ent.Plural(entityType))}'
def invalidQuery(query):
Ent = _getEnt()
return f'{Ent.Singular(Ent.QUERY)} ({query}) {Msg.INVALID}'
def invalidMember(query):
Ent = _getEnt()
if query:
badRequestWarning(Ent.GROUP, Ent.QUERY, invalidQuery(query))
return True
return False
def invalidUserSchema(schema):
Ent = _getEnt()
if isinstance(schema, list):
return f'{Ent.Singular(Ent.USER_SCHEMA)} ({",".join(schema)}) {Msg.INVALID}'
return f'{Ent.Singular(Ent.USER_SCHEMA)} {schema}) {Msg.INVALID}'
# --- Service Not Enabled Warnings ---
def userServiceNotEnabledWarning(entityName, service, i=0, count=0):
Ent = _getEnt()
setSysExitRC(SERVICE_NOT_APPLICABLE_RC)
writeStderr(formatKeyValueList(Ind.Spaces(),
[Ent.Singular(Ent.USER), entityName, Msg.SERVICE_NOT_ENABLED.format(service)],
currentCountNL(i, count)))
def userAlertsServiceNotEnabledWarning(entityName, i=0, count=0):
userServiceNotEnabledWarning(entityName, 'Alerts', i, count)
def userAnalyticsServiceNotEnabledWarning(entityName, i=0, count=0):
userServiceNotEnabledWarning(entityName, 'Alerts', i, count)
def userCalServiceNotEnabledWarning(entityName, i=0, count=0):
userServiceNotEnabledWarning(entityName, 'Calendar', i, count)
def userChatServiceNotEnabledWarning(entityName, i=0, count=0):
userServiceNotEnabledWarning(entityName, 'Chat', i, count)
def userContactDelegateServiceNotEnabledWarning(entityName, i=0, count=0):
userServiceNotEnabledWarning(entityName, 'Contact Delegate', i, count)
def userDriveServiceNotEnabledWarning(user, errMessage, i=0, count=0):
# if errMessage.find('Drive apps') == -1 and errMessage.find('Active session is invalid') == -1:
# entityServiceNotApplicableWarning(Ent.USER, user, i, count)
if errMessage.find('Drive apps') >= 0 or errMessage.find('Active session is invalid') >= 0:
userServiceNotEnabledWarning(user, 'Drive', i, count)
else:
entityActionNotPerformedWarning([_getEnt().USER, user], errMessage, i, count)
def userKeepServiceNotEnabledWarning(entityName, i=0, count=0):
userServiceNotEnabledWarning(entityName, 'Keep', i, count)
def userGmailServiceNotEnabledWarning(entityName, i=0, count=0):
userServiceNotEnabledWarning(entityName, 'Gmail', i, count)
def userLookerStudioServiceNotEnabledWarning(entityName, i=0, count=0):
userServiceNotEnabledWarning(entityName, 'Looker Studio', i, count)
def userPeopleServiceNotEnabledWarning(entityName, i=0, count=0):
userServiceNotEnabledWarning(entityName, 'People', i, count)
def userTasksServiceNotEnabledWarning(entityName, i=0, count=0):
userServiceNotEnabledWarning(entityName, 'Tasks', i, count)
def userYouTubeServiceNotEnabledWarning(entityName, i=0, count=0):
userServiceNotEnabledWarning(entityName, 'YouTube', i, count)
# --- Entity Warning Functions ---
def entityServiceNotApplicableWarning(entityType, entityName, i=0, count=0):
Ent = _getEnt()
setSysExitRC(SERVICE_NOT_APPLICABLE_RC)
writeStderr(formatKeyValueList(Ind.Spaces(),
[Ent.Singular(entityType), entityName, Msg.SERVICE_NOT_APPLICABLE],
currentCountNL(i, count)))
def entityDoesNotExistWarning(entityType, entityName, i=0, count=0):
Ent = _getEnt()
setSysExitRC(ENTITY_DOES_NOT_EXIST_RC)
writeStderr(formatKeyValueList(Ind.Spaces(),
[Ent.Singular(entityType), entityName, Msg.DOES_NOT_EXIST],
currentCountNL(i, count)))
def entityListDoesNotExistWarning(entityValueList, i=0, count=0):
Ent = _getEnt()
setSysExitRC(ENTITY_DOES_NOT_EXIST_RC)
writeStderr(formatKeyValueList(Ind.Spaces(),
Ent.FormatEntityValueList(entityValueList)+[Msg.DOES_NOT_EXIST],
currentCountNL(i, count)))
def entityDoesNotHaveItemWarning(entityValueList, i=0, count=0):
Ent = _getEnt()
setSysExitRC(ENTITY_DOES_NOT_EXIST_RC)
writeStderr(formatKeyValueList(Ind.Spaces(),
Ent.FormatEntityValueList(entityValueList)+[Msg.DOES_NOT_EXIST],
currentCountNL(i, count)))
def entityDuplicateWarning(entityValueList, i=0, count=0):
Ent = _getEnt()
setSysExitRC(ENTITY_DUPLICATE_RC)
writeStderr(formatKeyValueList(Ind.Spaces(),
Ent.FormatEntityValueList(entityValueList)+[Act.Failed(), Msg.DUPLICATE],
currentCountNL(i, count)))
def entityActionFailedWarning(entityValueList, errMessage, i=0, count=0):
Ent = _getEnt()
setSysExitRC(ACTION_FAILED_RC)
writeStderr(formatKeyValueList(Ind.Spaces(),
Ent.FormatEntityValueList(entityValueList)+[Act.Failed(), errMessage],
currentCountNL(i, count)))
def entityModifierItemValueListActionFailedWarning(entityValueList, modifier, infoTypeValueList, errMessage, i=0, count=0):
Ent = _getEnt()
setSysExitRC(ACTION_FAILED_RC)
writeStdout(formatKeyValueList(Ind.Spaces(),
Ent.FormatEntityValueList(entityValueList)+[f'{Act.ToPerform()} {modifier}', None]+Ent.FormatEntityValueList(infoTypeValueList)+[Act.Failed(), errMessage],
currentCountNL(i, count)))
def entityModifierActionFailedWarning(entityValueList, modifier, errMessage, i=0, count=0):
Ent = _getEnt()
setSysExitRC(ACTION_FAILED_RC)
writeStderr(formatKeyValueList(Ind.Spaces(),
Ent.FormatEntityValueList(entityValueList)+[f'{Act.ToPerform()} {modifier}', Act.Failed(), errMessage],
currentCountNL(i, count)))
def entityModifierNewValueActionFailedWarning(entityValueList, modifier, newValue, errMessage, i=0, count=0):
Ent = _getEnt()
setSysExitRC(ACTION_FAILED_RC)
writeStderr(formatKeyValueList(Ind.Spaces(),
Ent.FormatEntityValueList(entityValueList)+[f'{Act.ToPerform()} {modifier}', newValue, Act.Failed(), errMessage],
currentCountNL(i, count)))
def entityNumEntitiesActionFailedWarning(entityType, entityName, itemType, itemCount, errMessage, i=0, count=0):
Ent = _getEnt()
setSysExitRC(ACTION_FAILED_RC)
writeStderr(formatKeyValueList(Ind.Spaces(),
[Ent.Singular(entityType), entityName,
Ent.Choose(itemType, itemCount), itemCount,
Act.Failed(), errMessage],
currentCountNL(i, count)))
def entityActionNotPerformedWarning(entityValueList, errMessage, i=0, count=0):
Ent = _getEnt()
setSysExitRC(ACTION_NOT_PERFORMED_RC)
writeStderr(formatKeyValueList(Ind.Spaces(),
Ent.FormatEntityValueList(entityValueList)+[Act.NotPerformed(), errMessage],
currentCountNL(i, count)))
def entityItemValueListActionNotPerformedWarning(entityValueList, infoTypeValueList, errMessage, i=0, count=0):
Ent = _getEnt()
writeStdout(formatKeyValueList(Ind.Spaces(),
Ent.FormatEntityValueList(entityValueList)+[Act.NotPerformed(), '']+Ent.FormatEntityValueList(infoTypeValueList)+[errMessage],
currentCountNL(i, count)))
def entityModifierItemValueListActionNotPerformedWarning(entityValueList, modifier, infoTypeValueList, errMessage, i=0, count=0):
Ent = _getEnt()
writeStdout(formatKeyValueList(Ind.Spaces(),
Ent.FormatEntityValueList(entityValueList)+[f'{Act.NotPerformed()} {modifier}', None]+Ent.FormatEntityValueList(infoTypeValueList)+[errMessage],
currentCountNL(i, count)))
def entityNumEntitiesActionNotPerformedWarning(entityValueList, itemType, itemCount, errMessage, i=0, count=0):
Ent = _getEnt()
setSysExitRC(ACTION_NOT_PERFORMED_RC)
writeStderr(formatKeyValueList(Ind.Spaces(),
Ent.FormatEntityValueList(entityValueList)+[Ent.Choose(itemType, itemCount), itemCount, Act.NotPerformed(), errMessage],
currentCountNL(i, count)))
def entityBadRequestWarning(entityValueList, errMessage, i=0, count=0):
Ent = _getEnt()
setSysExitRC(BAD_REQUEST_RC)
writeStderr(formatKeyValueList(Ind.Spaces(),
Ent.FormatEntityValueList(entityValueList)+[ERROR, errMessage],
currentCountNL(i, count)))
# --- Getting / Paging Display ---
def printGettingAllAccountEntities(entityType, query='', qualifier='', accountType=None):
Ent = _getEnt()
if accountType is None:
accountType = Ent.ACCOUNT
if GC.Values[GC.SHOW_GETTINGS]:
if query:
Ent.SetGettingQuery(entityType, query)
elif qualifier:
Ent.SetGettingQualifier(entityType, qualifier)
else:
Ent.SetGetting(entityType)
writeStderr(f'{Msg.GETTING_ALL} {Ent.PluralGetting()}{Ent.GettingPreQualifier()}{Ent.MayTakeTime(accountType)}\n')
def printGotAccountEntities(count):
Ent = _getEnt()
if GC.Values[GC.SHOW_GETTINGS]:
writeStderr(f'{Msg.GOT} {count} {Ent.ChooseGetting(count)}{Ent.GettingPostQualifier()}\n')
def setGettingAllEntityItemsForWhom(entityItem, forWhom, query='', qualifier=''):
Ent = _getEnt()
if GC.Values[GC.SHOW_GETTINGS]:
if query:
Ent.SetGettingQuery(entityItem, query)
elif qualifier:
Ent.SetGettingQualifier(entityItem, qualifier)
else:
Ent.SetGetting(entityItem)
Ent.SetGettingForWhom(forWhom)
def printGettingAllEntityItemsForWhom(entityItem, forWhom, i=0, count=0, query='', qualifier='', entityType=None):
Ent = _getEnt()
if GC.Values[GC.SHOW_GETTINGS]:
setGettingAllEntityItemsForWhom(entityItem, forWhom, query=query, qualifier=qualifier)
writeStderr(f'{Msg.GETTING_ALL} {Ent.PluralGetting()}{Ent.GettingPreQualifier()} {Msg.FOR} {forWhom}{Ent.MayTakeTime(entityType)}{currentCountNL(i, count)}')
def printGotEntityItemsForWhom(count):
Ent = _getEnt()
if GC.Values[GC.SHOW_GETTINGS]:
writeStderr(f'{Msg.GOT} {count} {Ent.ChooseGetting(count)}{Ent.GettingPostQualifier()} {Msg.FOR} {Ent.GettingForWhom()}\n')
def printGettingEntityItem(entityType, entityItem, i=0, count=0):
Ent = _getEnt()
if GC.Values[GC.SHOW_GETTINGS]:
writeStderr(f'{Msg.GETTING} {Ent.Singular(entityType)} {entityItem}{currentCountNL(i, count)}')
def printGettingEntityItemForWhom(entityItem, forWhom, i=0, count=0):
Ent = _getEnt()
if GC.Values[GC.SHOW_GETTINGS]:
Ent.SetGetting(entityItem)
Ent.SetGettingForWhom(forWhom)
writeStderr(f'{Msg.GETTING} {Ent.PluralGetting()} {Msg.FOR} {forWhom}{currentCountNL(i, count)}')
def stderrEntityMessage(entityValueList, message, i=0, count=0):
Ent = _getEnt()
writeStderr(formatKeyValueList(Ind.Spaces(),
Ent.FormatEntityValueList(entityValueList)+[message],
currentCountNL(i, count)))
def getPageMessage(showFirstLastItems=False, showDate=None):
if not GC.Values[GC.SHOW_GETTINGS]:
return None
pageMessage = f'{Msg.GOT} {TOTAL_ITEMS_MARKER} {{0}}'
if showDate:
pageMessage += f' on {showDate}'
if showFirstLastItems:
pageMessage += f': {FIRST_ITEM_MARKER} - {LAST_ITEM_MARKER}'
else:
pageMessage += '...'
if GC.Values[GC.SHOW_GETTINGS_GOT_NL]:
pageMessage += '\n'
else:
GM.Globals[GM.LAST_GOT_MSG_LEN] = 0
return pageMessage
def getPageMessageForWhom(forWhom=None, showFirstLastItems=False, showDate=None, clearLastGotMsgLen=True):
Ent = _getEnt()
if not GC.Values[GC.SHOW_GETTINGS]:
return None
if forWhom:
Ent.SetGettingForWhom(forWhom)
pageMessage = f'{Msg.GOT} {TOTAL_ITEMS_MARKER} {{0}}{Ent.GettingPostQualifier()} {Msg.FOR} {Ent.GettingForWhom()}'
if showDate:
pageMessage += f' on {showDate}'
if showFirstLastItems:
pageMessage += f': {FIRST_ITEM_MARKER} - {LAST_ITEM_MARKER}'
else:
pageMessage += '...'
if GC.Values[GC.SHOW_GETTINGS_GOT_NL]:
pageMessage += '\n'
elif clearLastGotMsgLen:
GM.Globals[GM.LAST_GOT_MSG_LEN] = 0
return pageMessage
# --- Print Utilities ---
def printLine(message):
writeStdout(message+'\n')
def printBlankLine():
writeStdout('\n')
def printKeyValueList(kvList):
writeStdout(formatKeyValueList(Ind.Spaces(), kvList, '\n'))
def printKeyValueListWithCount(kvList, i, count):
writeStdout(formatKeyValueList(Ind.Spaces(), kvList, currentCountNL(i, count)))
def printKeyValueDict(kvDict):
for key, value in kvDict.items():
writeStdout(formatKeyValueList(Ind.Spaces(), [key, value], '\n'))
def printKeyValueWithCRsNLs(key, value):
escapeCRsNLs = _getEscapeCRsNLs()
if value.find('\n') >= 0 or value.find('\r') >= 0:
if GC.Values[GC.SHOW_CONVERT_CR_NL]:
printKeyValueList([key, escapeCRsNLs(value)])
else:
printKeyValueList([key, ''])
Ind.Increment()
printKeyValueList([Ind.MultiLineText(value)])
Ind.Decrement()
else:
printKeyValueList([key, value])
def printJSONKey(key):
writeStdout(formatKeyValueList(Ind.Spaces(), [key, None], ''))
def printJSONValue(value):
writeStdout(formatKeyValueList(' ', [value], '\n'))
def printEntity(entityValueList, i=0, count=0):
Ent = _getEnt()
writeStdout(formatKeyValueList(Ind.Spaces(),
Ent.FormatEntityValueList(entityValueList),
currentCountNL(i, count)))
def printEntityMessage(entityValueList, message, i=0, count=0):
Ent = _getEnt()
writeStdout(formatKeyValueList(Ind.Spaces(),
Ent.FormatEntityValueList(entityValueList)+[message],
currentCountNL(i, count)))
def printEntitiesCount(entityType, entityList):
Ent = _getEnt()
writeStdout(formatKeyValueList(Ind.Spaces(),
[Ent.Plural(entityType), None if entityList is None else f'({len(entityList)})'],
'\n'))
def printEntityKVList(entityValueList, infoKVList, i=0, count=0):
Ent = _getEnt()
writeStdout(formatKeyValueList(Ind.Spaces(),
Ent.FormatEntityValueList(entityValueList)+infoKVList,
currentCountNL(i, count)))
# --- performAction / entityPerformAction ---
def performAction(entityType, entityValue, i=0, count=0):
Ent = _getEnt()
writeStdout(formatKeyValueList(Ind.Spaces(),
[f'{Act.ToPerform()} {Ent.Singular(entityType)} {entityValue}'],
currentCountNL(i, count)))
def performActionNumItems(itemCount, itemType, i=0, count=0):
Ent = _getEnt()
writeStdout(formatKeyValueList(Ind.Spaces(),
[f'{Act.ToPerform()} {itemCount} {Ent.Choose(itemType, itemCount)}'],
currentCountNL(i, count)))
def performActionModifierNumItems(modifier, itemCount, itemType, i=0, count=0):
Ent = _getEnt()
writeStdout(formatKeyValueList(Ind.Spaces(),
[f'{Act.ToPerform()} {modifier} {itemCount} {Ent.Choose(itemType, itemCount)}'],
currentCountNL(i, count)))
def actionPerformedNumItems(itemCount, itemType, i=0, count=0):
Ent = _getEnt()
writeStderr(formatKeyValueList(Ind.Spaces(),
[f'{itemCount} {Ent.Choose(itemType, itemCount)} {Act.Performed()} '],
currentCountNL(i, count)))
def actionFailedNumItems(itemCount, itemType, errMessage, i=0, count=0):
Ent = _getEnt()
writeStderr(formatKeyValueList(Ind.Spaces(),
[f'{itemCount} {Ent.Choose(itemType, itemCount)} {Act.Failed()}: {errMessage} '],
currentCountNL(i, count)))
def actionNotPerformedNumItemsWarning(itemCount, itemType, errMessage, i=0, count=0):
Ent = _getEnt()
setSysExitRC(ACTION_NOT_PERFORMED_RC)
writeStderr(formatKeyValueList(Ind.Spaces(),
[Ent.Choose(itemType, itemCount), itemCount, Act.NotPerformed(), errMessage],
currentCountNL(i, count)))
def entityPerformAction(entityValueList, i=0, count=0):
Ent = _getEnt()
writeStdout(formatKeyValueList(Ind.Spaces(),
Ent.FormatEntityValueList(entityValueList)+[f'{Act.ToPerform()}'],
currentCountNL(i, count)))
def entityPerformActionNumItems(entityValueList, itemCount, itemType, i=0, count=0):
Ent = _getEnt()
writeStdout(formatKeyValueList(Ind.Spaces(),
Ent.FormatEntityValueList(entityValueList)+[f'{Act.ToPerform()} {itemCount} {Ent.Choose(itemType, itemCount)}'],
currentCountNL(i, count)))
def entityPerformActionModifierNumItems(entityValueList, modifier, itemCount, itemType, i=0, count=0):
Ent = _getEnt()
writeStdout(formatKeyValueList(Ind.Spaces(),
Ent.FormatEntityValueList(entityValueList)+[f'{Act.ToPerform()} {modifier} {itemCount} {Ent.Choose(itemType, itemCount)}'],
currentCountNL(i, count)))
def entityPerformActionNumItemsModifier(entityValueList, itemCount, itemType, modifier, i=0, count=0):
Ent = _getEnt()
writeStdout(formatKeyValueList(Ind.Spaces(),
Ent.FormatEntityValueList(entityValueList)+[f'{Act.ToPerform()} {itemCount} {Ent.Choose(itemType, itemCount)} {modifier}'],
currentCountNL(i, count)))
def entityPerformActionSubItemModifierNumItems(entityValueList, subitemType, modifier, itemCount, itemType, i=0, count=0):
Ent = _getEnt()
writeStdout(formatKeyValueList(Ind.Spaces(),
Ent.FormatEntityValueList(entityValueList)+[f'{Act.ToPerform()} {Ent.Plural(subitemType)} {modifier} {itemCount} {Ent.Choose(itemType, itemCount)}'],
currentCountNL(i, count)))
def entityPerformActionSubItemModifierNumItemsModifierNewValue(entityValueList, subitemType, modifier1, itemCount, itemType, modifier2, newValue, i=0, count=0):
Ent = _getEnt()
writeStdout(formatKeyValueList(Ind.Spaces(),
Ent.FormatEntityValueList(entityValueList)+
[f'{Act.ToPerform()} {Ent.Plural(subitemType)} {modifier1} {itemCount} {Ent.Choose(itemType, itemCount)} {modifier2}', newValue],
currentCountNL(i, count)))
def entityPerformActionModifierNumItemsModifier(entityValueList, modifier1, itemCount, itemType, modifier2, i=0, count=0):
Ent = _getEnt()
writeStdout(formatKeyValueList(Ind.Spaces(),
Ent.FormatEntityValueList(entityValueList)+[f'{Act.ToPerform()} {modifier1} {itemCount} {Ent.Choose(itemType, itemCount)} {modifier2}'],
currentCountNL(i, count)))
def entityPerformActionModifierItemValueList(entityValueList, modifier, infoTypeValueList, i=0, count=0):
Ent = _getEnt()
writeStdout(formatKeyValueList(Ind.Spaces(),
Ent.FormatEntityValueList(entityValueList)+[f'{Act.ToPerform()} {modifier}', None]+Ent.FormatEntityValueList(infoTypeValueList),
currentCountNL(i, count)))
def entityPerformActionModifierNewValue(entityValueList, modifier, newValue, i=0, count=0):
Ent = _getEnt()
writeStdout(formatKeyValueList(Ind.Spaces(),
Ent.FormatEntityValueList(entityValueList)+[f'{Act.ToPerform()} {modifier}', newValue],
currentCountNL(i, count)))
def entityPerformActionModifierNewValueItemValueList(entityValueList, modifier, newValue, infoTypeValueList, i=0, count=0):
Ent = _getEnt()
writeStdout(formatKeyValueList(Ind.Spaces(),
Ent.FormatEntityValueList(entityValueList)+[f'{Act.ToPerform()} {modifier}', newValue]+Ent.FormatEntityValueList(infoTypeValueList),
currentCountNL(i, count)))
def entityPerformActionItemValue(entityValueList, itemType, itemValue, i=0, count=0):
Ent = _getEnt()
writeStdout(formatKeyValueList(Ind.Spaces(),
Ent.FormatEntityValueList(entityValueList)+[Act.ToPerform(), None, Ent.Singular(itemType), itemValue],
currentCountNL(i, count)))
def entityPerformActionInfo(entityValueList, infoValue, i=0, count=0):
Ent = _getEnt()
writeStdout(formatKeyValueList(Ind.Spaces(),
Ent.FormatEntityValueList(entityValueList)+[Act.ToPerform(), infoValue],
currentCountNL(i, count)))
# --- entityActionPerformed / entityModifier ---
def entityActionPerformed(entityValueList, i=0, count=0):
Ent = _getEnt()
writeStdout(formatKeyValueList(Ind.Spaces(),
Ent.FormatEntityValueList(entityValueList)+[Act.Performed()],
currentCountNL(i, count)))
def entityActionPerformedMessage(entityValueList, message, i=0, count=0):
Ent = _getEnt()
if message:
writeStdout(formatKeyValueList(Ind.Spaces(),
Ent.FormatEntityValueList(entityValueList)+[Act.Performed(), message],
currentCountNL(i, count)))
else:
writeStdout(formatKeyValueList(Ind.Spaces(),
Ent.FormatEntityValueList(entityValueList)+[Act.Performed()],
currentCountNL(i, count)))
def entityNumItemsActionPerformed(entityValueList, itemCount, itemType, i=0, count=0):
Ent = _getEnt()
writeStdout(formatKeyValueList(Ind.Spaces(),
Ent.FormatEntityValueList(entityValueList)+[f'{itemCount} {Ent.Choose(itemType, itemCount)} {Act.Performed()}'],
currentCountNL(i, count)))
def entityModifierActionPerformed(entityValueList, modifier, i=0, count=0):
Ent = _getEnt()
writeStdout(formatKeyValueList(Ind.Spaces(),
Ent.FormatEntityValueList(entityValueList)+[f'{Act.Performed()} {modifier}', None],
currentCountNL(i, count)))
def entityModifierItemValueListActionPerformed(entityValueList, modifier, infoTypeValueList, i=0, count=0):
Ent = _getEnt()
writeStdout(formatKeyValueList(Ind.Spaces(),
Ent.FormatEntityValueList(entityValueList)+[f'{Act.Performed()} {modifier}', None]+Ent.FormatEntityValueList(infoTypeValueList),
currentCountNL(i, count)))
def entityModifierNewValueActionPerformed(entityValueList, modifier, newValue, i=0, count=0):
Ent = _getEnt()
writeStdout(formatKeyValueList(Ind.Spaces(),
Ent.FormatEntityValueList(entityValueList)+[f'{Act.Performed()} {modifier}', newValue],
currentCountNL(i, count)))
def entityModifierNewValueItemValueListActionPerformed(entityValueList, modifier, newValue, infoTypeValueList, i=0, count=0):
Ent = _getEnt()
writeStdout(formatKeyValueList(Ind.Spaces(),
Ent.FormatEntityValueList(entityValueList)+[f'{Act.Performed()} {modifier}', newValue]+Ent.FormatEntityValueList(infoTypeValueList),
currentCountNL(i, count)))
def entityModifierNewValueKeyValueActionPerformed(entityValueList, modifier, newValue, infoKey, infoValue, i=0, count=0):
Ent = _getEnt()
writeStdout(formatKeyValueList(Ind.Spaces(),
Ent.FormatEntityValueList(entityValueList)+[f'{Act.Performed()} {modifier}', newValue, infoKey, infoValue],
currentCountNL(i, count)))

193
src/gam/util/email.py Normal file
View File

@@ -0,0 +1,193 @@
"""GAM email utilities.
Extracted from gam/__init__.py. Provides email attachment handling
and email sending via Gmail API or SMTP.
"""
import base64
import mimetypes
import os
import smtplib
import ssl
import sys
from email.mime.application import MIMEApplication
from email.mime.audio import MIMEAudio
from email.mime.base import MIMEBase
from email.mime.image import MIMEImage
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from gamlib import glapi as API
from gamlib import glcfg as GC
from gamlib import glgapi as GAPI
def _getMain():
return sys.modules['gam']
# Add attachements to an email message
def _addAttachmentsToMessage(message, attachments):
gam = _getMain()
for attachment in attachments:
try:
attachFilename = gam.setFilePath(attachment[0], GC.INPUT_DIR)
attachContentType, attachEncoding = mimetypes.guess_type(attachFilename)
if attachContentType is None or attachEncoding is not None:
attachContentType = 'application/octet-stream'
main_type, sub_type = attachContentType.split('/', 1)
if main_type == 'text':
msg = MIMEText(gam.readFile(attachFilename, 'r', attachment[1]), _subtype=sub_type, _charset=gam.UTF8)
elif main_type == 'image':
msg = MIMEImage(gam.readFile(attachFilename, 'rb'), _subtype=sub_type)
elif main_type == 'audio':
msg = MIMEAudio(gam.readFile(attachFilename, 'rb'), _subtype=sub_type)
elif main_type == 'application':
msg = MIMEApplication(gam.readFile(attachFilename, 'rb'), _subtype=sub_type)
else:
msg = MIMEBase(main_type, sub_type)
msg.set_payload(gam.readFile(attachFilename, 'rb'))
msg.add_header('Content-Disposition', 'attachment', filename=os.path.basename(attachFilename))
message.attach(msg)
except (IOError, UnicodeDecodeError) as e:
gam.usageErrorExit(f'{attachFilename}: {str(e)}')
# Add embedded images to an email message
def _addEmbeddedImagesToMessage(message, embeddedImages):
gam = _getMain()
for embeddedImage in embeddedImages:
try:
imageFilename = gam.setFilePath(embeddedImage[0], GC.INPUT_DIR)
imageContentType, imageEncoding = mimetypes.guess_type(imageFilename)
if imageContentType is None or imageEncoding is not None:
imageContentType = 'application/octet-stream'
main_type, sub_type = imageContentType.split('/', 1)
if main_type == 'image':
msg = MIMEImage(gam.readFile(imageFilename, 'rb'), _subtype=sub_type)
else:
msg = MIMEBase(main_type, sub_type)
msg.set_payload(gam.readFile(imageFilename, 'rb'))
msg.add_header('Content-Disposition', 'attachment', filename=os.path.basename(imageFilename))
msg.add_header('Content-ID', f'<{embeddedImage[1]}>')
message.attach(msg)
except (IOError, UnicodeDecodeError) as e:
gam.usageErrorExit(f'{imageFilename}: {str(e)}')
# Send an email
def send_email(msgSubject, msgBody, msgTo, i=0, count=0, clientAccess=False, msgFrom=None, msgReplyTo=None,
html=False, charset=None, attachments=None, embeddedImages=None,
msgHeaders=None, ccRecipients=None, bccRecipients=None, mailBox=None, threadId=None,
action=None):
gam = _getMain()
Act = gam.Act
Ent = gam.Ent
if charset is None:
charset = gam.UTF8
if action is None:
action = Act.SENDEMAIL
def checkResult(entityType, recipients):
if not recipients:
return
toSent = set(recipients.split(','))
toFailed = {}
for addr, err in result.items():
if addr in toSent:
toSent.remove(addr)
toFailed[addr] = f'{err[0]}: {err[1]}'
if toSent:
gam.entityActionPerformed([entityType, ','.join(toSent), Ent.MESSAGE, msgSubject], i, count)
for addr, errMsg in toFailed.items():
gam.entityActionFailedWarning([entityType, addr, Ent.MESSAGE, msgSubject], errMsg, i, count)
def cleanAddr(emailAddr):
match = gam.NAME_EMAIL_ADDRESS_PATTERN.match(emailAddr)
if match:
emailName = match.group(1)
emailAddr = gam.normalizeEmailAddressOrUID(match.group(2), noUid=True, noLower=True)
return (f'{emailName} <{emailAddr}>', emailAddr)
emailAddr = gam.normalizeEmailAddressOrUID(emailAddr, noUid=True, noLower=True)
return (emailAddr, emailAddr)
if msgFrom is None:
msgFrom = gam._getAdminEmail()
# Force ASCII for RFC compliance
# xmlcharref seems to work to display at least
# some unicode in HTML body and is ignored in
# plain text body.
# msgBody = msgBody.encode('ascii', 'xmlcharrefreplace').decode(gam.UTF8)
if not attachments and not embeddedImages:
message = MIMEText(msgBody, ['plain', 'html'][html], charset)
else:
message = MIMEMultipart()
msg = MIMEText(msgBody, ['plain', 'html'][html], charset)
message.attach(msg)
if attachments:
_addAttachmentsToMessage(message, attachments)
if embeddedImages:
_addEmbeddedImagesToMessage(message, embeddedImages)
message['Subject'] = msgSubject
message['From'], msgFromAddr = cleanAddr(msgFrom)
if msgReplyTo is not None:
message['Reply-To'], _ = cleanAddr(msgReplyTo)
if ccRecipients:
message['Cc'] = ccRecipients.lower()
if bccRecipients:
message['Bcc'] = bccRecipients.lower()
if msgHeaders:
for header, value in msgHeaders.items():
if header not in {'Subject', 'From', 'To', 'Reply-To', 'Cc', 'Bcc'}:
message[header] = value
if mailBox is None:
mailBox = msgFromAddr
_, mailBoxAddr = cleanAddr(mailBox)
parentAction = Act.Get()
Act.Set(action)
if not GC.Values[GC.SMTP_HOST]:
if not clientAccess:
userId, gmail = gam.buildGAPIServiceObject(API.GMAIL, mailBoxAddr)
if not gmail:
Act.Set(parentAction)
return
else:
userId = mailBoxAddr
gmail = gam.buildGAPIObject(API.GMAIL)
message['To'] = msgTo if msgTo else userId
body = {'raw': base64.urlsafe_b64encode(message.as_bytes()).decode()}
if threadId is not None:
body['threadId'] = threadId
try:
result = gam.callGAPI(gmail.users().messages(), 'send',
throwReasons=[GAPI.SERVICE_NOT_AVAILABLE, GAPI.AUTH_ERROR, GAPI.DOMAIN_POLICY,
GAPI.INVALID, GAPI.INVALID_ARGUMENT, GAPI.FORBIDDEN, GAPI.PERMISSION_DENIED],
userId=userId, body=body, fields='id')
gam.entityActionPerformedMessage([Ent.RECIPIENT, msgTo, Ent.MESSAGE, msgSubject], f"{result['id']}", i, count)
except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy,
GAPI.invalid, GAPI.invalidArgument, GAPI.forbidden, GAPI.permissionDenied) as e:
gam.entityActionFailedWarning([Ent.RECIPIENT, msgTo, Ent.MESSAGE, msgSubject], str(e), i, count)
else:
message['To'] = msgTo if msgTo else mailBoxAddr
server = None
try:
server = smtplib.SMTP(GC.Values[GC.SMTP_HOST], 587, GC.Values[GC.SMTP_FQDN])
if GC.Values[GC.DEBUG_LEVEL] > 0:
server.set_debuglevel(1)
server.starttls(context=ssl.create_default_context(cafile=GC.Values[GC.CACERTS_PEM]))
if GC.Values[GC.SMTP_USERNAME] and GC.Values[GC.SMTP_PASSWORD]:
if isinstance(GC.Values[GC.SMTP_PASSWORD], bytes):
server.login(GC.Values[GC.SMTP_USERNAME], base64.b64decode(GC.Values[GC.SMTP_PASSWORD]).decode(gam.UTF8))
else:
server.login(GC.Values[GC.SMTP_USERNAME], GC.Values[GC.SMTP_PASSWORD])
result = server.send_message(message)
checkResult(Ent.RECIPIENT, message['To'])
checkResult(Ent.RECIPIENT_CC, ccRecipients)
checkResult(Ent.RECIPIENT_BCC, bccRecipients)
except smtplib.SMTPException as e:
gam.entityActionFailedWarning([Ent.RECIPIENT, msgTo, Ent.MESSAGE, msgSubject], str(e), i, count)
if server:
try:
server.quit()
except Exception:
pass
Act.Set(parentAction)

1558
src/gam/util/entity.py Normal file

File diff suppressed because it is too large Load Diff

213
src/gam/util/errors.py Normal file
View File

@@ -0,0 +1,213 @@
"""Error, exit, and argument validation functions.
Extracted from gam/__init__.py to reduce monolith size.
Functions here depend only on gamlib modules and util/output.
"""
import os
import sys
from gamlib import glcfg as GC
from gamlib import glglobals as GM
from gamlib import glmsgs as Msg
class _InstanceProxy:
"""Lazy proxy that delegates attribute access to a named instance in the gam module."""
def __init__(self, name):
self._name = name
def __getattr__(self, attr):
return getattr(getattr(sys.modules['gam'], self._name), attr)
Act = _InstanceProxy('Act')
Cmd = _InstanceProxy('Cmd')
Ind = _InstanceProxy('Ind')
from util.output import (
currentCountNL,
formatKeyValueList,
stderrErrorMsg,
stderrWarningMsg,
systemErrorExit,
writeStderr,
)
# Lazy accessor for Ent (runtime instance)
def _getEnt():
return sys.modules['gam'].Ent
# Constants imported from __init__.py at module level via lazy accessor
def _getConst(name):
return getattr(sys.modules['gam'], name)
# --- Return code constants (imported lazily) ---
ACTION_FAILED_RC = None
CLIENT_SECRETS_JSON_REQUIRED_RC = None
ENTITY_DOES_NOT_EXIST_RC = None
ENTITY_IS_NOT_UNIQUE_RC = None
INVALID_JSON_RC = None
OAUTH2_TXT_REQUIRED_RC = None
OAUTH2SERVICE_JSON_REQUIRED_RC = None
USAGE_ERROR_RC = None
def _ensureConstants():
global ACTION_FAILED_RC, CLIENT_SECRETS_JSON_REQUIRED_RC, ENTITY_DOES_NOT_EXIST_RC
global ENTITY_IS_NOT_UNIQUE_RC, INVALID_JSON_RC, OAUTH2_TXT_REQUIRED_RC
global OAUTH2SERVICE_JSON_REQUIRED_RC, USAGE_ERROR_RC
if ACTION_FAILED_RC is None:
m = sys.modules['gam']
ACTION_FAILED_RC = m.ACTION_FAILED_RC
CLIENT_SECRETS_JSON_REQUIRED_RC = m.CLIENT_SECRETS_JSON_REQUIRED_RC
ENTITY_DOES_NOT_EXIST_RC = m.ENTITY_DOES_NOT_EXIST_RC
ENTITY_IS_NOT_UNIQUE_RC = m.ENTITY_IS_NOT_UNIQUE_RC
INVALID_JSON_RC = m.INVALID_JSON_RC
OAUTH2_TXT_REQUIRED_RC = m.OAUTH2_TXT_REQUIRED_RC
OAUTH2SERVICE_JSON_REQUIRED_RC = m.OAUTH2SERVICE_JSON_REQUIRED_RC
USAGE_ERROR_RC = m.USAGE_ERROR_RC
# --- Credential file errors ---
def invalidClientSecretsJsonExit(errMsg):
_ensureConstants()
Ent = _getEnt()
stderrErrorMsg(Msg.DOES_NOT_EXIST_OR_HAS_INVALID_FORMAT.format(Ent.Singular(Ent.CLIENT_SECRETS_JSON_FILE), GC.Values[GC.CLIENT_SECRETS_JSON], errMsg))
writeStderr(Msg.INSTRUCTIONS_CLIENT_SECRETS_JSON)
systemErrorExit(CLIENT_SECRETS_JSON_REQUIRED_RC, None)
def invalidOauth2serviceJsonExit(errMsg):
_ensureConstants()
Ent = _getEnt()
stderrErrorMsg(Msg.DOES_NOT_EXIST_OR_HAS_INVALID_FORMAT.format(Ent.Singular(Ent.OAUTH2SERVICE_JSON_FILE), GC.Values[GC.OAUTH2SERVICE_JSON], errMsg))
writeStderr(Msg.INSTRUCTIONS_OAUTH2SERVICE_JSON)
systemErrorExit(OAUTH2SERVICE_JSON_REQUIRED_RC, None)
def invalidOauth2TxtExit(errMsg):
_ensureConstants()
Ent = _getEnt()
stderrErrorMsg(Msg.DOES_NOT_EXIST_OR_HAS_INVALID_FORMAT.format(Ent.Singular(Ent.OAUTH2_TXT_FILE), GC.Values[GC.OAUTH2_TXT], errMsg))
writeStderr(Msg.EXECUTE_GAM_OAUTH_CREATE)
systemErrorExit(OAUTH2_TXT_REQUIRED_RC, None)
def expiredRevokedOauth2TxtExit():
_ensureConstants()
Ent = _getEnt()
stderrErrorMsg(Msg.IS_EXPIRED_OR_REVOKED.format(Ent.Singular(Ent.OAUTH2_TXT_FILE), GC.Values[GC.OAUTH2_TXT]))
writeStderr(Msg.EXECUTE_GAM_OAUTH_CREATE)
systemErrorExit(OAUTH2_TXT_REQUIRED_RC, None)
def invalidDiscoveryJsonExit(fileName, errMsg):
_ensureConstants()
Ent = _getEnt()
stderrErrorMsg(Msg.DOES_NOT_EXIST_OR_HAS_INVALID_FORMAT.format(Ent.Singular(Ent.DISCOVERY_JSON_FILE), fileName, errMsg))
systemErrorExit(INVALID_JSON_RC, None)
# --- Entity error exits ---
def entityActionFailedExit(entityValueList, errMsg, i=0, count=0):
_ensureConstants()
Ent = _getEnt()
systemErrorExit(ACTION_FAILED_RC, formatKeyValueList(Ind.Spaces(),
Ent.FormatEntityValueList(entityValueList)+[Act.Failed(), errMsg],
currentCountNL(i, count)))
def entityDoesNotExistExit(entityType, entityName, i=0, count=0, errMsg=None):
_ensureConstants()
Ent = _getEnt()
Cmd.Backup()
writeStderr(Cmd.CommandLineWithBadArgumentMarked(False))
systemErrorExit(ENTITY_DOES_NOT_EXIST_RC, formatKeyValueList(Ind.Spaces(),
[Ent.Singular(entityType), entityName, errMsg or Msg.DOES_NOT_EXIST],
currentCountNL(i, count)))
def entityDoesNotHaveItemExit(entityValueList, i=0, count=0):
_ensureConstants()
Ent = _getEnt()
Cmd.Backup()
writeStderr(Cmd.CommandLineWithBadArgumentMarked(False))
systemErrorExit(ENTITY_DOES_NOT_EXIST_RC, formatKeyValueList(Ind.Spaces(),
Ent.FormatEntityValueList(entityValueList)+[Msg.DOES_NOT_EXIST],
currentCountNL(i, count)))
def entityIsNotUniqueExit(entityType, entityName, valueType, valueList, i=0, count=0):
_ensureConstants()
Ent = _getEnt()
Cmd.Backup()
writeStderr(Cmd.CommandLineWithBadArgumentMarked(False))
systemErrorExit(ENTITY_IS_NOT_UNIQUE_RC, formatKeyValueList(Ind.Spaces(),
[Ent.Singular(entityType), entityName, Msg.IS_NOT_UNIQUE.format(Ent.Plural(valueType), ','.join(valueList))],
currentCountNL(i, count)))
# --- Usage/argument errors ---
def usageErrorExit(message, extraneous=False):
_ensureConstants()
writeStderr(Cmd.CommandLineWithBadArgumentMarked(extraneous))
stderrErrorMsg(message)
FN_GAMCOMMANDS_TXT = _getConst('FN_GAMCOMMANDS_TXT')
GAM_WIKI = _getConst('GAM_WIKI')
writeStderr(Msg.HELP_SYNTAX.format(os.path.join(GM.Globals[GM.GAM_PATH], FN_GAMCOMMANDS_TXT)))
writeStderr(Msg.HELP_WIKI.format(GAM_WIKI))
sys.exit(USAGE_ERROR_RC)
def csvFieldErrorExit(fieldName, fieldNames, backupArg=False, checkForCharset=False):
if backupArg:
Cmd.Backup()
if checkForCharset and Cmd.Previous() == 'charset':
Cmd.Backup()
Cmd.Backup()
usageErrorExit(Msg.HEADER_NOT_FOUND_IN_CSV_HEADERS.format(fieldName, ','.join(fieldNames)))
def csvDataAlreadySavedErrorExit():
Cmd.Backup()
usageErrorExit(Msg.CSV_DATA_ALREADY_SAVED)
# The last thing shown is unknown
def unknownArgumentExit():
Cmd.Backup()
usageErrorExit(Cmd.ARGUMENT_ERROR_NAMES[Cmd.ARGUMENT_INVALID][1])
# Argument describes what's expected
def expectedArgumentExit(problem, argument):
usageErrorExit(f'{problem}: {Msg.EXPECTED} <{argument}>')
def blankArgumentExit(argument):
expectedArgumentExit(Cmd.ARGUMENT_ERROR_NAMES[Cmd.ARGUMENT_BLANK][1], f'{Msg.NON_BLANK} {argument}')
def emptyArgumentExit(argument):
expectedArgumentExit(Cmd.ARGUMENT_ERROR_NAMES[Cmd.ARGUMENT_EMPTY][1], f'{Msg.NON_EMPTY} {argument}')
def invalidArgumentExit(argument):
expectedArgumentExit(Cmd.ARGUMENT_ERROR_NAMES[Cmd.ARGUMENT_INVALID][1], argument)
def missingArgumentExit(argument):
expectedArgumentExit(Cmd.ARGUMENT_ERROR_NAMES[Cmd.ARGUMENT_MISSING][1], argument)
def deprecatedArgument(argument):
Cmd.Backup()
writeStderr(Cmd.CommandLineWithBadArgumentMarked(False))
Cmd.Advance()
stderrWarningMsg(f'{Cmd.ARGUMENT_ERROR_NAMES[Cmd.ARGUMENT_DEPRECATED][1]}: {Msg.IGNORED} <{argument}>')
def deprecatedArgumentExit(argument):
usageErrorExit(f'{Cmd.ARGUMENT_ERROR_NAMES[Cmd.ARGUMENT_DEPRECATED][1]}: <{argument}>')
def deprecatedCommandExit():
_ensureConstants()
systemErrorExit(USAGE_ERROR_RC, Msg.SITES_COMMAND_DEPRECATED.format(Cmd.CommandDeprecated()))
# Choices is the valid set of choices that was expected
def formatChoiceList(choices):
choiceList = [c if c else "''" for c in choices]
if len(choiceList) <= 5:
return '|'.join(choiceList)
return '|'.join(sorted(choiceList))
def invalidChoiceExit(choice, choices, backupArg):
if backupArg:
Cmd.Backup()
expectedArgumentExit(Cmd.ARGUMENT_ERROR_NAMES[Cmd.ARGUMENT_INVALID_CHOICE][1].format(choice), formatChoiceList(choices))
def missingChoiceExit(choices):
expectedArgumentExit(Cmd.ARGUMENT_ERROR_NAMES[Cmd.ARGUMENT_MISSING][1], formatChoiceList(choices))

289
src/gam/util/fileio.py Normal file
View File

@@ -0,0 +1,289 @@
"""GAM file I/O helpers — open/read/write/delete files, file path utilities.
Pure file operations that depend only on gamlib modules and util.output.
"""
import codecs
import io
import logging
import os
import sys
import time
from logging.handlers import RotatingFileHandler
from pathvalidate import sanitize_filename, sanitize_filepath
from gamlib import glcfg as GC
from gamlib import glentity
from gamlib import glglobals as GM
from gamlib import glmsgs as Msg
class _InstanceProxy:
"""Lazy proxy that delegates attribute access to a named instance in the gam module."""
def __init__(self, name):
self._name = name
def __getattr__(self, attr):
return getattr(getattr(sys.modules['gam'], self._name), attr)
Act = _InstanceProxy('Act')
Ind = _InstanceProxy('Ind')
from util.output import (
stderrErrorMsg,
stderrWarningMsg,
setSysExitRC,
systemErrorExit,
formatKeyValueList,
currentCountNL,
writeStderr,
flushStderr,
)
# Constants duplicated from __init__.py to avoid circular imports.
UTF8_SIG = 'utf-8-sig'
DEFAULT_FILE_READ_MODE = 'r'
DEFAULT_FILE_WRITE_MODE = 'w'
UNKNOWN = 'Unknown'
FILE_ERROR_RC = 6
ACTION_FAILED_RC = 50
CONFIG_ERROR_RC = 13
WARNING = 'WARNING'
WARNING_PREFIX = WARNING + ': '
# Ent is an instance of GamEntity created in __init__.py.
# For the default parameter value of fileErrorMessage, we use the
# class-level constant directly.
_ENT_FILE = glentity.GamEntity.FILE
def _getEnt():
"""Get the Ent instance from the main module (lazy accessor)."""
return sys.modules['gam'].Ent
def cleanFilename(filename):
return sanitize_filename(filename, '_')
def setFilePath(filename, cfgDir):
if filename.startswith('./') or filename.startswith('.\\'):
return os.path.join(os.getcwd(), filename[2:])
if filename == '-':
return filename
filename = os.path.expanduser(filename)
if os.path.isabs(filename):
return filename
return os.path.join(GC.Values[cfgDir], filename)
def uniqueFilename(targetFolder, filetitle, overwrite, extension=None):
filename = filetitle
y = 0
while True:
if extension is not None and filename.lower()[-len(extension):] != extension.lower():
filename += extension
filepath = os.path.join(targetFolder, filename)
if overwrite or not os.path.isfile(filepath):
return (filepath, filename)
y += 1
filename = f'({y})-{filetitle}'
def cleanFilepath(filepath):
return sanitize_filepath(filepath, platform='auto')
def fileErrorMessage(filename, e, entityType=_ENT_FILE):
Ent = _getEnt()
return f'{Ent.Singular(entityType)}: {filename}, {str(e)}'
def fdErrorMessage(f, defaultFilename, e):
return fileErrorMessage(getattr(f, 'name') if hasattr(f, 'name') else defaultFilename, e)
# Set file encoding to handle UTF8 BOM
def setEncoding(mode, encoding):
if 'b' in mode:
return {}
if not encoding:
encoding = GM.Globals[GM.SYS_ENCODING]
if 'r' in mode and encoding.lower().replace('-', '') == 'utf8':
encoding = UTF8_SIG
return {'encoding': encoding}
def StringIOobject(initbuff=None):
if initbuff is None:
return io.StringIO()
return io.StringIO(initbuff)
# Open a file
def openFile(filename, mode=DEFAULT_FILE_READ_MODE, encoding=None, errors=None, newline=None,
continueOnError=False, displayError=True, stripUTFBOM=False):
try:
if filename != '-':
kwargs = setEncoding(mode, encoding)
f = open(os.path.expanduser(filename), mode, errors=errors, newline=newline, **kwargs)
if stripUTFBOM:
if 'b' in mode:
if f.read(3) != b'\xef\xbb\xbf':
f.seek(0)
elif not kwargs['encoding'].lower().startswith('utf'):
if f.read(3).encode('iso-8859-1', 'replace') != codecs.BOM_UTF8:
f.seek(0)
else:
if f.read(1) != '\ufeff':
f.seek(0)
return f
if 'r' in mode:
return StringIOobject(str(sys.stdin.read()))
if 'b' not in mode:
return sys.stdout
return os.fdopen(os.dup(sys.stdout.fileno()), 'wb')
except (IOError, LookupError, UnicodeDecodeError, UnicodeError) as e:
if continueOnError:
if displayError:
stderrWarningMsg(fileErrorMessage(filename, e))
setSysExitRC(FILE_ERROR_RC)
return None
systemErrorExit(FILE_ERROR_RC, fileErrorMessage(filename, e))
# Close a file
def closeFile(f, forceFlush=False):
try:
if forceFlush:
# Necessary to make sure file is flushed by both Python and OS
# https://stackoverflow.com/a/13762137/1503886
f.flush()
os.fsync(f.fileno())
f.close()
return True
except IOError as e:
stderrErrorMsg(fdErrorMessage(f, UNKNOWN, e))
setSysExitRC(FILE_ERROR_RC)
return False
# Read a file
def readFile(filename, mode=DEFAULT_FILE_READ_MODE, encoding=None, newline=None,
continueOnError=False, displayError=True):
try:
if filename != '-':
kwargs = setEncoding(mode, encoding)
with open(os.path.expanduser(filename), mode, newline=newline, **kwargs) as f:
return f.read()
return str(sys.stdin.read())
except (IOError, LookupError, UnicodeDecodeError, UnicodeError) as e:
if continueOnError:
if displayError:
stderrWarningMsg(fileErrorMessage(filename, e))
setSysExitRC(FILE_ERROR_RC)
return None
systemErrorExit(FILE_ERROR_RC, fileErrorMessage(filename, e))
# Write a file
def writeFile(filename, data, mode=DEFAULT_FILE_WRITE_MODE,
continueOnError=False, displayError=True):
try:
if filename != '-':
kwargs = setEncoding(mode, None)
with open(os.path.expanduser(filename), mode, **kwargs) as f:
f.write(data)
return True
GM.Globals[GM.STDOUT].get(GM.REDIRECT_MULTI_FD, sys.stdout).write(data)
return True
except (IOError, LookupError, UnicodeDecodeError, UnicodeError) as e:
if continueOnError:
if displayError:
stderrErrorMsg(fileErrorMessage(filename, e))
setSysExitRC(FILE_ERROR_RC)
return False
systemErrorExit(FILE_ERROR_RC, fileErrorMessage(filename, e))
# Write a file, return error
def writeFileReturnError(filename, data, mode=DEFAULT_FILE_WRITE_MODE):
try:
kwargs = {'encoding': GM.Globals[GM.SYS_ENCODING]} if 'b' not in mode else {}
with open(os.path.expanduser(filename), mode, **kwargs) as f:
f.write(data)
return (True, None)
except (IOError, LookupError, UnicodeDecodeError, UnicodeError) as e:
return (False, e)
# Delete a file
def deleteFile(filename, continueOnError=False, displayError=True):
if os.path.isfile(filename):
try:
os.remove(filename)
except OSError as e:
if continueOnError:
if displayError:
stderrWarningMsg(fileErrorMessage(filename, e))
return
systemErrorExit(FILE_ERROR_RC, fileErrorMessage(filename, e))
def getGDocSheetDataRetryWarning(entityValueList, errMsg, i=0, count=0):
Ent = _getEnt()
action = Act.Get()
Act.Set(Act.RETRIEVE_DATA)
stderrWarningMsg(formatKeyValueList(Ind.Spaces(),
Ent.FormatEntityValueList(entityValueList)+[Act.NotPerformed(), errMsg, 'Retry', ''],
currentCountNL(i, count)))
Act.Set(action)
def getGDocSheetDataFailedExit(entityValueList, errMsg, i=0, count=0):
Ent = _getEnt()
Act.Set(Act.RETRIEVE_DATA)
systemErrorExit(ACTION_FAILED_RC, formatKeyValueList(Ind.Spaces(),
Ent.FormatEntityValueList(entityValueList)+[Act.NotPerformed(), errMsg],
currentCountNL(i, count)))
def incrAPICallsRetryData(errMsg, delta):
GM.Globals[GM.API_CALLS_RETRY_DATA].setdefault(errMsg, [0, 0.0])
GM.Globals[GM.API_CALLS_RETRY_DATA][errMsg][0] += 1
GM.Globals[GM.API_CALLS_RETRY_DATA][errMsg][1] += delta
def initAPICallsRateCheck():
GM.Globals[GM.RATE_CHECK_COUNT] = 0
GM.Globals[GM.RATE_CHECK_START] = time.time()
def checkAPICallsRate():
GM.Globals[GM.RATE_CHECK_COUNT] += 1
if GM.Globals[GM.RATE_CHECK_COUNT] >= GC.Values[GC.API_CALLS_RATE_LIMIT]:
current = time.time()
delta = int(current-GM.Globals[GM.RATE_CHECK_START])
if 0 <= delta < 60:
delta = (60-delta)+3
error_message = f'API calls per 60 seconds limit {GC.Values[GC.API_CALLS_RATE_LIMIT]} exceeded'
writeStderr(f'{WARNING_PREFIX}{error_message}: Backing off: {delta} seconds\n')
flushStderr()
time.sleep(delta)
if GC.Values[GC.SHOW_API_CALLS_RETRY_DATA]:
incrAPICallsRetryData(error_message, delta)
GM.Globals[GM.RATE_CHECK_START] = time.time()
else:
GM.Globals[GM.RATE_CHECK_START] = current
GM.Globals[GM.RATE_CHECK_COUNT] = 0
def openGAMCommandLog(Globals, name):
try:
Globals[GM.CMDLOG_LOGGER] = logging.getLogger(name)
Globals[GM.CMDLOG_LOGGER].setLevel(logging.INFO)
Globals[GM.CMDLOG_HANDLER] = RotatingFileHandler(GC.Values[GC.CMDLOG],
maxBytes=1024*GC.Values[GC.CMDLOG_MAX_KILO_BYTES],
backupCount=GC.Values[GC.CMDLOG_MAX_BACKUPS],
encoding=GC.Values[GC.CHARSET])
Globals[GM.CMDLOG_LOGGER].addHandler(Globals[GM.CMDLOG_HANDLER])
except Exception as e:
Globals[GM.CMDLOG_LOGGER] = None
systemErrorExit(CONFIG_ERROR_RC, Msg.LOGGING_INITIALIZATION_ERROR.format(str(e)))
def writeGAMCommandLog(Globals, logCmd, sysRC):
import sys as _sys # noqa: PLC0415
gam = _sys.modules['gam']
Globals[GM.CMDLOG_LOGGER].info(f'{gam.currentISOformatTimeStamp()},{sysRC},{logCmd}')
def closeGAMCommandLog(Globals):
try:
Globals[GM.CMDLOG_HANDLER].flush()
Globals[GM.CMDLOG_HANDLER].close()
Globals[GM.CMDLOG_LOGGER].removeHandler(Globals[GM.CMDLOG_HANDLER])
except Exception:
pass
Globals[GM.CMDLOG_LOGGER] = None

275
src/gam/util/gdoc.py Normal file
View File

@@ -0,0 +1,275 @@
"""GAM GDoc/GSheet/Cloud Storage data retrieval utilities.
Extracted from gam/__init__.py. Provides functions for reading data from
Google Docs, Google Sheets, Cloud Storage buckets, and CSV files.
"""
import csv
import re
import sys
import time
from tempfile import TemporaryFile
from urllib.parse import unquote
import googleapiclient.http
import httplib2
import google.auth.exceptions
from gamlib import glapi as API
from gamlib import glcfg as GC
from gamlib import glentity as Ent
from gamlib import glgapi as GAPI
from gamlib import glglobals as GM
from gamlib import glmsgs as Msg
def _getMain():
return sys.modules['gam']
GDOC_FORMAT_MIME_TYPES = {
'gcsv': 'text/csv',
'gdoc': 'text/plain',
'ghtml': 'text/html',
}
# gdoc <EmailAddress> <DriveFileIDEntity>|<DriveFileNameEntity>
def getGDocData(gformat):
mimeType = GDOC_FORMAT_MIME_TYPES[gformat]
user = _getMain().getEmailAddress()
fileIdEntity = _getMain().getDriveFileEntity(queryShortcutsOK=False)
if not GC.Values[GC.COMMANDDATA_CLIENTACCESS]:
_, drive = _getMain().buildGAPIServiceObject(API.DRIVE3, user)
else:
drive = _getMain().buildGAPIObject(API.DRIVE3)
if not drive:
sys.exit(GM.Globals[GM.SYSEXITRC])
_, _, jcount = _getMain()._validateUserGetFileIDs(user, 0, 0, fileIdEntity, drive=drive)
if jcount == 0:
_getMain().getGDocSheetDataFailedExit([Ent.USER, user], Msg.NO_ENTITIES_FOUND.format(Ent.Singular(Ent.DRIVE_FILE)))
if jcount > 1:
_getMain().getGDocSheetDataFailedExit([Ent.USER, user], Msg.MULTIPLE_ENTITIES_FOUND.format(Ent.Plural(Ent.DRIVE_FILE), jcount, ','.join(fileIdEntity['list'])))
fileId = fileIdEntity['list'][0]
f = None
try:
result = _getMain().callGAPI(drive.files(), 'get',
throwReasons=GAPI.DRIVE_GET_THROW_REASONS,
fileId=fileId, fields='name,mimeType,exportLinks',
supportsAllDrives=True)
# Google Doc
if 'exportLinks' in result:
if mimeType not in result['exportLinks']:
_getMain().getGDocSheetDataFailedExit([Ent.USER, user, Ent.DRIVE_FILE, result['name']],
Msg.INVALID_MIMETYPE.format(result['mimeType'], mimeType))
f = TemporaryFile(mode='w+', encoding=_getMain().UTF8)
_, content = drive._http.request(uri=result['exportLinks'][mimeType], method='GET')
f.write(content.decode(_getMain().UTF8_SIG))
f.seek(0)
return f
# Drive File
if result['mimeType'] != mimeType:
_getMain().getGDocSheetDataFailedExit([Ent.USER, user, Ent.DRIVE_FILE, result['name']],
Msg.INVALID_MIMETYPE.format(result['mimeType'], mimeType))
fb = TemporaryFile(mode='wb+')
request = drive.files().get_media(fileId=fileId)
downloader = googleapiclient.http.MediaIoBaseDownload(fb, request)
done = False
while not done:
_, done = downloader.next_chunk()
f = TemporaryFile(mode='w+', encoding=_getMain().UTF8)
fb.seek(0)
f.write(fb.read().decode(_getMain().UTF8_SIG))
fb.close()
f.seek(0)
return f
except GAPI.fileNotFound:
_getMain().getGDocSheetDataFailedExit([Ent.USER, user, Ent.DOCUMENT, fileId], Msg.DOES_NOT_EXIST)
except (IOError, httplib2.HttpLib2Error, google.auth.exceptions.TransportError, RuntimeError) as e:
if f:
f.close()
_getMain().getGDocSheetDataFailedExit([Ent.USER, user, Ent.DOCUMENT, fileId], str(e))
except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy) as e:
_getMain().userDriveServiceNotEnabledWarning(user, str(e))
sys.exit(GM.Globals[GM.SYSEXITRC])
HTML_TITLE_PATTERN = re.compile(r'.*<title>(.+)</title>')
# gsheet <EmailAddress> <DriveFileIDEntity>|<DriveFileNameEntity> <SheetEntity>
def getGSheetData():
user = _getMain().getEmailAddress()
fileIdEntity = _getMain().getDriveFileEntity(queryShortcutsOK=False)
sheetEntity = _getMain().getSheetEntity(False)
if not GC.Values[GC.COMMANDDATA_CLIENTACCESS]:
user, drive = _getMain().buildGAPIServiceObject(API.DRIVE3, user)
else:
drive = _getMain().buildGAPIObject(API.DRIVE3)
if not drive:
sys.exit(GM.Globals[GM.SYSEXITRC])
_, _, jcount = _getMain()._validateUserGetFileIDs(user, 0, 0, fileIdEntity, drive=drive)
if jcount == 0:
_getMain().getGDocSheetDataFailedExit([Ent.USER, user], Msg.NO_ENTITIES_FOUND.format(Ent.Singular(Ent.DRIVE_FILE)))
if jcount > 1:
_getMain().getGDocSheetDataFailedExit([Ent.USER, user], Msg.MULTIPLE_ENTITIES_FOUND.format(Ent.Plural(Ent.DRIVE_FILE), jcount, ','.join(fileIdEntity['list'])))
if not GC.Values[GC.COMMANDDATA_CLIENTACCESS]:
_, sheet = _getMain().buildGAPIServiceObject(API.SHEETS, user)
else:
sheet = _getMain().buildGAPIObject(API.SHEETS)
if not sheet:
sys.exit(GM.Globals[GM.SYSEXITRC])
fileId = fileIdEntity['list'][0]
f = None
try:
result = _getMain().callGAPI(drive.files(), 'get',
throwReasons=GAPI.DRIVE_GET_THROW_REASONS,
fileId=fileId, fields='name,mimeType', supportsAllDrives=True)
if result['mimeType'] != _getMain().MIMETYPE_GA_SPREADSHEET:
_getMain().getGDocSheetDataFailedExit([Ent.USER, user, Ent.DRIVE_FILE, result['name']],
Msg.INVALID_MIMETYPE.format(result['mimeType'], _getMain().MIMETYPE_GA_SPREADSHEET))
spreadsheet = _getMain().callGAPI(sheet.spreadsheets(), 'get',
throwReasons=GAPI.SHEETS_ACCESS_THROW_REASONS,
spreadsheetId=fileId, fields='spreadsheetUrl,sheets(properties(sheetId,title))')
sheetId = _getMain().getSheetIdFromSheetEntity(spreadsheet, sheetEntity)
if sheetId is None:
_getMain().getGDocSheetDataFailedExit([Ent.USER, user, Ent.SPREADSHEET, result['name'], sheetEntity['sheetType'], sheetEntity['sheetValue']], Msg.NOT_FOUND)
spreadsheetUrl = f'{re.sub("/edit.*$", "/export", spreadsheet["spreadsheetUrl"])}?format=csv&id={fileId}&gid={sheetId}'
f = TemporaryFile(mode='w+', encoding=_getMain().UTF8)
if GC.Values[GC.DEBUG_LEVEL] > 0:
sys.stderr.write(f'Debug: spreadsheetUrl: {spreadsheetUrl}\n')
triesLimit = 3
for n in range(1, triesLimit+1):
_, content = drive._http.request(uri=spreadsheetUrl, method='GET')
# Check for HTML error message instead of data
if content[0:15] != b'<!DOCTYPE html>':
break
tg = HTML_TITLE_PATTERN.match(content[0:600].decode('utf-8'))
errMsg = tg.group(1) if tg else 'Unknown error'
_getMain().getGDocSheetDataRetryWarning([Ent.USER, user, Ent.SPREADSHEET, result['name'], sheetEntity['sheetType'], sheetEntity['sheetValue']], errMsg, n, triesLimit)
time.sleep(20)
else:
_getMain().getGDocSheetDataFailedExit([Ent.USER, user, Ent.SPREADSHEET, result['name'], sheetEntity['sheetType'], sheetEntity['sheetValue']], errMsg)
f.write(content.decode(_getMain().UTF8_SIG))
f.seek(0)
return f
except GAPI.fileNotFound:
_getMain().getGDocSheetDataFailedExit([Ent.USER, user, Ent.SPREADSHEET, fileId], Msg.DOES_NOT_EXIST)
except (GAPI.notFound, GAPI.forbidden, GAPI.permissionDenied,
GAPI.internalError, GAPI.insufficientFilePermissions, GAPI.badRequest,
GAPI.invalid, GAPI.invalidArgument, GAPI.failedPrecondition) as e:
_getMain().getGDocSheetDataFailedExit([Ent.USER, user, Ent.SPREADSHEET, fileId, sheetEntity['sheetType'], sheetEntity['sheetValue']], str(e))
except (IOError, httplib2.HttpLib2Error) as e:
if f:
f.close()
_getMain().getGDocSheetDataFailedExit([Ent.USER, user, Ent.SPREADSHEET, fileId, sheetEntity['sheetType'], sheetEntity['sheetValue']], str(e))
except (GAPI.serviceNotAvailable, GAPI.authError, GAPI.domainPolicy) as e:
_getMain().userDriveServiceNotEnabledWarning(user, str(e))
sys.exit(GM.Globals[GM.SYSEXITRC])
BUCKET_OBJECT_PATTERNS = [
{'pattern': re.compile(r'https://storage.(?:googleapis|cloud.google).com/(.+?)/(.+)'), 'unquote': True},
{'pattern': re.compile(r'gs://(.+?)/(.+)'), 'unquote': False},
{'pattern': re.compile(r'(.+?)/(.+)'), 'unquote': False},
]
def getBucketObjectName():
Cmd = _getMain().Cmd
uri = _getMain().getString(Cmd.OB_STRING)
for pattern in BUCKET_OBJECT_PATTERNS:
mg = re.search(pattern['pattern'], uri)
if mg:
bucket = mg.group(1)
s_object = mg.group(2) if not pattern['unquote'] else unquote(mg.group(2))
return (bucket, s_object, f'{bucket}/{s_object}')
_getMain().systemErrorExit(_getMain().ACTION_NOT_PERFORMED_RC, f'Invalid <StorageBucketObjectName>: {uri}')
GCS_FORMAT_MIME_TYPES = {
'gcscsv': 'text/csv',
'gcsdoc': 'text/plain',
'gcshtml': 'text/html',
}
# gcscsv|gcshtml|gcsdoc <StorageBucketObjectName>
def getStorageFileData(gcsformat, returnData=True):
mimeType = GCS_FORMAT_MIME_TYPES[gcsformat]
bucket, s_object, bucketObject = getBucketObjectName()
s = _getMain().buildGAPIObject(API.STORAGEREAD)
try:
result = _getMain().callGAPI(s.objects(), 'get',
throwReasons=[GAPI.NOT_FOUND, GAPI.FORBIDDEN],
bucket=bucket, object=s_object, projection='noAcl', fields='contentType')
except GAPI.notFound:
_getMain().entityDoesNotExistExit(Ent.CLOUD_STORAGE_FILE, bucketObject)
except GAPI.forbidden as e:
_getMain().entityActionFailedExit([Ent.CLOUD_STORAGE_FILE, bucketObject], str(e))
if result['contentType'] != mimeType:
_getMain().getGDocSheetDataFailedExit([Ent.CLOUD_STORAGE_FILE, bucketObject],
Msg.INVALID_MIMETYPE.format(result['contentType'], mimeType))
fb = TemporaryFile(mode='wb+')
try:
request = s.objects().get_media(bucket=bucket, object=s_object)
downloader = googleapiclient.http.MediaIoBaseDownload(fb, request)
done = False
while not done:
_, done = downloader.next_chunk()
fb.seek(0)
if returnData:
data = fb.read().decode(_getMain().UTF8)
fb.close()
return data
f = TemporaryFile(mode='w+', encoding=_getMain().UTF8)
f.write(fb.read().decode(_getMain().UTF8_SIG))
fb.close()
f.seek(0)
return f
except googleapiclient.http.HttpError as e:
mg = _getMain().HTTP_ERROR_PATTERN.match(str(e))
_getMain().getGDocSheetDataFailedExit([Ent.CLOUD_STORAGE_FILE, bucketObject], mg.group(1) if mg else str(e))
# <CSVFileInput>
def openCSVFileReader(filename, fieldnames=None):
Cmd = _getMain().Cmd
filenameLower = filename.lower()
if filenameLower == 'gsheet':
f = getGSheetData()
_getMain().getCharSet()
elif filenameLower in {'gcsv', 'gdoc'}:
f = getGDocData(filenameLower)
_getMain().getCharSet()
elif filenameLower in {'gcscsv', 'gcsdoc'}:
f = getStorageFileData(filenameLower, False)
_getMain().getCharSet()
else:
encoding = _getMain().getCharSet()
filename = _getMain().setFilePath(filename, GC.INPUT_DIR)
f = _getMain().openFile(filename, mode=_getMain().DEFAULT_CSV_READ_MODE, encoding=encoding)
if _getMain().checkArgumentPresent('warnifnodata'):
loc = f.tell()
try:
if not f.readline() or not f.readline():
_getMain().stderrWarningMsg(_getMain().fileErrorMessage(filename, Msg.NO_CSV_FILE_DATA_FOUND))
sys.exit(_getMain().NO_ENTITIES_FOUND_RC)
f.seek(loc)
except (IOError, UnicodeDecodeError, UnicodeError) as e:
_getMain().systemErrorExit(_getMain().FILE_ERROR_RC, _getMain().fileErrorMessage(filename, e))
if _getMain().checkArgumentPresent('columndelimiter'):
columnDelimiter = _getMain().getCharacter()
else:
columnDelimiter = GC.Values[GC.CSV_INPUT_COLUMN_DELIMITER]
if _getMain().checkArgumentPresent('noescapechar'):
noEscapeChar = _getMain().getBoolean()
else:
noEscapeChar = GC.Values[GC.CSV_INPUT_NO_ESCAPE_CHAR]
if _getMain().checkArgumentPresent('quotechar'):
quotechar = _getMain().getCharacter()
else:
quotechar = GC.Values[GC.CSV_INPUT_QUOTE_CHAR]
if not _getMain().checkArgumentPresent('endcsv') and _getMain().checkArgumentPresent('fields'):
fieldnames = _getMain().shlexSplitList(_getMain().getString(Cmd.OB_FIELD_NAME_LIST))
try:
csvFile = csv.DictReader(f, fieldnames=fieldnames,
delimiter=columnDelimiter,
escapechar='\\' if not noEscapeChar else None,
quotechar=quotechar)
return (f, csvFile, csvFile.fieldnames if csvFile.fieldnames is not None else [])
except (csv.Error, UnicodeDecodeError, UnicodeError) as e:
_getMain().systemErrorExit(_getMain().FILE_ERROR_RC, e)

56
src/gam/util/html.py Normal file
View File

@@ -0,0 +1,56 @@
"""GAM HTML utilities.
Extracted from gam/__init__.py. Provides HTML-to-plain-text conversion.
"""
import re
from html.entities import name2codepoint
from html.parser import HTMLParser
class _DeHTMLParser(HTMLParser): #pylint: disable=abstract-method
def __init__(self):
HTMLParser.__init__(self)
self.__text = []
def handle_data(self, data):
self.__text.append(data)
def handle_charref(self, name):
self.__text.append(chr(int(name[1:], 16)) if name.startswith('x') else chr(int(name)))
def handle_entityref(self, name):
cp = name2codepoint.get(name)
if cp:
self.__text.append(chr(cp))
else:
self.__text.append('&'+name)
def handle_starttag(self, tag, attrs):
if tag == 'p':
self.__text.append('\n\n')
elif tag == 'br':
self.__text.append('\n')
elif tag == 'a':
for attr in attrs:
if attr[0] == 'href':
self.__text.append(f'({attr[1]}) ')
break
elif tag == 'div':
if not attrs:
self.__text.append('\n')
elif tag in {'http:', 'https'}:
self.__text.append(f' ({tag}//{attrs[0][0]}) ')
def handle_startendtag(self, tag, attrs):
if tag == 'br':
self.__text.append('\n\n')
def text(self):
return re.sub(r'\n{2}\n+', '\n\n', re.sub(r'\n +', '\n', ''.join(self.__text))).strip()
def dehtml(text):
parser = _DeHTMLParser()
parser.feed(str(text))
parser.close()
return parser.text()

133
src/gam/util/orgunits.py Normal file
View File

@@ -0,0 +1,133 @@
"""GAM OrgUnit helper utilities.
Extracted from gam/__init__.py. Provides OrgUnit path/ID resolution
and parent OrgUnit traversal.
"""
import sys
from gamlib import glapi as API
from gamlib import glcfg as GC
from gamlib import glgapi as GAPI
from gamlib import glglobals as GM
def _getMain():
return sys.modules['gam']
def getOrgUnitItem(pathOnly=False, absolutePath=True, cd=None):
Cmd = _getMain().Cmd
if Cmd.ArgumentsRemaining():
path = Cmd.Current().strip()
if path == 'root':
path = '/'
if path:
if pathOnly and (path.startswith('id:') or path.startswith('uid:')) and cd is not None:
try:
result = _getMain().callGAPI(cd.orgunits(), 'get',
throwReasons=GAPI.ORGUNIT_GET_THROW_REASONS,
customerId=GC.Values[GC.CUSTOMER_ID], orgUnitPath=path,
fields='orgUnitPath')
Cmd.Advance()
if absolutePath:
return _getMain().makeOrgUnitPathAbsolute(result['orgUnitPath'])
return _getMain().makeOrgUnitPathRelative(result['orgUnitPath'])
except (GAPI.invalidOrgunit, GAPI.orgunitNotFound, GAPI.backendError,
GAPI.badRequest, GAPI.invalidCustomerId, GAPI.loginRequired):
_getMain().checkEntityAFDNEorAccessErrorExit(cd, _getMain().Ent.ORGANIZATIONAL_UNIT, path)
_getMain().invalidArgumentExit(Cmd.OB_ORGUNIT_PATH)
Cmd.Advance()
if absolutePath:
return _getMain().makeOrgUnitPathAbsolute(path)
return _getMain().makeOrgUnitPathRelative(path)
_getMain().missingArgumentExit([Cmd.OB_ORGUNIT_ITEM, Cmd.OB_ORGUNIT_PATH][pathOnly])
def getTopLevelOrgId(cd, parentOrgUnitPath):
Ent = _getMain().Ent
if parentOrgUnitPath != '/':
try:
result = _getMain().callGAPI(cd.orgunits(), 'get',
throwReasons=GAPI.ORGUNIT_GET_THROW_REASONS,
customerId=GC.Values[GC.CUSTOMER_ID], orgUnitPath=_getMain().encodeOrgUnitPath(_getMain().makeOrgUnitPathRelative(parentOrgUnitPath)),
fields='orgUnitId')
return result['orgUnitId']
except (GAPI.invalidOrgunit, GAPI.orgunitNotFound, GAPI.backendError):
return None
except (GAPI.badRequest, GAPI.invalidCustomerId, GAPI.loginRequired):
_getMain().checkEntityAFDNEorAccessErrorExit(cd, Ent.ORGANIZATIONAL_UNIT, parentOrgUnitPath)
return None
try:
result = _getMain().callGAPI(cd.orgunits(), 'list',
throwReasons=GAPI.ORGUNIT_GET_THROW_REASONS,
customerId=GC.Values[GC.CUSTOMER_ID], orgUnitPath='/', type='allIncludingParent',
fields='organizationUnits(orgUnitId,orgUnitPath)')
for orgUnit in result.get('organizationUnits', []):
if orgUnit['orgUnitPath'] == '/':
return orgUnit['orgUnitId']
return None
except (GAPI.invalidOrgunit, GAPI.orgunitNotFound, GAPI.backendError):
return None
except (GAPI.badRequest, GAPI.invalidCustomerId, GAPI.loginRequired):
_getMain().checkEntityAFDNEorAccessErrorExit(cd, Ent.ORGANIZATIONAL_UNIT, parentOrgUnitPath)
return None
def getOrgUnitId(cd=None, orgUnit=None):
Ent = _getMain().Ent
if cd is None:
cd = _getMain().buildGAPIObject(API.DIRECTORY)
if orgUnit is None:
orgUnit = getOrgUnitItem()
try:
if orgUnit == '/':
result = _getMain().callGAPI(cd.orgunits(), 'list',
throwReasons=GAPI.ORGUNIT_GET_THROW_REASONS,
customerId=GC.Values[GC.CUSTOMER_ID], orgUnitPath='/', type='children',
fields='organizationUnits(parentOrgUnitId,parentOrgUnitPath)')
if result.get('organizationUnits', []):
return (result['organizationUnits'][0]['parentOrgUnitPath'], result['organizationUnits'][0]['parentOrgUnitId'])
topLevelOrgId = getTopLevelOrgId(cd, '/')
if topLevelOrgId:
return (orgUnit, topLevelOrgId)
return (orgUnit, '/') #Bogus but should never happen
result = _getMain().callGAPI(cd.orgunits(), 'get',
throwReasons=GAPI.ORGUNIT_GET_THROW_REASONS,
customerId=GC.Values[GC.CUSTOMER_ID], orgUnitPath=_getMain().encodeOrgUnitPath(_getMain().makeOrgUnitPathRelative(orgUnit)),
fields='orgUnitId,orgUnitPath')
return (result['orgUnitPath'], result['orgUnitId'])
except (GAPI.invalidOrgunit, GAPI.orgunitNotFound, GAPI.backendError):
_getMain().entityDoesNotExistExit(Ent.ORGANIZATIONAL_UNIT, orgUnit)
except (GAPI.badRequest, GAPI.invalidCustomerId, GAPI.loginRequired):
_getMain().accessErrorExit(cd)
def getAllParentOrgUnitsForUser(cd, user):
Ent = _getMain().Ent
try:
result = _getMain().callGAPI(cd.users(), 'get',
throwReasons=GAPI.USER_GET_THROW_REASONS,
userKey=user, fields='orgUnitPath', projection='basic')
except (GAPI.userNotFound, GAPI.domainNotFound, GAPI.domainCannotUseApis, GAPI.forbidden):
_getMain().entityDoesNotExistExit(Ent.USER, user)
except (GAPI.badRequest, GAPI.invalidCustomerId, GAPI.loginRequired):
_getMain().accessErrorExit(cd)
parentPath = result['orgUnitPath']
if parentPath == '/':
orgUnitPath, orgUnitId = getOrgUnitId(cd, '/')
return {orgUnitId: orgUnitPath}
parentPath = _getMain().encodeOrgUnitPath(_getMain().makeOrgUnitPathRelative(parentPath))
orgUnits = {}
while True:
try:
result = _getMain().callGAPI(cd.orgunits(), 'get',
throwReasons=GAPI.ORGUNIT_GET_THROW_REASONS,
customerId=GC.Values[GC.CUSTOMER_ID], orgUnitPath=parentPath,
fields='orgUnitId,orgUnitPath,parentOrgUnitId')
orgUnits[result['orgUnitId']] = result['orgUnitPath']
if 'parentOrgUnitId' not in result:
break
parentPath = result['parentOrgUnitId']
except (GAPI.invalidOrgunit, GAPI.orgunitNotFound, GAPI.backendError):
_getMain().entityDoesNotExistExit(Ent.ORGANIZATIONAL_UNIT, parentPath)
except (GAPI.badRequest, GAPI.invalidCustomerId, GAPI.loginRequired):
_getMain().accessErrorExit(cd)
return orgUnits

184
src/gam/util/output.py Normal file
View File

@@ -0,0 +1,184 @@
"""GAM output helpers — write/flush/print/format functions.
These are pure output functions that write to stdout/stderr via GM.Globals.
No circular dependency risk — they only depend on gamlib modules and
simple string constants.
"""
import sys
import time
from gamlib import glcfg as GC
from gamlib import glglobals as GM
class _InstanceProxy:
"""Lazy proxy that delegates attribute access to a named instance in the gam module."""
def __init__(self, name):
self._name = name
def __getattr__(self, attr):
return getattr(getattr(sys.modules['gam'], self._name), attr)
Ind = _InstanceProxy('Ind')
# These constants are duplicated from __init__.py to avoid circular imports.
# They are simple string literals that never change.
ERROR = 'ERROR'
ERROR_PREFIX = ERROR + ': '
WARNING = 'WARNING'
WARNING_PREFIX = WARNING + ': '
STDOUT_STDERR_ERROR_RC = 66
# stdin/stdout/stderr
def readStdin(prompt):
return input(prompt)
def stdErrorExit(e):
try:
sys.stderr.write(f'\n{ERROR_PREFIX}{str(e)}\n')
except IOError:
pass
sys.exit(STDOUT_STDERR_ERROR_RC)
def writeStdout(data):
try:
GM.Globals[GM.STDOUT].get(GM.REDIRECT_MULTI_FD, sys.stdout).write(data)
except IOError as e:
stdErrorExit(e)
def flushStdout():
try:
GM.Globals[GM.STDOUT].get(GM.REDIRECT_MULTI_FD, sys.stdout).flush()
except IOError as e:
stdErrorExit(e)
def writeStderr(data):
flushStdout()
try:
GM.Globals[GM.STDERR].get(GM.REDIRECT_MULTI_FD, sys.stderr).write(data)
except IOError as e:
stdErrorExit(e)
def flushStderr():
try:
GM.Globals[GM.STDERR].get(GM.REDIRECT_MULTI_FD, sys.stderr).flush()
except IOError as e:
stdErrorExit(e)
# Error messages
def setSysExitRC(sysRC):
GM.Globals[GM.SYSEXITRC] = sysRC
def stderrErrorMsg(message):
writeStderr(f'\n{ERROR_PREFIX}{message}\n')
def stderrWarningMsg(message):
writeStderr(f'\n{WARNING_PREFIX}{message}\n')
def systemErrorExit(sysRC, message):
if message:
stderrErrorMsg(message)
sys.exit(sysRC)
def printErrorMessage(sysRC, message):
setSysExitRC(sysRC)
writeStderr(f'\n{Ind.Spaces()}{ERROR_PREFIX}{message}\n')
def printWarningMessage(sysRC, message):
setSysExitRC(sysRC)
writeStderr(f'\n{Ind.Spaces()}{WARNING_PREFIX}{message}\n')
def supportsColoredText():
"""Determines if the current terminal environment supports colored text.
Returns:
Bool, True if the current terminal environment supports colored text via
ANSI escape characters.
"""
# Make a rudimentary check for Windows. Though Windows does seem to support
# colorization with VT100 emulation, it is disabled by default. Therefore,
# we'll simply disable it in GAM on Windows for now.
return not sys.platform.startswith('win')
def createColoredText(text, color):
"""Uses ANSI escape characters to create colored text in supported terminals.
See more at https://en.wikipedia.org/wiki/ANSI_escape_code#Colors
Args:
text: String, The text to colorize using ANSI escape characters.
color: String, An ANSI escape sequence denoting the color of the text to be
created. See more at https://en.wikipedia.org/wiki/ANSI_escape_code#Colors
Returns:
The input text with appropriate ANSI escape characters to create
colorization in a supported terminal environment.
"""
END_COLOR_SEQUENCE = '\033[0m' # Ends the applied color formatting
if supportsColoredText():
return color + text + END_COLOR_SEQUENCE
return text # Hand back the plain text, uncolorized.
def createRedText(text):
"""Uses ANSI encoding to create red colored text if supported."""
return createColoredText(text, '\033[91m')
def createGreenText(text):
"""Uses ANSI encoding to create green colored text if supported."""
return createColoredText(text, '\u001b[32m')
def createYellowText(text):
"""Uses ANSI encoding to create yellow text if supported."""
return createColoredText(text, '\u001b[33m')
def executeBatch(dbatch):
dbatch.execute()
if GC.Values[GC.INTER_BATCH_WAIT] > 0:
time.sleep(GC.Values[GC.INTER_BATCH_WAIT])
def _stripControlCharsFromName(name):
for cc in ['\x00', '\r', '\n']:
name = name.replace(cc, '')
return name
def currentCount(i, count):
return f' ({i}/{count})' if (count > GC.Values[GC.SHOW_COUNTS_MIN]) else ''
def currentCountNL(i, count):
return f' ({i}/{count})\n' if (count > GC.Values[GC.SHOW_COUNTS_MIN]) else '\n'
# Format a key value list
# key, value -> "key: value" + ", " if not last item
# key, '' -> "key:" + ", " if not last item
# key, None -> "key" + " " if not last item
def formatKeyValueList(prefixStr, kvList, suffixStr):
msg = prefixStr
i = 0
l = len(kvList)
while i < l:
if isinstance(kvList[i], (bool, float, int)):
msg += str(kvList[i])
else:
msg += kvList[i]
i += 1
if i < l:
val = kvList[i]
if (val is not None) or (i == l-1):
msg += ':'
if (val is not None) and (not isinstance(val, str) or val):
msg += ' '
if isinstance(val, (bool, float, int)):
msg += str(val)
else:
msg += val
i += 1
if i < l:
msg += ', '
else:
i += 1
if i < l:
msg += ' '
msg += suffixStr
return msg