Compare commits

..

15 commits

58 changed files with 149 additions and 1840 deletions

4
.gitignore vendored
View file

@ -2,18 +2,14 @@
*.pyo
*.egg-info
*.project
*.log
*.swp
*.pydevproject
*.sublime-project
*.sublime-workspace
*.ropeproject
uv.lock
.env
.private*
.pytest.ini
*#*#
*.#*
__pycache__
log
var

View file

@ -1 +0,0 @@
# py-scopes: apidemo

View file

@ -1,61 +0,0 @@
# py-scopes/apidemo/config.py
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file='.env', extra='ignore')
#, env_prefix='SCOPES_')
base_url: str = 'https://test.cy7.eu'
log_file: str = 'log/scopes.log'
log_level: str = 'INFO'
log_format: str = '%(asctime)s %(levelname)s %(name)s %(message)s'
log_dateformat: str = '%Y-%m-%dT%H:%M:%S'
dbengine: str = 'postgresql+psycopg'
dbname: str = 'demo'
dbuser: str = 'demo'
dbpassword: str = 'secret'
dbschema: str = 'demo'
settings = Settings()
from dotenv import load_dotenv
import logging
from os import getenv
load_dotenv()
base_url = settings.base_url
logging.basicConfig(filename=settings.log_file, level=settings.log_level,
format=settings.log_format, datefmt=settings.log_dateformat)
logging.getLogger('uvicorn.error').propagate = True
# storage settings
from scopes.storage.db.postgres import StorageFactory
dbengine = settings.dbengine
dbname = settings.dbname
dbuser = settings.dbuser
dbpassword = settings.dbpassword
dbschema = settings.dbschema
# authentication settings
oidc_provider = getenv('OIDC_PROVIDER', 'https://a1.cy7.de')
oidc_client_id = getenv('OIDC_CLIENT_ID', '12345')
oidc_params = dict(
op_config_url=oidc_provider + '/.well-known/openid-configuration',
op_uris=None,
op_keys=None,
op_project_scope='urn:zitadel:iam:org:project:id:zitadel:aud',
callback_url=getenv('OIDC_CALLBACK_URL', base_url + '/auth/callback'),
client_id = oidc_client_id,
cookie_name=getenv('OIDC_COOKIE_NAME', 'oidc_' + oidc_client_id),
cookie_domain=getenv('OIDC_COOKIE_DOMAIN', None),
cookie_lifetime=getenv('OIDC_COOKIE_LIFETIME', '86400'),
cookie_crypt=getenv('OIDC_COOKIE_CRYPT', None),
private_key_file=getenv('OIDC_SERVICE_USER_PRIVATE_KEY_FILE', '.private-key.json'),
organization_id=getenv('OIDC_ORGANIZATION_ID', '12346'),
project_id=getenv('OIDC_PROJECT_ID', None),
)

View file

@ -1,2 +0,0 @@
# py-scopes: demo

View file

@ -1,48 +0,0 @@
# py-scopes/demo/config.py
from dotenv import load_dotenv
import logging
from os import getenv
from scopes.web.app import zope_app_factory
load_dotenv()
log_file = 'log/scopes.log'
log_level = logging.DEBUG
log_format = '%(asctime)s %(levelname)s %(name)s %(message)s'
log_dateformat = '%Y-%m-%dT%H:%M:%S'
logging.basicConfig(filename=log_file, level=log_level,
format=log_format, datefmt=log_dateformat)
server_port = getenv('SERVER_PORT', '8099')
base_url = getenv('BASE_URL', 'https://demo.cy7.de')
app_factory = zope_app_factory
# storage settings
from scopes.storage.db.postgres import StorageFactory
dbengine = 'postgresql+psycopg'
dbname = getenv('DBNAME', 'demo')
dbuser = getenv('DBUSER', 'demo')
dbpassword = getenv('DBPASSWORD', 'secret')
dbschema = getenv('DBSCHEMA', 'demo')
# authentication settings
oidc_provider = getenv('OIDC_PROVIDER', 'https://a1.cy7.de')
oidc_client_id = getenv('OIDC_CLIENT_ID', '12345')
oidc_params = dict(
op_config_url=oidc_provider + '/.well-known/openid-configuration',
op_uris=None,
op_keys=None,
op_project_scope='urn:zitadel:iam:org:project:id:zitadel:aud',
callback_url=getenv('OIDC_CALLBACK_URL', base_url + '/auth/callback'),
client_id = oidc_client_id,
cookie_name=getenv('OIDC_COOKIE_NAME', 'oidc_' + oidc_client_id),
cookie_domain=getenv('OIDC_COOKIE_DOMAIN', None),
cookie_lifetime=getenv('OIDC_COOKIE_LIFETIME', '86400'),
cookie_crypt=getenv('OIDC_COOKIE_CRYPT', None),
private_key_file=getenv('OIDC_SERVICE_USER_PRIVATE_KEY_FILE', '.private-key.json'),
organization_id=getenv('OIDC_ORGANIZATION_ID', '12346'),
project_id=getenv('OIDC_PROJECT_ID', None),
)

View file

@ -1,21 +0,0 @@
# py-scopes/demo/demo_server.py
from scopes.web.auth import oidc
from scopes.storage import topic
import logging
import waitress
def run(app, config):
oidc.startup() # todo: use generic app.startServices()
port = int(config.server_port)
print(f'Serving on port {port}.')
waitress.serve(app, port=port)
if __name__ == '__main__':
import config
app = config.app_factory(config)
run(app, config)
# see zope.app.wsgi.getWSGIApplication

View file

@ -1,17 +0,0 @@
# s10: py-scopes/demo/.env
# input (example) file - copy to .env and edit.
SERVER_PORT=8800
BASE_URL=https://demo.cy7.de
DBNAME=demo
DBUSER=demo
DBPASSWORD=secret
DBSCHEMA=demo
OIDC_PROVIDER=
OIDC_CLIENT_ID=
OIDC_COOKIE_CRYPT=
OIDC_ORGANIZATION_ID=
OIDC_PROJECT_ID=

View file

@ -1,2 +0,0 @@
directory for logfiles created by application

View file

@ -1,11 +0,0 @@
# scopes/demo/shell.py
# simple shell for interactive testing / accessing the database (storage)
# use: `python -i shell.py`
import config
from scopes.web.auth import oidc
from scopes.storage.folder import Root
from scopes.storage import topic
storage = config.StorageFactory(config)(config.dbschema)
root = Root(storage)

View file

@ -1,43 +0,0 @@
[build-system]
requires = ["setuptools"]
build-backend = "setuptools.build_meta"
[project]
name = "py-scopes"
version = "3.0.1"
description = "Implementation of the unknown 'scopes' paradigm in Python"
readme = "README.md"
license = {text = "MIT"}
keywords = ["scopes"]
authors = [{name = "Helmut Merz", email = "helmutm@cy55.de"}]
dependencies = ["SQLAlchemy", "pydantic", "pydantic-settings"]
[project.optional-dependencies]
postgres = [
"psycopg[binary]",
"transaction",
"zope.sqlalchemy",
]
app = [ # TODO: rename to zope
"python-dotenv",
"waitress",
"zope.authentication",
"zope.interface",
"zope.publisher",
"zope.traversing",
]
api = ["fastapi[standard]"]
auth = ["pyjwt[crypto]", "cryptography", "requests"]
test = ["zope.testrunner"] # TODO: rename to zopetest, or merge with apitest
apitest = ["pytest", "httpx2"]
[tool.setuptools]
packages = ["scopes"]
[tool.pytest.ini_options]
addopts = ["-s"]
testpaths = ["tests"]
#python_files = ["test_api.py"]
# use .pytest.ini file for local test config

View file

@ -1,6 +0,0 @@
# runtests.sh
# run all unit / doc tests
#zope-testrunner --test-path=. -m !test_api $*
zope-testrunner --test-path=. $*

View file

@ -1 +0,0 @@
"""package scopes.api"""

View file

@ -1,45 +0,0 @@
# scopes.api.main
import sys
if '' not in sys.path:
sys.path = [''] + sys.path
import config
from scopes.storage import tracking
from scopes.storage.common import Storage
import transaction
from fastapi import Depends, FastAPI
from fastapi.responses import HTMLResponse
from typing import Annotated
# dependencies
def storage():
stf = config.StorageFactory(config)
return stf(config.dbschema)
# app and routes
app = FastAPI()
@app.get('/')
async def read_root():
return {'Hello': 'World'}
@app.get('/demo')
async def read_demo():
return HTMLResponse('<b>Hello World</b>')
@app.get('/persons/{name}')
async def read_person(name: str, q: str | None = None):
return {'name': name, 'query': q}
@app.get('/tracks')
async def get_tracks(storage: Annotated[Storage, Depends(storage)]):
tracks = storage.create(tracking.Container)
result = [tr.asDict() for tr in tracks.query()]
transaction.commit()
return result

View file

@ -1,5 +0,0 @@
# scopes.api.message
"""routes and views for Message objects"""

View file

@ -1,4 +0,0 @@
"""package scopes.core"""
from .core import *

View file

@ -1,40 +0,0 @@
# scopes.core.actor
import asyncio
from collections.abc import Awaitable, Callable
from typing import Any, Optional
from scopes.util import keywords as k, Symbol
# keywords:
k('active full ok quit retired suspended')
type Behavior = Callable[[Any], Awaitable[Behavior | None]]
class Mailbox:
def __init__(self, maxsize:int = 0, status: Symbol = k.active):
self.queue: asyncio.Queue = asyncio.Queue(maxsize)
self.status: Symbol = status
async def loop(q: asyncio.Queue, bhv: Behavior) -> None:
while True:
msg = await q.get()
if msg is k.quit:
break
bhv = await bhv(msg) or bhv
q.task_done()
def create(bhv: Behavior, maxsize: int = 0) -> Mailbox:
mb = Mailbox(maxsize)
asyncio.create_task(loop(mb.queue, bhv))
return mb
async def send(mb: Mailbox, msg: Any, no_wait: bool = False) -> Symbol:
if mb.status is k.retired:
return k.retired
if no_wait and mb.queue.full():
return k.full
await mb.queue.put(msg)
return k.ok

View file

@ -1,175 +0,0 @@
# scopes.core.core
"""A concurrent cybernetic communication system"""
from __future__ import annotations # no check of type annotations at runtime
import asyncio
from collections.abc import Awaitable, Callable, Coroutine
from dataclasses import dataclass, field
from typing import Any, Self
from scopes.core import actor
from scopes.core.message import Message
from scopes import util
from scopes.util import keywords as k, Symbol, Symbols
# keywords (domains, actions, classes, stages, data keys):
k('csys env _meta',
'connect create created data default query set value',
'c00 e00 s00 op rcvr',
'initial active retired',
'address pred succ')
type Categ = tuple[Symbol, Symbol]
type Proc = Callable[[Message, Scope], Awaitable[Scope | None]]
type Program = Callable[[Scope], Proc]
type ProgramSpec = dict[tuple[Symbol, Symbol], Proc]
type Syn = Callable[[Message], Awaitable[Symbol | None]]
type SynOp = Callable[[Message], Message | None]
type Handler = Callable[[Message, Scope],
Awaitable[Message | None | tuple[Message | None, Scope | None]]]
type Handlers = Handler | None | list[Handlers]
type Actions = dict[Symbol, Handlers]
def neuron(scope: Scope) -> actor.Mailbox:
async def process(msg: Message) -> actor.Behavior | None:
nonlocal scope
scope = await scope.proc(msg, scope) or scope
return process
return actor.create(process)
def synapse(rcvr: actor.Mailbox, op: SynOp = lambda x: x) -> Syn:
async def syn(msg: Message) -> Symbol | None:
msg_new = op(msg)
if msg_new is not None:
return await actor.send(rcvr, msg_new)
return None
return syn
env_dummy: actor.Mailbox = actor.Mailbox()
def notify() -> Handler:
async def handle(msg: Message, scope: Scope) -> None:
await actor.send(scope.environ, msg)
return handle
def gen_proc(actions: Actions, default: Handlers = notify(),
prepare: Handlers = None, finalize: Handlers = None) -> Proc:
async def proc(msg: Message, scope: Scope) -> Scope:
acts = actions.get(msg.action, actions.get(k.default, default))
acts = util.flatten(prepare, acts, finalize, ignore_none=True)
for act in acts:
msg = await act(msg, scope)
if isinstance(msg, tuple):
msg, new_scope = msg
if new_scope is not None:
scope = new_scope
if msg is None:
break
return scope
return proc
def gen_program(spec: ProgramSpec, default: Proc = gen_proc({})) -> Program:
def program(scope: Scope) -> Proc:
key = (scope.categ[1], scope.stage)
proc = spec.get(key, spec.get((key[0], k.default)))
if proc is None:
proc = spec.get((k.default, key[1]), default)
return proc
return program
@dataclass
class Scope:
value: Any = 0
categ: Categ = (k.csys, k.c00)
stage: Symbol = k.initial
proc: Proc = field(init=False)
program: Program = gen_program({})
syns: list[Syn | None] = field(default_factory=list)
environ: actor.Mailbox = env_dummy
def __post_init__(self):
self.set_proc()
def set_proc(self) -> Self:
self.proc = self.program(self)
return self
def create_scope(env: actor.Mailbox, cat: Categ = (k.csys, k.c00), **kw) -> Scope:
return Scope(environ=env, categ=cat, **kw)
def clone_scope(scope: Scope, **kw) -> Scope:
value = kw.get('value', 0)
categ = kw.get('categ', scope.categ)
stage = kw.get('stage', k.initial)
return Scope(environ=scope.environ, value=value, categ=categ, stage=stage,
program=scope.program, syns=[])
async def create_zero(env: actor.Mailbox) -> actor.Mailbox:
scope = create_scope(env, program=gen_program({}, default=std_proc()))
zero = neuron(scope)
await notify_created(Message(k.csys), zero, scope)
return zero
# predefined action handlers and Action sets
def noop(no_forward: bool = False) -> Handler:
async def handle(msg: Message, scope: Scope) -> Message | None:
return None if no_forward else msg
return handle
def forward(no_syns_notify: bool = False) -> Handler:
do_notify = notify() if no_syns_notify else None
async def handle(msg: Message, scope: Scope) -> None:
count = 0
for idx, syn in enumerate(scope.syns):
res = await syn(msg) if syn is not None else None
match res:
case k.retired: scope.syns[idx] = None
case k.ok: count += 1
if count == 0 and do_notify:
do_notify(msg, scope)
return handle
def value_add() -> Handler:
async def handle(msg: Message, scope: Scope) -> tuple[Message, Scope]:
return msg, scope
return handle
def create() -> Handler:
async def handle(msg: Message, scope: Scope) -> None:
new = neuron(clone_scope(scope, **msg.data))
await notify_created(msg, new, scope)
conn = msg.data.get(k.connect)
return handle
def connect() -> Handler:
async def handle(msg: Message, scope: Scope) -> None:
return None
return handle
def basic_actions() -> Actions:
acts: Actions = {
k.value: value_add(),
k.create: create(),
k.connect: connect()
}
return acts
# predefined specialized procs
def std_proc(actions: Actions | None = None, finalize: Handler = forward(),
more: Actions = {}) -> Proc:
if actions is None:
actions = basic_actions()
actions.update(more)
return gen_proc(actions, finalize=finalize)
# helpers
async def notify_created(msg: Message, new: actor.Mailbox, scope: Scope) -> None:
msg = Message(msg.domain, k.created, data=msg.data | {k.new: new})
await notify()(msg, scope)

View file

@ -1,28 +0,0 @@
# scopes.core.environ
"""Defining an environment for the communication system"""
from scopes import core
from scopes.core import actor
from scopes.core.message import Message
from scopes.util import keywords as k
def create_env(meta_env: actor.Mailbox) -> actor.Mailbox:
scope = core.create_scope(meta_env, (k.env, k.c00),
program=core.gen_program({}, default=env_proc()))
return core.neuron(scope)
def env_proc() -> core.Proc:
return core.gen_proc(env_actions(), default=None, finalize=core.notify())
def handle_created() -> core.Handler:
async def handle(msg: Message, scope: core.Scope) -> Message:
return msg
return handle
def env_actions() -> core.Actions:
return {
k.created: handle_created(),
}

View file

@ -1,20 +0,0 @@
# scopes.core.message
"""Generic message definition"""
from scopes import shape
class Message(shape.Record):
headFields = ('domain', 'action', 'class', 'item')
headFieldsAsKeywords = {'domain', 'action', 'class'}
customer = None
def addr(self):
hv = self.headValues
return hv[0], hv[2], hv[3]
def categ(self):
return self.domain, self.head['class']

View file

@ -1,14 +1,9 @@
# scopes.interfaces
from zope.interface import Interface, Attribute
from zope.interface import Interface
class IViewable(Interface):
prefix = Attribute('Prefix string for identifying the type (class) of an object')
class ITraversable(IViewable):
class ITraversable(Interface):
def get(key, default=None):
"""Return the item addressed by `key`; return `default` if not found."""

View file

@ -1 +0,0 @@
"""package scopes.org"""

View file

@ -1,16 +0,0 @@
# scopes.org.mail
from zope.interface import implementer
from zope.sendmail.interfaces import IMailDelivery
"""Utilities for creating and sending emails."""
@implementer(IMailDelivery)
class DummyMailDelivery:
"""For testing purposes: just store mails in maildata.log"""
def send(self, fromaddr, toaddrs, message):
print("DummyMailDelivery")
print(f"fromaddr: {fromaddr}, toaddrs: {toaddrs}")
print(message)

View file

@ -0,0 +1 @@
"""package scopes.organize"""

View file

@ -1,4 +1,4 @@
# scopes.org.task
# scopes.organize.task
"""Task (and corresponding container) implementation."""

View file

@ -0,0 +1 @@
"""package scopes.server"""

View file

@ -1,31 +1,22 @@
# scopes.web.app
# scopes.server.app
import logging
from zope.i18n.interfaces import IUserPreferredCharsets
from zope.interface import implementer
from zope.publisher.base import DefaultPublication
from zope.publisher.browser import BrowserRequest
from zope.publisher.interfaces import NotFound
from zope.publisher.publish import publish
from scopes.interfaces import ITraversable, IView
from scopes.web.browser import getView
from scopes.server.browser import getView
import scopes.storage.concept # register container classes
from scopes.storage.folder import Root
@implementer(IUserPreferredCharsets)
class Request(BrowserRequest):
def getPreferredCharsets(self):
return ['UTF-8']
def zope_app_factory(config):
storageFactory = config.StorageFactory(config)
def zope_app(environ, start_response):
storage = storageFactory(config.dbschema)
appRoot = Root(storage)
request = Request(environ['wsgi.input'], environ)
request = BrowserRequest(environ['wsgi.input'], environ)
request.setPublication(Publication(appRoot))
request = publish(request, True)
response = request.response
@ -36,12 +27,6 @@ def zope_app_factory(config):
class Publication(DefaultPublication):
def beforeTraversal(self, request):
super(Publication, self).beforeTraversal(request)
from scopes.web.auth.oidc import authentication
prc = authentication.authenticate(request)
request.setPrincipal(prc)
def traverseName(self, request, ob, name):
next = getView(request, ob, name)
if next is not None:

View file

@ -1,25 +1,21 @@
# scopes.web.browser
# scopes.server.browser
import json
import logging
from zope.interface import implementer
from scopes.interfaces import IContainer, IReference, IView
logger = logging.getLogger('web.browser')
views = {} # registry for all views: {name: {prefix: viewClass, ...}, ...}
def register(name, *contextTypes):
"""Use as decorator: `@register(name, class_or_prefix, ...).
No class (or `None` or `''`) means default view for all classes."""
def register(name, *contextClasses):
"""Use as decorator: `@register(name, class, ...).
class `None` means default view for all classes."""
def doRegister(factory):
implementer(IView)(factory)
nameEntry = views.setdefault(name, {})
cts = contextTypes or ['']
for ct in cts:
if not isinstance(ct, str):
ct = ct.prefix
nameEntry[ct] = factory
for cl in contextClasses:
nameEntry[cl.prefix] = factory
else:
nameEntry[''] = factory
return factory
return doRegister
@ -32,7 +28,6 @@ def getView(request, ob, name):
factory = nameEntry.get('')
if factory is None:
return None
logger.debug('getView: %s %s', ob, request['PATH_INFO'])
return factory(ob, request)
@ -59,11 +54,10 @@ class DefaultView:
result['target'] = target.asDict()
if IContainer.providedBy(target):
result['target']['items'] = [v.asDict() for v in target.values()]
prc = self.request.principal
if prc is not None:
result['principal'] = prc.asDict()
return result
def render(self, result):
self.request.response.setHeader('Content-type', 'application/json; charset=utf-8')
return json.dumps(result).encode('UTF-8')

View file

@ -1,53 +0,0 @@
# scopes.shape
"""really abstract basic data shape definitions"""
from scopes.util import keywords as k
class Record:
headFields: tuple = () # to be defined by subclass
headFieldsAsKeywords: set[str] = set()
defaultHeadValue = ''
def __init__(self, *headValues, data=None, **kw):
self.head = dict((k, self.defaultHeadValue) for k in self.headFields)
for k, v in kw.items():
self.set(k, v)
for ix, hv in enumerate(headValues):
self.set(self.headFields[ix], hv)
self.data = data or {}
def __getattr__(self, k):
raise AttributeError(k)
@property
def headValues(self):
return [self.head[k] for k in self.headFields]
def set(self, attr, value):
if attr in self.headFields:
if value is None:
value = self.defaultHeadValue
elif attr in self.headFieldsAsKeywords:
value = k[value]
self.head[attr] = value
setattr(self, attr, value)
else:
raise AttributeError(attr)
def update(self, data, overwrite=False):
if data is None:
return
if overwrite:
self.data = data
else:
self.data.update(data)
def __repr__(self):
return f'<{self.__class__.__name__}: {self.headValues}, {self.data}>'
def asDict(self):
return dict(head=self.head, data=self.data)

View file

@ -52,8 +52,8 @@ class Storage(object):
return metadata.tables.get((schema and schema + '.' or '') + tableName)
def dropTable(self, tableName):
prefix = self.schema and self.schema + '.' or ''
with self.engine.begin() as conn:
prefix = self.schema and self.schema + '.' or ''
conn.execute(text('drop table if exists %s%s' % (prefix, tableName)))
def resetSequence(self, tableName, colName, v):
@ -63,19 +63,15 @@ class Storage(object):
conn.execute(text(sq))
class StorageFactory:
class StorageFactory(object):
def sessionFactory(self):
return self.engine.connect
@staticmethod
def getEngine(dbtype, dbname, user, pw, **kw):
def getEngine(dbtype, dbname, user, pw, host='localhost', port=5432, **kw):
return create_engine('%s:///%s' % (dbtype, dbname), **kw)
def engineFromConfig(self, config):
return self.getEngine(config.dbengine, config.dbname,
config.dbuser, config.dbpassword)
@staticmethod
def mark_changed(session):
pass
@ -90,7 +86,8 @@ class StorageFactory:
storageClass = Storage
def __init__(self, config, storageClass=None):
self.engine = self.engineFromConfig(config)
self.engine = self.getEngine(config.dbengine, config.dbname,
config.dbuser, config.dbpassword)
self.Session = self.sessionFactory()
if storageClass is not None:
self.storageClass = storageClass

View file

@ -24,12 +24,6 @@ class StorageFactory(StorageFactory):
return create_engine('%s://%s:%s@%s:%s/%s' % (
dbtype, user, pw, host, port, dbname), **kw)
def engineFromConfig(self, config):
return self.getEngine(config.dbengine, config.dbname,
config.dbuser, config.dbpassword,
host=getattr(config, 'dbhost', 'localhost'),
port=getattr(config, 'dbport', 5432))
@staticmethod
def mark_changed(session):
return mark_changed(session)

View file

@ -7,18 +7,6 @@ from scopes.storage.common import registerContainerClass
from scopes.storage.tracking import Container, Track
class DummyFolder(dict):
prefix = 'dummy'
def asDict(self):
return self
def __repr__(self):
return '<%s: %s>' % (self.__class__.__name__,
super(DummyFolder, self).__repr__())
@implementer(IContainer, IReference)
class Folder(Track):
@ -69,8 +57,6 @@ class Root(Folder):
"""A dummy (virtual) root folder for creating real folders
using the Folder API."""
prefix = 'root'
def __init__(self, storage):
cont = storage.create(Folders)
super(Root, self).__init__(container=cont)

View file

@ -1,22 +0,0 @@
# scopes.storage.message
"""Generic messages (or events) to be stored in SQL database."""
from scopes.core.message import Message as BaseMessage
from scopes.storage.common import registerContainerClass
#from scopes.storage.tracking import Container, Track
from scopes.storage.tracking import Container, Record
class Message(Record, BaseMessage):
prefix = 'msg'
@registerContainerClass
class Messages(Container):
itemFactory = Message
indexes = [('domain', 'action', 'class', 'item'), ('domain', 'class', 'item')]
tableName = 'messages'
insertOnChange = True

View file

@ -12,22 +12,46 @@ from sqlalchemy import Table, Column, Index
from sqlalchemy import DateTime, Text, func
from sqlalchemy import and_
from scopes import shape
from scopes.storage.common import registerContainerClass
class Record(shape.Record):
class Track(object):
def __init__(self, *headValues, data=None, timeStamp=None, trackId=None,
container=None, **kw):
super().__init__(*headValues, data=data, **kw)
self.timeStamp = timeStamp
self.trackId = trackId
self.container = container
headFields = ['taskId', 'userName']
prefix = 'rec'
def asDict(self):
return dict(uid=self.uid, head=self.head, data=self.data,
timeStamp=str(self.timeStamp)[:19])
def __init__(self, *keys, **kw):
self.head = {}
for k, v in kw.items():
if k in self.headFields:
self.head[k] = kw.pop(k)
for ix, k in enumerate(keys):
self.head[self.headFields[ix]] = k
for k in self.headFields:
if self.head.get(k) is None:
self.head[k] = ''
setattr(self, k, self.head[k])
self.data = kw.get('data') or {}
self.timeStamp = kw.get('timeStamp')
self.trackId = kw.get('trackId')
self.container = kw.get('container')
def set(self, attr, value):
if attr in self.headFields:
if value is None:
value = ''
self.head[attr] = value
setattr(self, attr, value)
else:
raise AttributeError(attr)
def update(self, data, overwrite=False):
if data is None:
return
if overwrite:
self.data = data
else:
self.data.update(data)
@property
def uid(self):
@ -41,6 +65,13 @@ class Record(shape.Record):
return ''
return str(self.trackId)
def __repr__(self):
return '%s: %s' % (self.__class__.__name__, self.asDict())
def asDict(self):
return dict(uid=self.uid, head=self.head, data=self.data,
timeStamp=str(self.timeStamp)[:19])
# shortcuts for compatibility with existing software:
def updateIndex(self, **kw):
@ -51,14 +82,9 @@ class Record(shape.Record):
def storeTrack(self):
self.container.update(self)
class Track(Record):
headFields = ['taskId', 'userName']
prefix = 'rec'
@registerContainerClass
class Container:
class Container(object):
itemFactory = Track
tableName = 'tracks'
@ -90,8 +116,7 @@ class Container:
and_(*self.setupWhere(crit))).order_by(self.table.c.trackid)
else:
stmt = self.table.select().order_by(self.table.c.trackid)
#return [self.makeTrack(r) for r in self.session.execute(stmt)]
for r in self.session.execute(stmt).all():
for r in self.session.execute(stmt):
yield self.makeTrack(r)
def queryLast(self, **crit):

View file

@ -1,18 +0,0 @@
# scopes.testing
"""Common stuff (config, fixtures, ...) for testing"""
import os, sys
sys.path = [os.path.dirname(__file__)] + sys.path
import config
from scopes.storage.db import postgres
config.dbengine = 'postgresql+psycopg'
config.dbname = 'testdb'
config.dbuser = 'testuser'
config.dbpassword = 'secret'
config.dbschema = 'testing'
config.StorageFactory = postgres.StorageFactory

33
scopes/tests.py Normal file
View file

@ -0,0 +1,33 @@
# scopes/tests.py
"""The real test implementations"""
import unittest
from scopes import tlib_storage
import config
config.dbengine = 'postgresql'
config.dbname = 'ccotest'
config.dbuser = 'ccotest'
config.dbpassword = 'cco'
config.dbschema = 'testing'
# PostgreSQL-specific settings
from scopes.storage.db.postgres import StorageFactory
config.storageFactory = StorageFactory(config)
#storage = factory(schema='testing')
class Test(unittest.TestCase):
def test_001_tracking(self):
tlib_storage.test_tracking(self, config)
def test_002_folder(self):
tlib_storage.test_folder(self, config)
def test_003_type(self):
tlib_storage.test_type(self, config)
def test_004_topic(self):
tlib_storage.test_topic(self, config)

View file

@ -1 +0,0 @@
# py-scopes/tests

View file

@ -1,64 +0,0 @@
# py-scopes/tests/config.py
import logging
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file='.env')
base_url: str = 'https://test.cy7.eu'
log_file: str = 'scopes/tests/log/scopes-test.log'
log_level: str = 'INFO'
log_format: str = '%(asctime)s %(levelname)s %(name)s %(message)s'
log_dateformat: str = '%Y-%m-%dT%H:%M:%S'
dbengine: str = 'sqlite'
dbname: str = 'var/test.db'
dbuser: str | None = None
dbpassword: str | None = None
dbschema: str | None = None
settings = Settings()
from os import getenv
import sys
base_url = settings.base_url
logging.basicConfig(filename=settings.log_file, level=settings.log_level,
format=settings.log_format, datefmt=settings.log_dateformat)
# SQLite storage settings
dbengine = settings.dbengine
dbname = settings.dbname
dbuser = settings.dbuser
dbpassword = settings.dbpassword
dbschema = settings.dbschema
# special testing stuff
from scopes.tests import data_auth # add oidc URIs and keys to dummy_requests data
from scopes.tests import dummy_requests
sys.modules['requests'] = dummy_requests
# authentication settings
oidc_provider = 'test://oidc'
oidc_client_id = getenv('OIDC_CLIENT_ID', '12345')
oidc_params = dict(
op_config_url=oidc_provider + '/.well-known/openid-configuration',
op_uris=None,
op_keys=None,
op_project_scope='urn:zitadel:iam:org:project:id:zitadel:aud',
callback_url=getenv('OIDC_CALLBACK_URL', base_url + '/auth/callback'),
client_id=oidc_client_id,
principal_prefix=getenv('OIDC_PRINCIPAL_PREFIX', 'loops.'),
cookie_name=getenv('OIDC_COOKIE_NAME', 'oidc_' + oidc_client_id),
cookie_domain=getenv('OIDC_COOKIE_DOMAIN', None),
cookie_lifetime=getenv('OIDC_COOKIE_LIFETIME', '86400'),
cookie_crypt=getenv('OIDC_COOKIE_CRYPT', None),
private_key_file=getenv('OIDC_SERVICE_USER_PRIVATE_KEY_FILE',
'scopes/tests/test-private-key.json'),
organization_id=getenv('OIDC_ORGANIZATION_ID', '12346'),
project_id=getenv('OIDC_PROJECT_ID', None),
)

View file

@ -1,37 +0,0 @@
# scopes.tests.data_auth
"""provide response data for testing (via dummy_requests)"""
from cryptography.hazmat.primitives.asymmetric import rsa
from scopes import util
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
public_key = private_key.public_key()
public_key_n = util.b64e(public_key.public_numbers().n.to_bytes(256)).decode('ASCII')
oidc_data = {
'test://oidc/.well-known/openid-configuration': {
"issuer": "test://oidc",
"authorization_endpoint": "test://oidc/oauth/v2/authorize",
"token_endpoint": "test://oidc/oauth/v2/token",
"introspection_endpoint": "test://oidc/oauth/v2/introspect",
"userinfo_endpoint": "test://oidc/oidc/v1/userinfo",
"revocation_endpoint": "test://oidc/oauth/v2/revoke",
"end_session_endpoint": "test://oidc/oidc/v1/end_session",
"device_authorization_endpoint": "test://oidc/oauth/v2/device_authorization",
"jwks_uri": "test://oidc/oauth/v2/keys"},
'test://oidc/oauth/v2/keys': { "keys": [
{"use": "sig",
"kty": "RSA",
"kid": "316766976250797901",
"alg": "RS256",
"n": public_key_n,
"e": "AQAB"}]},
'test://oidc/oauth/v2/token': {
"access_token": "abcde12345"},
'test://oidc/v2/users/human': {
"code": 1}
}
from scopes.tests.dummy_requests import response_data
response_data.update(oidc_data)

View file

@ -1,30 +0,0 @@
# scopes.tests.requests
"""Dummy requests implementation for testing."""
from logging import getLogger
logger = getLogger('tests.dummy_requests')
def get(url, *args, **kw):
logger.info(f'get: %s - %s - %s', url, args, kw)
return FakeResponse(response_data[url])
def post(url, *args, **kw):
logger.info(f'post: %s - %s - %s', url, args, kw)
return FakeResponse(response_data[url])
class FakeResponse:
def __init__(self, data):
self.data = data
@property
def status_code(self):
return 200
def json(self):
return self.data
response_data = {}

View file

@ -1 +0,0 @@
directory for logfiles created by tests

View file

@ -1,36 +0,0 @@
# scopes.tests.scratch
"""for keeping experimental and demonstration code and data (mainly for testing)"""
import base64
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives import serialization
from cryptography.utils import int_to_bytes
from scopes import util
# generate (and serialize) or load private key
# generate public JWKS (key set) from private ky
#private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
#pem = private_key.private_bytes(encoding=serialization.Encoding.PEM,
# format=serialization.PrivateFormat.PKCS8,
# encryption_algorithm=serialization.NoEncryption())
pem = b'-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQD1ipW1hnV+OWF/\nE09YEQB9GTwQ0DUhmM1ZtmONV3xp1zrpLsvQtJJ6lZ8rp+ws35HGEEQWuBvgKP+K\nyyhILkgHp5LUtXsIruvo/+20Gbxr3bbIp2Omv8NuUnQIlb0SkZwauz/iLi+dFnoW\nTzkCwRnOPG+xqrSzD4CUCIZc4fU61h457I5PWTgK+bZXlsPytook9HpCIPX+9PaU\n935KHeWLonx3GZWqcqUyHSUn8dgRMypaKO70zCZkZWwDbCkJv7OUuY9QijN9niA+\nX3MFyqf9+WK7/lLnNYimdXk9Pz9Lim7HhoAApoW/0ueUfok54OIzuemTAUaunaN6\nmdOvh54dAgMBAAECggEAAWW5Vx7kAY1Bto104U51EEm519EvhUm6B5T4+VTp5LYK\nF1rEMIFwtBkoFfjzLJFEwEakJ8Hgq80TIRdyJ72r9ACIPGtTlH0Jjg94IqyvwOy6\n23fPEXkrLHR0Z5g8bR+6A95liQrA+tCD1QVFRAWUktlkVXfxRC95TMCA3i7rakGG\no6BbyUD2Hp7IEO5fbfKNiqrUO/LwvLOMBTe3Q9EVv7fblm21HiwWG26RgO29UtFo\nEKcaALSqIYwYCkc9EuLpcGRzLfH1opid3Xtu4+hm5enNgdXZRKr9lSQUCSi9oZRE\npszNGyDsaot2ixwKP3c6Eo3bQKMmXXw8Y3JsNiZzIQKBgQD8QuCjKyAeH0QrhW1Y\nvSmUC3q517WZPRgsEMx917MYn/Gl1ywsROjawP/ClxTZkpXVgALFvrggjB75p/QG\noFamZpf9U95fVivAkANQYmhGmqPVQUeb2vgHQOdPSdvvMoRumcEcfS/dOo9R//s+\nfrBQNYfp/tTttdyM2N0CzsuBzQKBgQD5LjX8SvLPe6q6xJchdExywbTk+DLtiKDs\nT5avaGhOqzYhir6FvEt7Gw74J6IWzIt5X4OteSkpRvj2TN0ik+KAfZOk+v9C5iK7\nxbicWj5XSaPJcYvhxogO7mb8sSrl/cY20HRkCsyQOKeH005PHsGZztZoHrsF6t2X\nbtKgNWp9kQKBgHLDHh09RlxNzx6Zkfh3/k1qt4eKmgQ/5hpN/ioWElVWloHjFSaC\npwi2GuT1BLhC1sWNejVqIaw08vaTMRI+qY0ESYsnN5hZxIfTPJ66VkQgn/4pt6Ex\nCfuKzHCm4la8vcDvVApY7YiQ1pjwguWYjy++WrnahBYs0UyGcG2RlMXVAoGBAJNJ\nf1ubqZ5+yNIQ9gwuRCno2dYl52SESCqmeLlCC7XEegCllCxUuoEP429HbgXv7dlW\nXe0iGvRtISflEyknJNEyaR0xx8RxZ8J6Ar9YkFTkEE44MajIww+gV3ux9Vtw/8LS\nwJmJ0JTHCC++9SDLW0BhBFcTIxVCWKz0MsfECyghAoGAOLm2dF59JiiiynGiWXzJ\nZcbZQStR+ysjLX+RqTATAorhweUiv+HwbB4boWhOnDD7q3t+93GnzeqI9m9Q5yo6\nRTyyNYa4uJXMMarZxi3m+RGvuCC2h+7ja6H6UGg3xrHNwcykJkuRAZvqCN+f6Nxo\njc8o9LMimQyTv+yptrOhi2w=\n-----END PRIVATE KEY-----\n'
private_key = serialization.load_pem_private_key(pem, password=None)
public_key = private_key.public_key()
nums = public_key.public_numbers()
#e = base64.urlsafe_b64encode(int_to_bytes(nums.e)).rstrip(b'=')
#e = base64.urlsafe_b64encode(nums.e.to_bytes(3)).rstrip(b'=')
e = b'AQAB'
#n = base64.urlsafe_b64encode(int_to_bytes(nums.n)).rstrip(b'=')
n = util.b64e(nums.n.to_bytes(256))
# data from example session with zitadel
keys_response = {"keys":[{"use":"sig","kty":"RSA","kid":"317781576895225677","alg":"RS256","n":"rBE2kf4S1-uLqTKoRcxmBDZXeNU0PcDZtXX3pYK4hbE_cVNqMnWZQNXh4asLvZ_VpInIRI_gRe5pFfrUfnFppnu7sgUGPHS_i6E1JSDWDDViwU0DbQXzTMA36xb8fI9vm_StrKqQ2snpkV2YprWC597vOa6w8eWMEfwWV39gmpgjyaYfwKi2ldvnn_djYSEyy6Cs8QW9L8uaTKDYUuuIY7iyjTRI7kM1boZcg26WQ4nDgSU8BMEmPGc-h7BHIm3PVEKNBUU3dRIcvIlnAx7AJoqdmUU0BllMGOd-To7PP760Vx4gYBsTFRGaQ_9P7vRApn0Wywnhj0BaVWGyJPkggQ","e":"AQAB"}]}
token_response = {'access_token': 'WGn4xjOluOgEugwqfxACziBZxAxQ1SKBKU6p27DysWw9slM55Fn7wkz_avpy5BjFR1uU_2rJoQTMNsO8NyxOEkmhbpP9xURUQrLRBiZT', 'token_type': 'Bearer', 'expires_in': 43199, 'id_token': 'eyJhbGciOiJSUzI1NiIsImtpZCI6IjMxNzc4MTU3Njg5NTIyNTY3NyIsInR5cCI6IkpXVCJ9.eyJhbXIiOlsicHdkIl0sImF0X2hhc2giOiJQbC1kMjBvRG9DRHZqTUtGMmF4S3N3IiwiYXVkIjpbIjMxMTYxMzExOTgxNjM5MjUyNSIsIjMxNDQ2OTIwOTA3OTkzMDcwMSIsIjMxMTQ3MjQ1MTc2ODg2ODY4NSJdLCJhdXRoX3RpbWUiOjE3NDU5MTAxNTksImF6cCI6IjMxMTYxMzExOTgxNjM5MjUyNSIsImNsaWVudF9pZCI6IjMxMTYxMzExOTgxNjM5MjUyNSIsImVtYWlsIjoiaGVsbXV0Lm1lcnpAcG9zdGVvLmRlIiwiZW1haWxfdmVyaWZpZWQiOnRydWUsImV4cCI6MTc0NTk1MzUwOSwiZmFtaWx5X25hbWUiOiJVc2VyIiwiZ2VuZGVyIjoibWFsZSIsImdpdmVuX25hbWUiOiJUZXN0IiwiaWF0IjoxNzQ1OTEwMzA5LCJpc3MiOiJodHRwczovL2ExLmN5Ny5kZSIsImxvY2FsZSI6ImRlIiwibmFtZSI6IlRlc3QgVXNlciIsIm5vbmNlIjoiMVJGem9RNDkyazNWTFEzcyIsInByZWZlcnJlZF91c2VybmFtZSI6InRzdDkiLCJzaWQiOiJWMV8zMTc3ODQyMjY2MjE2MTE4NTMiLCJzdWIiOiIzMTE4NDY3OTY3Mzk1MzQ2NjkiLCJ1cGRhdGVkX2F0IjoxNzQ1MzA1MTkzLCJ1cm46eml0YWRlbDppYW06b3JnOnByb2plY3Q6MzExNDcyNDUxNzY4ODY4Njg1OnJvbGVzIjp7ImNjLXVzZXIiOnsiMzExNDczNTAyMjc0MjQ4NTI1IjoiY3liZXJjb25jZXB0cy5hMS5jeTcuZGUifX0sInVybjp6aXRhZGVsOmlhbTpvcmc6cHJvamVjdDpyb2xlcyI6eyJjYy11c2VyIjp7IjMxMTQ3MzUwMjI3NDI0ODUyNSI6ImN5YmVyY29uY2VwdHMuYTEuY3k3LmRlIn19LCJ1cm46eml0YWRlbDppYW06dXNlcjpyZXNvdXJjZW93bmVyOmlkIjoiMzExNDczNTAyMjc0MjQ4NTI1IiwidXJuOnppdGFkZWw6aWFtOnVzZXI6cmVzb3VyY2Vvd25lcjpuYW1lIjoiY3liZXJjb25jZXB0cyIsInVybjp6aXRhZGVsOmlhbTp1c2VyOnJlc291cmNlb3duZXI6cHJpbWFyeV9kb21haW4iOiJjeWJlcmNvbmNlcHRzLmExLmN5Ny5kZSJ9.hx8JWXWVPq-WfBx9AMgFRWaEX3CioU8BCZwT3d8YETMejsR33LDsbPfnzO0Dbw885tiyNIsdNRj7lfk5HtuZVWdHx58hnaMw-czGhIfB0GXvuh2UFemBoLNJLajtRvCBi-YIZ_c6TD0Ryd2wn_yQlgY1J2R8xTsBWeKM_SYqlKU20r4r3nsX4krg6Q9x-Kc3k0Rg3lMXoxFYgQSzXrKjxTI3HHb03H4lzgmrdcbQqfbLoHKdvGxzfo-Gf8Rlt9rQ-fpGu7fRfPJQV14SPJ6CQtKP0adq_D2blcp1I1xKXML8TytJa05RjL_l_KH-segFfORtGMc4mYo8JUpMW0KOvg'}
token_id_claims = {'amr': ['pwd'], 'at_hash': 'Pl-d20oDoCDvjMKF2axKsw', 'aud': ['311613119816392525', '314469209079930701', '311472451768868685'], 'auth_time': 1745910159, 'azp': '311613119816392525', 'client_id': '311613119816392525', 'email': 'helmut.merz@posteo.de', 'email_verified': True, 'exp': 1745953509, 'family_name': 'User', 'gender': 'male', 'given_name': 'Test', 'iat': 1745910309, 'iss': 'https://a1.cy7.de', 'locale': 'de', 'name': 'Test User', 'nonce': '1RFzoQ492k3VLQ3s', 'preferred_username': 'tst9', 'sid': 'V1_317784226621611853', 'sub': '311846796739534669', 'updated_at': 1745305193, 'urn:zitadel:iam:org:project:311472451768868685:roles': {'cc-user': {'311473502274248525': 'cyberconcepts.a1.cy7.de'}}, 'urn:zitadel:iam:org:project:roles': {'cc-user': {'311473502274248525': 'cyberconcepts.a1.cy7.de'}}, 'urn:zitadel:iam:user:resourceowner:id': '311473502274248525', 'urn:zitadel:iam:user:resourceowner:name': 'cyberconcepts', 'urn:zitadel:iam:user:resourceowner:primary_domain': 'cyberconcepts.a1.cy7.de'}

View file

@ -1 +0,0 @@
{"type":"serviceaccount","keyId":"314794985486606157","key":"-----BEGIN RSA PRIVATE KEY-----\nMIIEowIBAAKCAQEA0dC8wcwu6Uefxx/shqsSTk//ATseeCy28RMAEa4NFGj/y8Ju\nOfVUj7pB5+6onjmsBAHXhCJ+fEWWAJdHnbvywrBNNhVx38f8v+90zUP2IzlT1UDp\ncTIYaehnf3+uqwgMcijnYJ6UgaHFMSecxnYD4adnw8J/FEMDgy2N+v5krp989VQ5\nT2kgrkb/l5z8dgLhmmcLKm7YCG1uXXP+g+qzEZ9Uhur5b+czjIalzC/tq2V2JoJB\nooH9w1iaRXRKel7FZPo0YGyQh/0a9Zn5JsXVc3YTHTKh9madr/yQqmk+6siTl/Ou\ntz9mvpY+AfFRaIWikoyB3W9rHd0b6WtQPflEPwIDAQABAoIBAAN64daZC2IlJPpJ\nhkPJjJkt7H3ZvCykGTiwZvzkFSV0hGGdzPQ7JHbp0PQG2lcdf8PlP+zaIZzwDofd\n+nscRe+CuxUdj/D1QTTxxM8uxGNbLQ/JbtXIzezbxPOxa3U8wfAWy5enqbDovPuO\nu6PzCydv/mGZ1T/ByMohNEyocYUP6mupHWwf2hN/lnrL264w8uvNjAw0xDtbtBJN\nX61u6vi/fiY37qKblN3irAePwK4LIhHZZoyJ1HrFYIkFf0Bviuzpw/ASVqbjizPV\nmTxGxghiQacAMvSSe+pcfJ7ip74rCFv7+6pzL+yW8df1lbSM9vS+86SDgY9RCc2E\n3h1/hUECgYEA/WqiWNXey25qCNB6WHo3SU5cZIZVNWzsT1zkwkXOUtEyU0/zEfT+\nEjW/vbxIBgZNV1tX2aXd7Ke5OCoQ1dqLnmDoO5d13xTeaWN3FR8ibTwbaDCwyg5d\njyIXK2k7IwtcpJFgJFGM/6udAdO/bPm1IPEslJXHBqZoGrKb+bTw6N8CgYEA0/RQ\nHtQluQYBtXNzEql0MaxBUxfHkwjL6Yo6dM+EJAomI+cccVy22s+z2aQX5GVQnbzs\nm9BGkJzzn7eGPy3i2LgStqUZ2W7VqfIJNCIDbC7OxBAaszh5/LEgv5pfp1Yr/HIf\nwHZz53rdV8H+oUfMJdlyrRyGOeGIDZCd94nTMKECgYAQOpT9BW1IL+EAgYFkSydh\nPXBzS5sHWdtkVbmcq2XELfuAFF2np73hoqmN2BHwuNSZJJNir9mffzpAW4lKeL16\nPhCBSHjW+Xoo26LTqnPE9RV4Pa4EspjRQsijEhEkdGTRcTHsAYD7Gp1qcYoPy4oK\n+wb02Qau6Vc/ZnLQsgK/lwKBgQDMLSGxUPQ11E95GAnWBF7mKuWSwemC/opQItRF\nClJk1VIAa/W+Tm3nQwYhti0920tZaFEVmAEh9c/KH+S2n+FSm5+LSmgoSNiSqZGs\nIsfhQwXzYQAXfWQlxAukB3X1oNEmkll78Z+dcYIfs8UyYBOMsngBwuSahWOmjZVe\ni+phgQKBgC0ozpbIcNg48M4/Rrev3qJB7XlU74MySsFJdBhlrzmK3+z02bXWbyaJ\nzQLwC6Dorw0PcWAKtcJcbBn6ZAoptcmG6wdQrYk1IC+82TDcNvAFL06y8OXHYLtu\ni5AiE4nK1waoDF/1I66VACyKI6hhISRW3bKaxHhrx5OsGKVurF4R\n-----END RSA PRIVATE KEY-----\n","expirationDate":"9999-12-31T23:59:59Z","userId":"311889729668833101"}

View file

@ -1,47 +0,0 @@
# scopes.tests.test_postgres
"""Tests for the 'scopes.storage' package - using PostgreSQL."""
import os, sys
sys.path = [os.path.dirname(__file__)] + sys.path
import unittest
from scopes.tests import tlib_storage
from scopes.tests.util import setup_logging
from scopes.storage.db.postgres import StorageFactory
import config
setup_logging(config)
config.dbengine = 'postgresql+psycopg'
config.dbname = 'testdb'
config.dbuser = 'testuser'
config.dbpassword = 'secret'
config.dbschema = 'testing'
config.storageFactory = StorageFactory(config)
class Test(unittest.TestCase):
def test_001_tracking(self):
tlib_storage.test_tracking(self, config)
def test_002_folder(self):
tlib_storage.test_folder(self, config)
def test_003_type(self):
tlib_storage.test_type(self, config)
def test_004_topic(self):
tlib_storage.test_topic(self, config)
def test_005_message(self):
tlib_storage.test_message(self, config)
def test_suite():
return unittest.TestSuite((
unittest.TestLoader().loadTestsFromTestCase(Test),
))
if __name__ == '__main__':
unittest.main(defaultTest='test_suite')

View file

@ -1,48 +0,0 @@
# scopes.tests.test_standard
"""Tests for the 'scopes.storage' package."""
import os, sys
sys.path = [os.path.dirname(__file__)] + sys.path
import unittest
from scopes.tests import tlib_web, tlib_storage
from scopes.tests.util import setup_logging
from scopes.storage.common import StorageFactory
import config
setup_logging(config)
config.dbengine = 'sqlite'
config.dbname = 'var/test.db'
config.dbschema = None
config.storageFactory = StorageFactory(config)
class Test(unittest.TestCase):
def test_001_tracking(self):
tlib_storage.test_tracking(self, config)
def test_002_folder(self):
tlib_storage.test_folder(self, config)
def test_003_type(self):
tlib_storage.test_type(self, config)
def test_004_topic(self):
tlib_storage.test_topic(self, config)
def test_013_web(self):
tlib_web.test_app(self, config)
tlib_web.test_auth(self, config)
tlib_web.test_user_data(self, config)
def suite():
return unittest.TestSuite((
unittest.TestLoader().loadTestsFromTestCase(Test),
))
if __name__ == '__main__':
unittest.main(defaultTest='suite')

View file

@ -1,49 +0,0 @@
# tests/tlib_web.py
"""Test implementation for the `scopes.web` package."""
import json
import logging
from zope.publisher.browser import TestRequest
from zope.publisher.publish import publish
from scopes.web.app import Publication
from scopes.storage.folder import Root
logger = logging.getLogger('tlib_web')
def publishRequest(config, storage, path):
appRoot = Root(storage)
request = TestRequest(environ=dict(PATH_INFO=path))
request.setPublication(Publication(appRoot))
request = publish(request, False)
return request.response
def test_app(self, config):
storage = config.storageFactory(config.dbschema)
response = publishRequest(config, storage, '/top')
logger.info('test_app: response %s %s', response.getStatus(), response.getHeaders())
result = json.loads(response.consumeBody())
self.assertEqual(result['items'][0]['head']['name'], 'level2-item1')
def test_auth(self, config):
from scopes.web.auth import oidc
oidc.startup() # todo: use generic app.startServices()
self.assertEqual(len(config.oidc_params['op_uris']), 8)
storage = config.storageFactory(config.dbschema)
response = publishRequest(config, storage, '/top/auth/login')
headers = dict(response.getHeaders())
logger.info('test_auth: response %s %s', response.getStatus(), headers)
self.assertEqual(response.getStatus(), 302)
uri = config.oidc_params['op_uris']['jwks_uri']
keys = oidc.loadOidcKeys(uri)
logger.info('test_auth keys: %s', keys)
def test_user_data(self, config):
from scopes.web.auth import user
u1 = user.User(login='tst9', email='tst9@example.com',
firstName='Test', lastName='User')
xu1 = user.ExtUser(u1, idPrefix='test.')
xu1.create(updateIfExists=True)

View file

@ -1,12 +0,0 @@
# scopes.tests.util
import logging
def setup_logging(config):
settings = config.settings
hdlr = logging.getLogger().handlers[-1]
logging.getLogger().removeHandler(hdlr) # remove NullHandler added by testrunner
logging.basicConfig(filename=settings.log_file, level=settings.log_level,
format=settings.log_format, datefmt=settings.log_dateformat)

View file

@ -1,13 +1,9 @@
# tests/tlib_storage.py
# scopes/tlib_storage.py
"""Test implementation for the `scopes.storage` package."""
from datetime import datetime
from scopes.storage import concept, folder, message, topic, tracking
from scopes.util import keywords as k
# predefine keywords used as domain, action, class head values in messages
k('session system')
from scopes.storage import concept, folder, topic, tracking
def test_tracking(self, config):
@ -86,7 +82,7 @@ def test_type(self, config):
concept.setupCoreTypes(storage)
types = storage.getContainer(concept.Type)
tps = list(types.query())
self.assertEqual(len(tps), 7)
self.assertEqual(len(tps), 6)
tfolder = types.queryLast(name='folder')
fldrs = list(tfolder.values())
@ -98,8 +94,6 @@ def test_type(self, config):
def test_topic(self, config):
storage = config.storageFactory(config.dbschema)
storage.dropTable('rels')
rels = storage.getContainer(concept.Triple)
storage.dropTable('topics')
topics = storage.getContainer(topic.Topic)
types = storage.getContainer(concept.Type)
@ -118,23 +112,10 @@ def test_topic(self, config):
title='Programming Languages',
description='Programming Languages'))
topics.save(tp_proglang)
#storage.commit() # avoid "database locked" error with sqlite
tp_itc.addChild(tp_proglang)
c = list(tp_itc.children())
self.assertEqual(c[0].name, 'prog_lang')
storage.commit()
def test_message(self, config):
storage = config.storageFactory(config.dbschema)
storage.dropTable('messages')
messages = storage.create(message.Messages)
m01 = message.Message('system', 'data', 'session', 'V1_317784226621611853')
m01.update(dict(userid='tst9'))
mid01 = messages.save(m01)
storage.commit()

View file

@ -1,69 +0,0 @@
# scopes.util
import base64
from collections.abc import Iterable
import hashlib
from secrets import choice
import string
# symbols and keywords
class Symbol(str):
def __repr__(self):
return f'<{self}>'
class Symbols:
def __init__(self, ns=None):
self.__prefix = ns + '.' if ns else ''
def __call__(self, *keys):
for segment in keys:
for key in segment.split():
if not hasattr(self, key):
setattr(self, key, Symbol(self.__prefix + key))
return self
def __getattr__(self, k):
raise AttributeError(k)
def __getitem__(self, k):
return getattr(self, k)
keywords = Symbols()
# list / iterable utilities
def flatten(*it, ignore_none=False):
accu = []
def doit(it):
if ignore_none and it is None:
return
if isinstance(it, Iterable):
for t in it:
doit(t)
else:
accu.append(it)
doit(it)
return accu
# random strings, hashes, encodings
# for authentication, encryption, and other purposes
BASE = string.ascii_letters + string.digits
BASE2 = BASE + '-._~'
def rndstr(size=16):
return ''.join([choice(BASE) for _ in range(size)])
def rndstr2(size=64):
return ''.join([choice(BASE2) for _ in range(size)])
def b64e(b):
return base64.urlsafe_b64encode(b).rstrip(b'=')
def hashS256(s):
h = hashlib.sha256(s.encode('ascii')).digest()
return b64e(h).decode('ascii')

View file

@ -1 +0,0 @@
"""package scopes.web"""

View file

@ -1 +0,0 @@
"""package scopes.web.auth"""

View file

@ -1,312 +0,0 @@
# scopes.web.auth.uidc
from cryptography.fernet import Fernet
from datetime import datetime, timedelta, timezone
from email.utils import formatdate
import json
import jwt
import logging
import requests
from time import time
from urllib.parse import urlencode
from zope.authentication.interfaces import IAuthentication, IPrincipal
from zope.interface import Attribute, Interface, implementer
from zope.publisher.interfaces import Unauthorized
from zope.security.interfaces import IGroupAwarePrincipal
from scopes.web.browser import DefaultView, register
from scopes.storage.folder import DummyFolder, Root
from scopes import util
import config
logger = logging.getLogger('web.auth.oidc')
# OIDC authentication for browser users (principals)
@implementer(IAuthentication)
class OidcAuthentication:
def __init__(self, baseAuth):
self.baseAuth = baseAuth
def authenticate(self, request):
auth = Authenticator(request)
prc = auth.authenticate()
if prc is None and self.baseAuth is not None:
prc = self.baseAuth.authenticate(request)
if prc is None:
prc = self.unauthenticatedPrincipal()
return prc
def getPrincipal(self, id):
if self.baseAuth is not None:
return self.baseAuth.getPrincipal(id)
def getPrincipals(self, s):
if self.baseAuth is not None:
return self.baseAuth.getPrincipals(s)
def unauthenticatedPrincipal(self):
if self.baseAuth is not None:
return self.baseAuth.unauthenticatedPrincipal()
def unauthorized(self, id, request):
if self.baseAuth is not None:
return self.baseAuth.unauthorized(id, request)
Authenticator(request).login()
def logout(self, request):
Authenticator(request).logout()
authentication = OidcAuthentication(None)
class IExternalPrincipal(Interface):
extUserLink = Attribute('Link to OIDC provider for viewing/editing external user')
@implementer(IGroupAwarePrincipal, IExternalPrincipal)
class Principal:
def __init__(self, id, data):
self.id = id
self.data = data
@property
def title(self):
return self.data['name']
@property
def description(self):
return self.data['name']
@property
def groups(self):
return self.data.get('groups', [])
@property
def extUserLink(self):
return config.oidc_provider + '/ui/console/users/me'
def asDict(self):
data = self.data.copy()
data['id'] = self.id
return data
class Authenticator(DummyFolder):
prefix = 'auth.oidc'
group_prefix = 'gloops.'
def __init__(self, request):
self.request = request
self.params = config.oidc_params
self.setCrypt(self.params.get('cookie_crypt'))
def setCrypt(self, key):
self.cookieCrypt = key and Fernet(key) or None
def authenticate(self):
''' return principal or None'''
data = self.loadSession()
logger.debug('authenticate: %s', data)
if data and 'userid' in data:
id = data.pop('userid')
return Principal(id, data)
return None
def login(self):
state = util.rndstr()
nonce = util.rndstr()
codeVerifier = util.rndstr2()
codeChallenge = util.hashS256(codeVerifier)
reqUrl = self.request.form.get('camefrom') or config.base_url
args = dict(
client_id=self.params['client_id'],
response_type='code', # 'code id_token token',
state=state, nonce=nonce,
code_challenge=codeChallenge, code_challenge_method='S256',
scope='openid profile email urn:zitadel:iam:user:resourceowner',
redirect_uri=self.params['callback_url'],
)
self.storeSession(dict(state=state, nonce=nonce, request_uri=reqUrl,
code_verifier=codeVerifier))
authUrl = self.params['op_uris']['authorization_endpoint']
loginUrl = '?'.join((authUrl, urlencode(args)))
logger.debug('login: URL %s', loginUrl)
self.request.response.redirect(loginUrl, trusted=True)
def callback(self, groupsProvider=None):
req = self.request
logger.debug('callback: %s %s', self, req.form)
sdata = self.loadSession()
reqUrl = sdata.get('request_uri') or config.base_url
code = req.form['code']
# !check state: req.form['state'] == sdata['state']
args = dict(
grant_type='authorization_code',
code=code,
redirect_uri=self.params['callback_url'],
client_id=self.params['client_id'],
code_verifier=sdata['code_verifier']
)
# !set header: 'Content-Type: application/x-www-form-urlencoded'
tokenUrl = self.params['op_uris']['token_endpoint']
tokenResponse = requests.post(tokenUrl, data=args)
tdata = tokenResponse.json()
userData = self.getIdTokenData(tdata['id_token'])
userId = userData['sub']
if not '.' in userId:
userId = (self.params.get('principal_prefix', '') +
userData['preferred_username'])
groups = userData.get('urn:zitadel:iam:org:project:roles', {})
groups = set(self.group_prefix + g for g in groups)
if groupsProvider is not None:
groups = groups.union(groupsProvider(userId))
ndata = dict(
userid=userId,
name=userData['name'],
email=userData['email'],
groups=list(groups),
access_token=tdata['access_token'],
session_id=userData['sid'],
)
self.storeSession(ndata)
logger.debug('callback: session data: %s', ndata)
req.response.redirect(reqUrl, trusted=True)
def logout(self):
logoutUrl = self.params['op_uris']['end_session_endpoint']
args = dict(
client_id=self.params['client_id'],
post_logout_redirect_uri=config.oidc_params.get('post_logout_redirect_uri',
config.base_url),
)
logoutUrl = '?'.join((logoutUrl, urlencode(args)))
cname = self.params['cookie_name']
logger.debug('logout, cookie: %s, url: %s', cname, logoutUrl)
self.request.response.expireCookie(cname, path='/')
self.request.response.redirect(logoutUrl, trusted=True)
def storeSession(self, data):
lifetime = int(self.params['cookie_lifetime'])
options = dict(
path='/',
expires=formatdate(time() + lifetime, localtime=False, usegmt=True),
httponly=True,
)
options['max-age'] = lifetime
domain = self.params['cookie_domain']
if domain:
options['domain'] = domain
name = self.params['cookie_name']
value = json.dumps(data)
if self.cookieCrypt:
value = self.cookieCrypt.encrypt(value.encode('UTF-8')).decode('ASCII')
self.request.response.setCookie(name, value, **options)
def loadSession(self):
cookie = self.request.getCookies().get(self.params['cookie_name'])
if cookie is None:
return {}
if self.cookieCrypt:
cookie = self.cookieCrypt.decrypt(cookie)
# !error check: return None - or raise error?
data = json.loads(cookie)
return data
def getIdTokenData(self, token):
uri = self.params['op_uris']['jwks_uri']
keys = loadOidcKeys(uri)
header = jwt.get_unverified_header(token)
key = jwt.PyJWK(keys[header['kid']])
return jwt.decode(token, key, audience=self.params['client_id'])
@register('auth')
def authView(context, request):
return Authenticator(request)
@register('login', Authenticator)
def login(context, request):
context.login()
return DefaultView(context, request)
@register('callback', Authenticator)
def callback(context, request):
context.callback()
return DefaultView(context, request)
@register('logout', Authenticator)
def logout(context, request):
context.logout()
return DefaultView(context, request)
def startup():
try:
loadOidcProviderData()
except requests.exceptions.JSONDecodeError as e:
logger.error(f'oidc.loadOidcProviderData: {e} - OIDC provider not available!')
#app.Publication.registerBeforeTraversal(
# lambda req: req.setPrincipal(authentication.authenticate(req))
oidcProviderUris = ['authorization_endpoint', 'token_endpoint',
'introspection_endpoint', 'userinfo_endpoint',
'revocation_endpoint', 'end_session_endpoint',
'device_authorization_endpoint', 'jwks_uri']
def loadOidcProviderData(force=False):
params = config.oidc_params
if force or params.get('op_uris') is None:
uris = params['op_uris'] = {}
opData = requests.get(params['op_config_url']).json()
for key in oidcProviderUris:
uris[key] = opData[key]
#if force or params.get('op_keys') is None:
#params['op_keys'] = requests.get(uris['jwks_uri']).json()['keys']
def loadOidcKeys(uri):
return dict((item['kid'], item) for item in requests.get(uri).json()['keys'])
# service user authentication
def authenticateClient(paramsName='oidc_params'):
loadOidcProviderData()
params = getattr(config, paramsName)
keyData = loadPrivateKeyData(params['private_key_file'])
userId = keyData['userId']
keyId = keyData['keyId']
key = keyData['key']
now = datetime.now(timezone.utc)
token_lifetime=params.get('api_token_lifetime', 60)
payload = dict(
iss=userId, sub=userId, aud=config.oidc_provider,
iat=now, exp=now + timedelta(minutes=token_lifetime),
)
jwToken = jwt.encode(payload, key, algorithm="RS256",
headers=dict(alg='RS256', kid=keyId))
data = dict(
grant_type='urn:ietf:params:oauth:grant-type:jwt-bearer',
scope=' '.join(('openid', params['op_project_scope'])),
assertion=jwToken,
)
headers = {'Content-Type': 'application/x-www-form-urlencoded'}
url = params['op_uris']['token_endpoint']
resp = requests.post(url, data=data, headers=headers)
if resp.status_code != 200:
#print(resp.text)
logger.error('authenticateClient: %s', resp.text)
return None
tdata = resp.json()
#print(tdata)
return tdata['access_token']
def loadPrivateKeyData(fn='.private-key.json'):
with open(fn) as f:
return json.load(f)

View file

@ -1,98 +0,0 @@
# scopes.org.user
"""Basic user account (principal) definitions + access to identity provider."""
# should be moved to scopes.web.auth
# scopes.org: + person
from dataclasses import dataclass, field
from typing import List, Optional
from scopes.web import client
from scopes import util
import config
@dataclass
class User:
login: str
email: str
hashedPassword: Optional[str] = None
firstName: str = ''
lastName: str = ''
displayName: str = ''
groups: List[str] = field(default_factory=list)
def __post_init__(self):
if not self.displayName:
self.displayName = ' '.join((self.firstName, self.lastName))
class ExtUser:
"""All infos for exchanging user data with an external service.
This base class implements the zitadel interface (as of version 3.3.2).
For other identity providers sublass accordingly.
"""
provider = 'zitatel'
endpoints = dict(
users_human='v2/users/human',
#create_authorization='management/v1/zitadel.authorization.v2beta.AuthorizationService/CreateAuthorization',
create_authorization='v2beta/authorizations',
)
def __init__(self, user, idPrefix=''):
self.user = user
self.userId = idPrefix + user.login
self.client = client.ApiClient(config.oidc_provider)
def asDict(self):
params = config.oidc_params
data = dict(
userId=self.userId,
username=self.user.login,
email=dict(email=self.user.email, isVerified=True),
profile=dict(
givenName=self.user.firstName,
familyName=self.user.lastName,
displayName=self.user.displayName,
),
organization=dict(orgId=params['organization_id']),
)
return data
def create(self, updateIfExists=False):
data = self.asDict()
if self.user.hashedPassword:
data['hashedPassword'] = self.user.hashedPassword
status, res = self.client.post(self.endpoints['users_human'], data)
if status > 201:
if updateIfExists:
return self.update()
return status, res
#if self.user.groups:
#return self.createGroups()
def update(self, createIfMissing=False):
data = self.asDict()
if self.user.hashedPassword:
data['password'] = dict(hashedPassword=self.user.hashedPassword)
status, res = self.client.put(self.endpoints['users_human'], self.userId, data)
if status > 200:
if createIfMissing:
return self.create()
else:
return status, res
#if self.user.groups:
#return self.updateGroups()
def createGroups(self):
data = dict(
userId=self.userId,
projectId=config.oidc_params['project_id'],
roleKeys=self.user.groups,
)
return self.client.post(self.endpoints['create_authorization'], data)

View file

@ -1,43 +0,0 @@
# scopes.web.client
"""Web client functionality: access to web sites, APIs with authentication."""
import logging
import requests
from scopes.web.auth import oidc
logger = logging.getLogger('web.client')
class ApiClient:
def __init__(self, baseUrl, authToken=None):
self.baseUrl = baseUrl
self.authToken = authToken
def authentication(self):
if self.authToken == None:
self.authToken = oidc.authenticateClient()
return dict(Authorization=f'Bearer {self.authToken}')
def post(self, endpoint, data):
headers = self.authentication()
headers['Content-Type'] = 'application/json'
headers['Connect-Protocol-Version'] = '1'
# self.makeUrl(endpoint)
url = '/'.join((self.baseUrl, endpoint))
resp = requests.post(url, json=data, headers=headers)
if resp.status_code >= 400:
logger.error('post %s: %s %s', url, resp.status_code, resp.text)
return resp.status_code, resp.json()
def put(self, endpoint, objId, data):
headers = self.authentication()
headers['Content-Type'] = 'application/json'
# self.makeUrl(endpoint, objId)
url = '/'.join((self.baseUrl, endpoint, objId))
resp = requests.put(url, json=data, headers=headers)
if resp.status_code >= 400:
logger.error('post %s: %s %s', url, resp.status_code, resp.text)
return resp.status_code, resp.json()

View file

@ -1,4 +1,47 @@
from setuptools import setup
from setuptools import setup, find_packages
import os
setup()
version = '2.0'
long_description = (
open('README.md').read()
+ '\n' +
'Contributors\n'
'============\n'
+ '\n' +
open('CONTRIBUTORS.txt').read()
+ '\n' +
open('CHANGES.txt').read()
+ '\n')
setup(name='py-scopes',
version=version,
description="combined triple and event storage for the cco application platform",
long_description=long_description,
# Get more strings from
# http://pypi.python.org/pypi?%3Aaction=list_classifiers
classifiers=[
"Programming Language :: Python",
],
keywords='',
author='cyberconcepts.org team',
author_email='team@cyberconcepts.org',
url='http://www.cyberconcepts.org',
license='MIT',
packages=find_packages(),
#package_dir = {'': 'src'},
#namespace_packages=['cco'],
include_package_data=True,
zip_safe=False,
install_requires=[
'setuptools',
'transaction',
'psycopg2-binary',
'SQLAlchemy',
'zope.sqlalchemy',
# -*- Extra requirements: -*-
],
entry_points="""
# -*- Entry points: -*-
""",
)

View file

@ -1,64 +0,0 @@
# py-scopes/tests/config.py
import logging
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file='.env')
base_url: str = 'https://test.cy7.eu'
log_file: str = 'scopes/tests/log/scopes-test.log'
log_level: str = 'INFO'
log_format: str = '%(asctime)s %(levelname)s %(name)s %(message)s'
log_dateformat: str = '%Y-%m-%dT%H:%M:%S'
dbengine: str = 'sqlite'
dbname: str = 'var/test.db'
dbuser: str | None = None
dbpassword: str | None = None
dbschema: str | None = None
settings = Settings()
from os import getenv
import sys
base_url = settings.base_url
logging.basicConfig(filename=settings.log_file, level=settings.log_level,
format=settings.log_format, datefmt=settings.log_dateformat)
# SQLite storage settings
dbengine = settings.dbengine
dbname = settings.dbname
dbuser = settings.dbuser
dbpassword = settings.dbpassword
dbschema = settings.dbschema
# special testing stuff
from scopes.tests import data_auth # add oidc URIs and keys to dummy_requests data
from scopes.tests import dummy_requests
sys.modules['requests'] = dummy_requests
# authentication settings
oidc_provider = 'test://oidc'
oidc_client_id = getenv('OIDC_CLIENT_ID', '12345')
oidc_params = dict(
op_config_url=oidc_provider + '/.well-known/openid-configuration',
op_uris=None,
op_keys=None,
op_project_scope='urn:zitadel:iam:org:project:id:zitadel:aud',
callback_url=getenv('OIDC_CALLBACK_URL', base_url + '/auth/callback'),
client_id=oidc_client_id,
principal_prefix=getenv('OIDC_PRINCIPAL_PREFIX', 'loops.'),
cookie_name=getenv('OIDC_COOKIE_NAME', 'oidc_' + oidc_client_id),
cookie_domain=getenv('OIDC_COOKIE_DOMAIN', None),
cookie_lifetime=getenv('OIDC_COOKIE_LIFETIME', '86400'),
cookie_crypt=getenv('OIDC_COOKIE_CRYPT', None),
private_key_file=getenv('OIDC_SERVICE_USER_PRIVATE_KEY_FILE',
'scopes/tests/test-private-key.json'),
organization_id=getenv('OIDC_ORGANIZATION_ID', '12346'),
project_id=getenv('OIDC_PROJECT_ID', None),
)

View file

@ -1,55 +0,0 @@
# py-scopes: tests.test_api
"""Tests for the fastapi- (and asyncio-) based stuff"""
from scopes import testing
import os, sys
import config
from scopes.storage import tracking
import pytest
from fastapi.testclient import TestClient
from httpx2 import ASGITransport, AsyncClient
from scopes.api import main
from scopes.api.main import app
@pytest.fixture(scope='module')
def storage():
#storage = config.StorageFactory(config)(config.dbschema)
storage = main.storage()
storage.dropTable('tracks')
tracks = storage.create(tracking.Container)
tr01 = tracking.Track('t01', 'john')
tr01.update(dict(activity='testing'))
trid01 = tracks.save(tr01)
storage.commit()
return storage
def async_client():
return AsyncClient(transport=ASGITransport(app=app), base_url='http://test')
#@pytest.mark.anyio
#async def test_root():
#async with async_client() as clt:
#response = await clt.get('/')
def test_root():
with TestClient(app) as clt:
response = clt.get('/')
assert response.status_code == 200
assert response.json() == {'Hello': 'World'}
def test_tracking(storage, caplog):
with TestClient(app) as clt:
caplog.set_level(config.settings.log_level)
tracks = clt.get('/tracks').json()
assert len(tracks) == 1
tr = tracks[0]
tr['timeStamp'] = '...'
assert tr == {'uid': 'rec-1', 'head': {'taskId': 't01', 'userName': 'john'},
'data': {'activity': 'testing'}, 'timeStamp': '...'}
#print('\nLogs: ', caplog.text)

View file

@ -1,83 +0,0 @@
# py-scopes: tests.test_core
""" Tests for scopes.core modules, including e.g. actor, message"""
import asyncio
import pytest
from scopes import core
from scopes.core import actor, environ
from scopes.core.message import Message
from scopes.util import keywords as k
k('data', 'hello', 'person', 'show', 'switch', 'quit', 'test')
@pytest.fixture
def collector():
return []
def show(msg, coll):
coll.append(msg)
def handle_basic(coll):
async def handle(msg):
match msg:
case k.show: show(msg, coll)
case k.switch: return handle_alt(coll)
case _: show(msg, coll)
return handle
def handle_alt(coll):
async def handle(msg):
match msg:
case k.show: show('switched', coll)
case _: await asyncio.sleep(0.01); show(msg, coll)
return handle
@pytest.mark.anyio
async def test_actor(collector):
mb1 = actor.create(handle_basic(collector))
mb2 = actor.create(handle_basic(collector))
msgs = [Message(k.test, k.hello), k.show, k.switch, k.show, "actor 1: Goodbye"]
await asyncio.gather(*[actor.send(mb1, m) for m in msgs])
await actor.send(mb2, "actor 2")
await actor.send(mb2, k.show)
await actor.send(mb2, k.quit)
await actor.send(mb2, k.quit)
await asyncio.sleep(0.01)
#print(collector)
assert len(collector) == 6
assert collector[-1] == 'actor 1: Goodbye'
def test_message():
msg = Message(k.test, k.data, 'person', '4711',
data=dict(firstName='Bob', lastName='Miller'))
assert msg.head['class'] is k.person
assert msg.addr() == (k.test, k.person, '4711')
assert msg.categ() == (k.test, k.person)
@pytest.fixture
async def meta_env(collector):
async def collect(msg):
collector.append(msg)
return actor.create(collect)
@pytest.mark.anyio
async def test_core(meta_env, collector):
env = environ.create_env(meta_env)
zero = await core.create_zero(env)
await actor.send(env, Message(k.env, k.hello))
await actor.send(zero, Message(k.csys, k.hello))
await actor.send(zero, Message(k.csys, k.create,
data={k.connect: k.pred, k.address: (1, 1)}))
await asyncio.sleep(0.01)
assert len(collector) == 4
assert collector[0].action is k.created
assert collector[1].domain is k.env
assert collector[2].domain is k.csys
assert collector[3].action is k.created
#print(collector)

View file

@ -1,30 +0,0 @@
# py-scopes: tests.test_util
"""Tests for the scopes.util package"""
import pytest
from scopes.util import keywords as k, flatten, Symbols
k('dummy other', 'cd', 'new', 'erp')
dom = Symbols('dom')('crm erp sys')
def test_keywords():
assert k.dummy == 'dummy'
assert k.dummy is not 'dummy'
assert k.dummy is k.dummy
assert k.dummy is not k.other
assert getattr(k, "abcd"[-2:]) is k.cd
assert k['dummy'] is k.dummy
assert k['new'] is k['new']
assert dom.erp == 'dom.erp'
assert dom.erp is not 'dom.erp'
assert repr(dom.erp) == '<dom.erp>'
assert dom.erp != k.erp
def test_flatten():
lst = (1, (None, 2), (3, (4, 5)))
assert flatten(lst) == [1, None, 2, 3, 4, 5]
assert flatten(42, lst, (6, 7), ignore_none=True) == [42, 1, 2, 3, 4, 5, 6, 7]