A Python API to Canary Lab's historian web services.
Project description
Birdsong - A Python interface to the Canary API
Make talking to Canary easy
Canary is a historian from Canary Labs, and birdsong is a library for interfacing with it via Python.
Birdsong will take care of the details of dealing with REST calls, tokens, continuations, and other powerful low level features to let you focus on making Canary sing.
1.4.0Update for Canary's v2 API was kindly brought by Nick Fornicola @nfornicola and brings improvements in getting data, utility functions, and performance!1.3.0Keyring and Arrow datetime support1.2.8Ignition 8 example1.2.0Initial release
Table of Contents
- Installation
- Quickstart
- Usage
- Helper structures
- Sending data to Canary:
CanarySender - Viewing data in Canary:
CanaryView- Exploring Canary:
browseNodes - Exploring tags:
browseTags - Get node status:
browseStatus - Option values: translate quality values:
getQualities - Option values: get aggregate values:
getAggregates - Get tag data:
getTagData - Get tag data (v2):
getTagData2 - Get tag context:
getTagContext - Get annotations:
getAnnotations - Get tag properties:
getTagProperties - Get live tag data:
getLiveData
- Exploring Canary:
- Advanced usage
- Contributing
- License
Installation
Pip
You can use the packages on the PyPI via pip:
python -m pip install birdsong --upgrade
Manual installation
Copy the contents of the Canary folder into your site-packages folder in your Python libs folder.
Depending on your environment, use the Git branch appropriate.
Ignition
Choose the branch for the version of Ignition in use first. From there you may either:
A) Copy the /birdsong directory from this repo into directly into Ignition's ./user-lib/pylib/site-packages/ directory.
On Windows systems this will likely be C:\Program Files\Inductive Automation\Ignition\user-lib\pylib\site-packages
B) Copy the contents of the all-in-one python file into the project library in the Ignition Designer.
That file will be named something like birdsong.ignition_8.py.
Once copied, the install can be tested with the ./test/playground_test.py file in that branch.
(Change the import path if Birdsong was places somewhere other than site-packages or in Project Library/birdsong)
Quickstart
# get started so quick we don't even have time for text outside a code block
from birdsong import CanarySender, CanaryView, Tvq
viewName = 'CS-Surface61'
datasetName = 'Testing2'
tagPath = datasetName + '.Quick Data!!!'
with CanarySender(autoCreateDatasets=True) as sender:
sender.storeData({tagPath: [Tvq('2019-10-20 12:34Z', -666), Tvq('2019-10-20 15:20Z', 999)]})
with CanaryView() as view:
print(next(view.getTagData(viewName + '.' + tagPath)))
{'timestamp': u'2019-10-20T15:20:00.0000000-07:00', 'value': 999}
In this we:
- Imported the Canary interfaces and a convenient helper
- Connected to the localhost Canary Sender service on the default anonymous port
- Stored two datapoints in the
Quick Data!tag in theTesting2dataset (whether or notTestingalready existed - it does now) - Connected to the localhost Canary View service on the default anonymous port
- Got data from the
CS-Surface61view (the name my computer gave my historian)... - ... and immediately consumed the first entry returned via Python's
nextkeyword... - ... and printed it to the console (which the
Tvqclass autoformatted to look like a dictionary) - And after the
withstatements, Python exited the connections (firstviewthensender), revoking tokens as needed to free them up for others to use.
Inside baseball note: if the
viewwas initialized outside the sender service's context (thewithstatement), the value returned would have beenNone, since we didn't tell the sender service to setautoWriteNoData=False. Thus, once thewithends the sender would have automatically marked an end to the data transmission session via aNo Dataentry. Pass in the flag to suppress that, if desired.
Important security notice: noVerifySSL=False by default
Because many Canary instances are on intranets and may or may not have certificates that are easily validated by a trusted central authority, SSL validation is OFF by default.
You may keep warnings on if you set birdsong.rest.VALIDATE_SSL_CERTS = True before initializing a connection to Canary. You may also manually turn on cert validation by passing in verifySSL=True to CanaryView and CanarySender on initialization.
Usage
By default, the connections will attach to localhost over an anonymous connection. If you're testing birdsong on the same machine as a Canary instance, it'll try to connect to that first.
Tokens will be aquired as needed. That means until a call is made that requires it, it won't request a user token. Further, it will keep trying to use the token until Canary throws an error on it; on token expiration, Canary will send an error and the interface class will automatically reaquire. You won't see the error other than a slight additional delay as the new token is reaquired.
Both CanaryView and CanarySender are designed to automatically clean themselves up when they go out of scope. For best practice, use it in a context manager Once instantiated, it will make the HTTP REST calls to Canary with the right user/session/livedata tokens. If they expire, it'll renew them on the next call.
For the purposes of the following examples, assume that Canary is running on
localhost, and that machine is calledCS-Surface61. Examples of how to log in will be sprinkled throughout as well. Also assume that there is a dataset calledTestingin which we'll be putting and pulling most of the data in the following examples.
For demo purposes, we'll assume that Testing is the dataset of choice, and our main view will be CS-Surface61:
import random, string
host = 'localhost'
mainView = 'CS-Surface61'
dataset = 'Testing'
tagPaths = [
'.'.join([dataset, ''.join([random.choice(string.ascii_uppercase) for i in range(6)])])
for _ in range(3)
]
print(tagPaths)
['Testing.ADRLCO', 'Testing.XBCFZF', 'Testing.ITKORQ']
For demo purposes, I'll be referencing these tags, unless stated otherwise.
Helper structures
Three helper classes are provided: Tvq, Property, and Annotation. These are all based on a class that allow these to be created with a bit of flexibility. Importantly, these will ensure values are sent to Canary in the expected order while leaving optional values out.
Note: If Canary returns a date of
0001-01-01T00:00:00.0000000this will be set on thetimestampfields asNone. It's a value returned under some circumstances (like requesting data for a nonexistent tag in a valid dataset), but because it's not a valid timebirdsonginterprets this to make sure it can't be confused for a normal datetime object.
To generate an instance, pass in values either in order or by name:
>>> tvq1 = Tvq('2019-10-01 03:00:00', 3) # quality is optional and assumed 192 GOOD
>>> tvq2 = Tvq('2019-10-01 12:34:56', 999, 216)
>>> tvq1
{'timestamp': '2019-10-01 03:00:00', 'value': 3}
>>> tvq2
{'timestamp': '2019-10-01 12:34:56', 'quality': 216, 'value': 999}
>>> tvq1.quality
None
>>> tvq2.value
999
>>> tvq2.v
999
>>> tvq2['value']
999
>>> Tvq('0001-01-01T00:00:00.0000000-08:00',None)
{'timestamp': None, 'value': None}
The values for these are:
| Helper Class | Attributes |
|---|---|
| Tvq | timestamp, value, quality* |
| Property | name, timestamp, value, quality * |
| Annotation | user, timestamp, value, createdAt* |
*are optional
Note that these will attempt to convert the timestamp to an Arrow datetime object. It's just like a normal datetime object, but a bit smarter and easier to manipulate. Combined with ciso8601, this can quickly convert the timestamps to a highly flexible object.
Each class has a settter like Tvq.setTimeFormat('...') that can be called in case something perverse like a non-ISO8601 date is parsed. Note that a timezone should be set. Canary returns results in a timezone sensitive way - be ever wary of naked timestamps, especially when searching, filtering, and storing data!
Also note that once instantiated these are immutible. These are meant to be treated as read-only since no mechanism to feed directly back on the process is available.
Sending data to Canary: CanarySender
The CanarySender class works just like how views are worked with.
Create new file: createNewFile
Use this to create a new file that's not linked to the previous. Provide it with the dataset that gets a new file and the timestamp to apply to the file.
with CanarySender() as send:
send.createNewFile('Testing', '2019-10-01 00:00')
Create rollover file: fileRollover
Create a new file rolling over from the previous.
with CanarySender() as send:
send.fileRollover('Testing', '2019-10-01 00:00')
Store data: storeData
The storeData method logs both tvq values (time, value, quality) as well as properties and annotations. If there is any question about the tuples that should be sent to Canary, use the helper structs - these will be expanded correctly when sent.
All inputs are dictionaries where the keys are Canary tag paths and the values are lists of entries.
Storing data is as easy as making a dictionary of tags and a list of their TVQ entries.
tvqDict = {
tagPaths[1]: [
('2019-10-01 01:11:11', 1.11),
('2019-10-01 02:22:22', 2.22, 192),
Tvq('2019-10-01 03:33:33', 3.33),
Tvq('2019-10-01 04:44:44', 4.44, 192),
],
tagPaths[2]: [
('2019-10-01 01:00:00', 1),
('2019-10-01 02:00:00', 2, 192),
Tvq('2019-10-01 03:00:00', 3),
Tvq('2019-10-01 04:00:00', 4, 192),
]
}
with CanarySender() as send:
send.storeData(tvqDict)
Store properties like so:
with CanarySender() as send:
send.storeData(properties={
tagPaths[0]: [['Some Property', '10/01/2019 12:00', 'A property value']]
})
See the
getTagPropertiesexample for getting this back from the system.
And annotations likewise:
with CanarySender() as send:
send.storeData(annotations={
tagPaths[0]: [['SHODAN', '11/7/2019 19:11', 'Passcode 711 missing']]
})
Viewing data in Canary: CanaryView
Views are how we look into the data Canary holds. The interface birdsong provides is the CanaryView class.
Pass in the following keywords to
Exploring Canary: browseNodes
If my main Canary instance is on my computer (named CS-Surface61), then browseNodes will list the
from birdsong import CanaryView
with CanaryView() as view:
for node in view.browseNodes():
print(node)
Test Model CS-Surface61
Likewise, we can drill in to get the datasets under a view:
with CanaryView() as view:
for node in view.browseNodes('CS-Surface61'):
print(node)
Testing {Diagnostics}
Exploring tags: browseTags
A tag listing can be retrieved by calling browseTags. The path argument is the root node to search under, while search will narrow the results down to values matching the tag (much as the search works in Axiom). Set deep to True to recursively search a node.
with CanaryView() as view:
for tagPath in view.browseTags(path='CS-Surface61.Testing'):
print tagPath
CS-Surface61.Testing.ADRLCO CS-Surface61.Testing.ITKORQ CS-Surface61.Testing.XBCFZF
My computer happens to have another testing dataset, which shows up in the following:
with CanaryView() as view:
for tagPath in view.browseTags(path='CS-Surface61', search='Testing', deep=True):
print tagPath
CS-Surface61.Testing.BKYTXS CS-Surface61.Testing.FEZWZR CS-Surface61.Testing.OCEGGC CS-Surface61.Testing.QIFAFZ CS-Surface61.Testing.RWNNHP CS-Surface61.Testing.Some.Tag.Path.CV CS-Surface61.Testing2.Quick Data!!!!
Get node status: browseStatus
To find out if a node has been updated, directly query it and check if the sequence number is different:
>>> print(CanaryView().browseStatus('CS-Surface61'))
or muliple views at once:
with CanaryView() as view:
for viewName,sequence in view.browseStatus(['CS-Surface61','Test Model']):
print('%s - %s' % (viewName, sequence))
637078138670000000 - CS-Surface61 637075536560000000 - Test Model
Option values: translate quality values: getQualities
Unless a returned value is 192 (Good), data is returned with a quality value. This is a value as enumerated by the OPC communication standard. Not everyone has all the values memorized, though, so you can look them up with this function.
with CanaryView(host='localhost') as view:
print view.getQualities('90')
{u'90': u'Uncertain-Sub Normal-Limit High'}
Or you can ask for more than one at a time (say from retrieved data)
someDataQualities = [value.quality for value in someData if value.quality]
with CanaryView() as view:
print view.getQualities(someDataQualities)
{u'9': u'Bad-Not Connected-Limit Low', u'90': u'Uncertain-Sub Normal-Limit High', u'210': u'Good-Limit High'}
Option values: get aggregate values: getAggregates
When getting data for a tag, you can set aggregateName to one of the values given by this dictionary.
with CanaryView() as view:
print(sorted(view.getAggregates().keys()))
[u'Average', u'Count', u'Delta', u'DeltaBounds', u'DurationBad', u'DurationGood', u'DurationInStateNonZero', u'DurationInStateZero', u'End', u'EndBound', u'Instant', u'Interpolative', u'Maximum', u'Maximum2', u'MaximumActualTime', u'MaximumActualTime2', u'Minimum', u'Minimum2', u'MinimumActualTime', u'MinimumActualTime2', u'NumberOfTransitions', u'PercentBad', u'PercentGood', u'Range', u'Range2', u'StandardDeviationPopulation', u'StandardDeviationSample', u'Start', u'StartBound', u'TimeAverage', u'TimeAverage2', u'Total', u'Total2', u'TotalPer24Hours', u'TotalPerHour', u'TotalPerMinute', u'VariancePopulation', u'VarianceSample', u'WorstQuality', u'WorstQuality2']
Get tag data: getTagData
To get the most recent value for a tag, simply call getTagData with that tag's path:
# Get the default value (most recent - may well be the No Data value)
with CanaryView() as view:
for value in view.getTagData('CS-Surface61.Testing.ADRLCO'):
print(value)
{'timestamp': u'2019-10-01T04:56:12.0000001-07:00', 'value': None}
Note: as we log data to Canary and close our sessions, Canary will assume the data stream has come to an end and bracket it with
No Data, which will show as aNonein our results here.
To get the values for a tag between two time spans, simply pass in the constraints as arguments:
# Get all values between dates
tagPath = mainView + '.' + dataset + '.' + 'ADRLCO'
with CanaryView() as view:
for value in view.getTagData(tagPath,
startTime='2019-10-01T00:00:00-0700',
endTime='2019-10-01T03:00-0700'):
print(value)
{'timestamp': u'2019-10-01T01:23:45.0000000-07:00', 'value': 1.23} {'timestamp': u'2019-10-01T02:34:56.0000000-07:00', 'value': 2.34}
Any of the constraints outlined in your Canary View's /help endpoint will work.
Getting data for more than one tag simply means passing in a list of tags.
tagList = [mainView + '.' + tagPath for tagPath in tagPaths[1:3]]
# Get the default value (most recent - will be the No Data value)
with CanaryView() as view:
for tagPath, values in view.getTagData(tagList):
print(tagPath)
for value in values:
print('\t%r' % value)
CS-Surface61.Testing.XBCFZF {'timestamp': u'2019-10-01T04:44:44.0000001-07:00', 'value': None} CS-Surface61.Testing.ITKORQ {'timestamp': u'2019-10-01T04:00:00.0000001-07:00', 'value': None}
Pay special attention that the results match the input: if a tag path is given by itself, you'll get back an iterable of values. If a list of tags are given, you'll get back an iterable back of the tag paths and their values. (These will be in the same order given.)
If a start and end time is given it will look like this:
# Get the values for each tag between given dates
with CanaryView() as view:
for tagPath, values in view.getTagData(tagList,
start='2019-10-01T00:00:00-0700',
end='2019-10-01T03:00-0700'):
print(tagPath)
for value in values:
print('\t%r' % value)
CS-Surface61.Testing.XBCFZF {'timestamp': u'2019-10-01T01:11:11.0000000-07:00', 'value': 1.11} {'timestamp': u'2019-10-01T02:22:22.0000000-07:00', 'value': 2.22} CS-Surface61.Testing.ITKORQ {'timestamp': u'2019-10-01T01:00:00.0000000-07:00', 'value': 1} {'timestamp': u'2019-10-01T02:00:00.0000000-07:00', 'value': 2}
Note that start and end were used here. For convenience these are automatically translated to the naming convention Canary expects. (I caught myself writing the wrong suffix too much...)
with CanaryView() as view:
tagProps = view.getTagProperties('CS-Surface61.' + tagPaths[0])
print(tagProps)
{u'Some Property': u'A property value'}Note that this result comes from the later
storeDataroutine.
Passing in a list results in a generator:
tagList = ['CS-Surface61.' + tagPath for tagPath in tagPaths[:2]]
with CanaryView() as view:
for tagPath, propDict in view.getTagProperties(tagList):
print('%s - %r' % (tagPath, propDict))
CS-Surface61.Testing.QIFAFZ - {u'Some Property': u'A property value'} CS-Surface61.Testing.OCEGGC - {}
Get tag data v2: getTagData2
The getTagData2 method is an enhanced version of getTagData that supports Canary API v2 for versions 24.0+. It provides the same functionality as getTagData but with additional features and improved performance.
# Get data using getTagData2
with CanaryView() as view:
for value in view.getTagData2('CS-Surface61.Testing.ADRLCO',
start='2019-10-01T00:00:00-0700',
end='2019-10-01T03:00-0700',
maxSize=100):
print(value)
The method supports the following additional parameters:
useTimeExtension (boolean, default: True): When true, extends the time range to include values just outside the requested range quality (string): Filter values by quality (e.g., 'good', 'bad', 'uncertain') includeBounds (boolean): Include boundary values when filtering
tagList = [mainView + '.' + tagPath for tagPath in tagPaths[1:3]]
with CanaryView() as view:
for tagPath, values in view.getTagData2(tagList,
start='2019-10-01T00:00:00-0700',
end='2019-10-01T03:00-0700'):
print(tagPath)
for value in values:
print('\t%r' % value
Get tag context: getTagContext
The getTagContext method retrieves context information from tags, including the oldest and latest timestamps available.
with CanaryView() as view:
context = view.getTagContext('CS-Surface61.Testing.ADRLCO')
print(context)
{'historianItemId': 'localhost.ACTUAL_PRESS', 'sourceItemId': 'localhost.ACTUAL_PRESS', 'oldestTimeStamp': '2023-06-27T10:13:39.1990629-05:00', 'latestTimeStamp': '2025-05-17T11:14:42.0000000-05:00'}
For multiple tags:
tagList = ['CS-Surface61.' + tagPath for tagPath in tagPaths[:2]]
with CanaryView() as view:
contexts = view.getTagContext(tagList)
print(contexts)
{'CS-Surface61.Testing.ADRLCO': {'historianItemId': 'localhost.PRESS',
> 'sourceItemId': 'localhost.PRESS',
>'oldestTimeStamp': '2023-06-27T10:13:39.1990629-05:00',
>'latestTimeStamp': '2025-05-17T11:14:42.0000000-05:00'},
'CS-Surface61.Testing.XBCFZF': {'historianItemId': 'localhost.FLOW', >'sourceItemId': 'localhost.FLOW', >'oldestTimeStamp': '2021-08-05T23:58:49.2612311-05:00', >'latestTimeStamp': '2025-05-17T11:14:43.0000000-05:00'}}
Get annotations: getAnnotations
The getAnnotations method retrieves annotations for tags within a specified time range.
with CanaryView() as view:
annotations = view.getAnnotations('CS-Surface61.Testing.ADRLCO',
'2019-10-01T00:00:00-0700',
'2019-10-01T03:00-0700')
print(annotations)
[]
For multiple tags:
tagList = ['CS-Surface61.' + tagPath for tagPath in tagPaths[:2]]
with CanaryView() as view:
annotations = view.getAnnotations(tagList,
'2019-10-01T00:00:00-0700',
'2019-10-01T03:00-0700')
print(annotations)
>{'CS-Surface61.Testing.ADRLCO': [], 'CS-Surface61.Testing.XBCFZF': []}
Get tag properties: getTagProperties
Tag properties can be queried by the getTagProperties function. This will return the most recent value set for each property for a tag.
Like the other get iterator methods, this will likewise return a dict object or a generator when a list of tag paths is provided.
Get live tag data: getLiveData
Canary provides a special API call for getting the most recent data since the last time you asked in the getLiveData method. Birdsong will manage the token needed to take advantage of this.
The easiest way to use it is like the regular tag data method:
tagPath = '.'.join([mainView, dataset, tagPaths[0]])
with CanaryView() as view:
for value in view.getLiveData(tagPath):
print(value)
{'timestamp': u'2019-10-27T16:03:10.4280000-07:00', 'value': 0}
This tag happens to have another thread pumping data in via CanarySender().storeData(), so if we connect and check periodically we'll see additional updates as they come in:
stepTime = 3
with CanaryView() as view:
for step in range( (testDuration//stepTime) + 2):
print('Update %d' % step)
for value in view.getLiveData(tagPath):
print(value)
sleep(stepTime)
Update 0 {'timestamp': u'2019-10-27T16:03:12.4490000-07:00', 'value': 100} Update 1 {'timestamp': u'2019-10-27T16:03:14.4590000-07:00', 'value': 200} {'timestamp': u'2019-10-27T16:03:16.4690000-07:00', 'value': 300} Update 2 {'timestamp': u'2019-10-27T16:03:18.4740000-07:00', 'value': 400} Update 3 {'timestamp': u'2019-10-27T16:03:18.4740001-07:00', 'value': None}
Likewise, multiple tags can also be checked. For this example, we'll only be updating tags that aren't the first.
# Check all tags so that we can see the first _not_ get updated in later calls
viewQualifiedTagPaths = [mainView + '.' + tagPath for tagPath in tagPaths]
stepTime = 3
with CanaryView() as view:
for step in range( (testDuration//stepTime)+2):
print('Update %d' % step)
for tagPath,values in view.getLiveData(viewQualifiedTagPaths):
print('\t%s' % tagPath)
for value in values:
print('\t\t%r' % value)
sleep(stepTime)
Update 0 CS-Surface61.Testing.ITKORQ {'timestamp': u'2019-10-27T16:02:39.4040000-07:00', 'value': 1} CS-Surface61.Testing.ADRLCO {'timestamp': u'2019-10-27T16:00:52.1000001-07:00', 'value': None} CS-Surface61.Testing.XBCFZF {'timestamp': u'2019-10-27T16:02:39.4040000-07:00', 'value': 0} Update 1 CS-Surface61.Testing.ITKORQ {'timestamp': u'2019-10-27T16:02:41.4190000-07:00', 'value': 101} CS-Surface61.Testing.XBCFZF {'timestamp': u'2019-10-27T16:02:41.4190000-07:00', 'value': 100} Update 2 CS-Surface61.Testing.ITKORQ {'timestamp': u'2019-10-27T16:02:43.4390000-07:00', 'value': 201} {'timestamp': u'2019-10-27T16:02:45.4500000-07:00', 'value': 301} CS-Surface61.Testing.XBCFZF {'timestamp': u'2019-10-27T16:02:43.4390000-07:00', 'value': 200} {'timestamp': u'2019-10-27T16:02:45.4500000-07:00', 'value': 300} Update 3 CS-Surface61.Testing.ITKORQ {'timestamp': u'2019-10-27T16:02:47.4560000-07:00', 'value': 401} CS-Surface61.Testing.XBCFZF {'timestamp': u'2019-10-27T16:02:47.4560000-07:00', 'value': 400}
Note that CS-Surface61.Testing.ADRLCO didn't show up past the first loop iteration. That's because the tag had no new data (and the None is the result of that - the other two's last update would have shown the same had it been let go one more iteration.)
Advanced usage
Given this is Python, we can do a number of handy things.
For example, we can predefine values for configurations and use **kwarg expansion to map the configuration dict values to the function.
# Send a random number to a tag every 5 seconds forever
from birdsong import CanarySender, Tvq, Annotation
import random
import arrow
senderConfig = {
'username': 'AzureDiamond',
'password': 'hunter2',
'autoCreateDatasets': True,
'autoWriteNoData': False
}
tagPath = 'CS-Surface61.Testing.Random Noise'
with CanarySender(**senderConfig) as send:
rightNow = arrow.utcnow().isoformat()
updateData = {
'tvqs': {tagPath: (rightNow, random.random())},
'annotations': {tagPath: (senderConfig['username'],rightNow,'Inserted Data')}
}
send.storeData(**updateData)
sleep(5)
(Don't use annotations like this, though...)
You can also use the interfaces outside of a context manager. When the object goes out of scope it'll get the connection tokens cleaned up automatically. Note that this will not guarantee cleanup in the event the program shuts down gracelessly (but it'll try, given the chance).
For example, you may want to initiate your live data connection, but not close it. Or you just don't want to indent everything. And, just to be sure, you can close the connection yourself using __exit__. Or to be brutal, go ahead and use the del command.
from birdsong import CanaryView
import arrow
# Create the object.
view = CanaryView()
tagSetOne = [
'CS-Surface61.Testing.Test Tag 1',
'CS-Surface61.Testing.Test Tag 2'
]
tagSetTwo = [
'CS-Surface61.Testing.Some Other Tag'
]
loopCount = 0
while True:
print('Update %d' % loopCount)
for tagPath,values in view.getLiveData(tagSetOne):
print('\t%s' % tagPath)
for value in values:
print('\t\t%r' % value)
for tagPath,values in view.getLiveData(tagSetTwo):
print('\t%s' % tagPath)
for value in values:
print('\t\t%r' % value)
sleep(stepTime)
# Manually close out the connection
view.__exit__()
Contributing
Feel free to send suggestions and bug notices (especially if the API shifts/upgrades and is not caught quickly). Features requests are also welcome, though this is primarily meant to act as an interface wrapper library rather than an extension (though 'unpythonic' constructs will be considered bugs :)
Special thanks to @nfornicola for the update for Canary's v2 API as well as numerous improvements and extra functions!
getTagData2getTagContextgetAnnotations- Tweaks for performance
- Updates to test suite
- ... and documentation (huge help, thanks!)
License
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file birdsong-1.4.0.tar.gz.
File metadata
- Download URL: birdsong-1.4.0.tar.gz
- Upload date:
- Size: 37.2 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.1.0 CPython/3.9.13
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
75797064093401f7b77102a286b4d44dd2484cf91bfb15a86f92717889668e3d
|
|
| MD5 |
b0b5d0ef4a774aadb2e05129646d52dc
|
|
| BLAKE2b-256 |
3c623febd139e9c8ca1b161a62462df798ccfe63ea0cb975654841e0f7f823f0
|
File details
Details for the file birdsong-1.4.0-py3-none-any.whl.
File metadata
- Download URL: birdsong-1.4.0-py3-none-any.whl
- Upload date:
- Size: 28.2 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.1.0 CPython/3.9.13
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
dbf302aa8bedcd8caa3c6a137ad6dd82814c631331640fe340421b2fa9062dca
|
|
| MD5 |
97ad5be75b5f2bfc2f81cfd3b8832d93
|
|
| BLAKE2b-256 |
d16e57d5c100bf747608c2431a221d05f9dca0c3e6f7431a4ef057c41101366a
|